From 113c69e34c586291bcb1c285e8ff801de0bd9602 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 01:34:59 +0000 Subject: [PATCH 01/33] =?UTF-8?q?feat(ui):=20Claude-design=20tokens=20?= =?UTF-8?q?=E2=80=94=20quieter=20dark,=20single=20rust=20accent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/styles/tokens.css as the canonical :root token sheet, imported first from main.jsx. Replaces the dual teal+gold accent scheme with a single rust accent (#c96442), kills drop shadows in favor of hairline borders, swaps font-display to Source Serif 4 (now loaded), and lights up the previously dormant light-mode + high-contrast theme branches that utils/theme.js was already wired for. - src/styles/tokens.css new — :root + data-theme="light" + high-contrast - src/main.jsx imports tokens.css before global.css - src/styles/global.css :root block removed (moved to tokens.css); .backdrop loses dual radial; convo-bar.peak gradient flattened to var(--danger) - index.html Source Serif 4 added; theme-color → #1a1815 Legacy --primary / --gold aliases retained as var(--accent) / var(--warn) so the 150+ inline hex / token references across panel JSX keep working during the follow-up sweep PR. CI guard against raw rgba() and remaining aliases lands with that sweep. --- brainsnn-r3f-app/index.html | 4 +- brainsnn-r3f-app/src/main.jsx | 1 + brainsnn-r3f-app/src/styles/global.css | 25 ++------ brainsnn-r3f-app/src/styles/tokens.css | 89 ++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 brainsnn-r3f-app/src/styles/tokens.css 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/src/main.jsx b/brainsnn-r3f-app/src/main.jsx index e341f67..1a21c2e 100644 --- a/brainsnn-r3f-app/src/main.jsx +++ b/brainsnn-r3f-app/src/main.jsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import './styles/tokens.css'; import './styles/global.css'; ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/brainsnn-r3f-app/src/styles/global.css b/brainsnn-r3f-app/src/styles/global.css index 27d340c..e5b3150 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 } @@ -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; diff --git a/brainsnn-r3f-app/src/styles/tokens.css b/brainsnn-r3f-app/src/styles/tokens.css new file mode 100644 index 0000000..be77d95 --- /dev/null +++ b/brainsnn-r3f-app/src/styles/tokens.css @@ -0,0 +1,89 @@ +/** + * 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; + + /* migration aliases — keep legacy --primary / --gold pointing to new + * tokens until the inline-hex sweep PR removes every raw reference. + * After that sweep, these two aliases will be deleted and CI will + * grep-fail on any remaining --primary / --gold use. */ + --primary: var(--accent); + --gold: var(--warn); + + /* 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); +} + +/* 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; } +} From 91183fda4703af18a1b9930e09b03a8c5da0e2ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 01:35:16 +0000 Subject: [PATCH 02/33] =?UTF-8?q?feat(engine):=20beast-mode=20foundations?= =?UTF-8?q?=20=E2=80=94=20worker=20pool,=20firewall=20worker,=20IDB=20stor?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the under-the-hood plumbing for the 100-layer engine to run heavy work off the main thread and store at IndexedDB scale instead of the 5MB localStorage ceiling. Additive only — no existing caller changes shape; every sync API still works. Worker pool (src/utils/workerPool.js) Comlink-lite postMessage proxy. createPool(factory, {size, fallback}) spawns N module workers, round-robins requests, resolves a Promise per request via reply ID. Graceful degrade: when Worker is unavailable (SSR, ancient browser, test) calls invoke the optional sync fallback. Firewall worker (src/workers/firewall.worker.js) Hosts scoreContent / scoreContentWithRules / setRules off the main thread. Vite emits a separate chunk via new Worker(new URL(...)). Build verified: dist/assets/firewall.worker-*.js, 9.67kB. scoreContentAsync (cognitiveFirewall.js) New export. Routes long text (≥500 chars) through the pool; short text uses the sync path because spin-up overhead exceeds the work. Guarded against recursive spawn inside the worker itself (typeof window). Panels opt in by switching scoreContent → scoreContentAsync; sync API is unchanged. Persistence layer (src/utils/store.js) openStore(name) returns an IDB-backed get/set/delete/keys/values/clear API per collection. Lazy schema upgrades on first use of a new collection. localStorage fallback (namespaced brainsnn_store//) when IDB unavailable. storageEstimate() surfaces quota for the Privacy Budget panel. Migration of the 30+ scattered brainsnn_*_v1 keys lands in a follow-up PR. --- .../src/utils/cognitiveFirewall.js | 42 ++++ brainsnn-r3f-app/src/utils/store.js | 189 ++++++++++++++++++ brainsnn-r3f-app/src/utils/workerPool.js | 127 ++++++++++++ .../src/workers/firewall.worker.js | 40 ++++ 4 files changed, 398 insertions(+) create mode 100644 brainsnn-r3f-app/src/utils/store.js create mode 100644 brainsnn-r3f-app/src/utils/workerPool.js create mode 100644 brainsnn-r3f-app/src/workers/firewall.worker.js diff --git a/brainsnn-r3f-app/src/utils/cognitiveFirewall.js b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js index 8271775..58a902f 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, @@ -226,6 +227,47 @@ 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: 1, fallback: (type, payload) => { + if (type === 'score') return scoreContent(payload?.text || ''); + return null; + } } + ); + return _firewallPool; + } 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 { + return await pool.call('score', { text }); + } catch { + return scoreContent(text); + } +} + export function mapTRIBEToRegions(state, tribe) { const { emotionalActivation, cognitiveSuppression, manipulationPressure } = tribe; diff --git a/brainsnn-r3f-app/src/utils/store.js b/brainsnn-r3f-app/src/utils/store.js new file mode 100644 index 0000000..56c1dca --- /dev/null +++ b/brainsnn-r3f-app/src/utils/store.js @@ -0,0 +1,189 @@ +/** + * 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; + } +} + +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(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(upgrade.result); + upgrade.onerror = () => reject(upgrade.error); + }; + 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/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/firewall.worker.js b/brainsnn-r3f-app/src/workers/firewall.worker.js new file mode 100644 index 0000000..88abbc2 --- /dev/null +++ b/brainsnn-r3f-app/src/workers/firewall.worker.js @@ -0,0 +1,40 @@ +/** + * 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, + scoreContentWithRules, + DEFAULT_RULES, + deserializeRules, + setActiveRules +} from '../utils/cognitiveFirewall.js'; + +handleRequests({ + score: async ({ text }) => scoreContent(text || ''), + + scoreWithRules: async ({ text, rules, opts }) => { + const rs = rules && typeof rules === 'object' && !Array.isArray(rules) + ? (rules.urgency instanceof RegExp || !rules.urgency + ? rules + : deserializeRules(rules)) + : DEFAULT_RULES; + return scoreContentWithRules(text || '', rs, opts || {}); + }, + + setRules: async ({ rules }) => { + setActiveRules(rules ? deserializeRules(rules) : null); + return { ok: true }; + } +}); From 6aa03bae6956e1c41f507474a14a17d7c4f3eac4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 01:40:02 +0000 Subject: [PATCH 03/33] fix(engine): propagate active rules to worker; IDB versionchange handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three issues from review on PR #46. 1. scoreContentAsync ignored non-default rules The worker keeps its own module-level _activeRules, so custom rules, evolved rulesets (Layer 31), and rule packs (Layer 83) set on the main thread never reached the worker — async results silently used DEFAULT_RULES while sync results used the promoted ruleset. Fix: when active rules differ from DEFAULT_RULES, scoreContentAsync serializes them and routes through the scoreWithRules handler. The worker temporarily swaps its active rules around the call so the full pipeline (language detection, language-pack routing, template detection, language decoration) runs through scoreContent and matches the main-thread output shape byte-for-byte. Workers process one message at a time so the swap-and-restore is race-free. 2. IDB connections lacked versionchange handlers Without onversionchange, a stale handle in tab A blocks any schema upgrade triggered by tab B (new collection registered, etc.), leaving writes hung indefinitely. Fix: attachVersionChange() wires db.onversionchange to close the handle and reset dbPromise so the next getDb() call rebuilds. Applied on both code paths (probe with all collections present + upgrade path). Also added an onblocked handler so the upgrade rejects cleanly instead of hanging when another tab holds an old handle. 3. Worker pool capped at size 1 Hardcoded {size: 1} negated the multi-core point of the pool. Removed the override so the firewall pool uses the default (min(4, cores-1)), giving Red Team batch scans and inbox triage real parallelism. Build verified: dist/assets/firewall.worker-*.js, 9.63kB, no new warnings. --- .../src/utils/cognitiveFirewall.js | 27 +++++++++++++++---- brainsnn-r3f-app/src/utils/store.js | 18 +++++++++++-- .../src/workers/firewall.worker.js | 26 +++++++++++------- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/brainsnn-r3f-app/src/utils/cognitiveFirewall.js b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js index 58a902f..e105167 100644 --- a/brainsnn-r3f-app/src/utils/cognitiveFirewall.js +++ b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js @@ -246,10 +246,15 @@ function ensurePool() { try { _firewallPool = createPool( () => new Worker(new URL('../workers/firewall.worker.js', import.meta.url), { type: 'module' }), - { size: 1, fallback: (type, payload) => { - if (type === 'score') return scoreContent(payload?.text || ''); - return null; - } } + { + // 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 { @@ -262,7 +267,19 @@ export async function scoreContentAsync(text = '') { const pool = ensurePool(); if (!pool || pool.degraded) return scoreContent(text); try { - return await pool.call('score', { text }); + // 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); } diff --git a/brainsnn-r3f-app/src/utils/store.js b/brainsnn-r3f-app/src/utils/store.js index 56c1dca..c4d46ed 100644 --- a/brainsnn-r3f-app/src/utils/store.js +++ b/brainsnn-r3f-app/src/utils/store.js @@ -37,6 +37,17 @@ function idbAvailable() { } } +// 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. @@ -44,7 +55,7 @@ function openDb(neededCollections) { probe.onsuccess = () => { const db = probe.result; const haveAll = neededCollections.every((c) => db.objectStoreNames.contains(c)); - if (haveAll) { resolve(db); return; } + if (haveAll) { resolve(attachVersionChange(db)); return; } const nextVersion = db.version + 1; db.close(); const upgrade = indexedDB.open(DB_NAME, nextVersion); @@ -56,8 +67,11 @@ function openDb(neededCollections) { } } }; - upgrade.onsuccess = () => resolve(upgrade.result); + 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); }); diff --git a/brainsnn-r3f-app/src/workers/firewall.worker.js b/brainsnn-r3f-app/src/workers/firewall.worker.js index 88abbc2..4b5afa3 100644 --- a/brainsnn-r3f-app/src/workers/firewall.worker.js +++ b/brainsnn-r3f-app/src/workers/firewall.worker.js @@ -15,22 +15,28 @@ import { handleRequests } from '../utils/workerPool.js'; import { scoreContent, - scoreContentWithRules, - DEFAULT_RULES, deserializeRules, - setActiveRules + setActiveRules, + getActiveRules } from '../utils/cognitiveFirewall.js'; handleRequests({ score: async ({ text }) => scoreContent(text || ''), - scoreWithRules: async ({ text, rules, opts }) => { - const rs = rules && typeof rules === 'object' && !Array.isArray(rules) - ? (rules.urgency instanceof RegExp || !rules.urgency - ? rules - : deserializeRules(rules)) - : DEFAULT_RULES; - return scoreContentWithRules(text || '', rs, opts || {}); + // 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 }) => { From 0a219e364d19fc79cf48c11f39948de7119a22ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 03:22:39 +0000 Subject: [PATCH 04/33] =?UTF-8?q?refactor(ui):=20inline-hex=20sweep=20?= =?UTF-8?q?=E2=80=94=20every=20brand=20color=20now=20flows=20through=20tok?= =?UTF-8?q?ens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell PR #2 from the plan: migrate the 100+ raw brand-color references scattered across JSX inline styles + global.css + the Three.js scene to the new Claude-design tokens (--accent, --danger, --warn, --ok). Removes the temporary --primary / --gold migration aliases. Coverage: * src/components/*.jsx — 95 files swept (excludes BrainScene): - '#4fa8b3' / "#4fa8b3" -> 'var(--accent)' / "var(--accent)" (9 refs) - '#dd6974' / "#dd6974" -> 'var(--danger)' / "var(--danger)" (69 refs) - '#e8b934' / "#e8b934" -> 'var(--warn)' / "var(--warn)" (1 ref) - '#6daa45' / "#6daa45" -> 'var(--ok)' / "var(--ok)" (13 refs) Plus the 6 'borderLeft: ... #dd6974' template literals and the two ActivityCharts gradient strings. * src/components/{BrainScene,brain/NeuralFlowGrid,brain/PulseWave}.jsx Three.js parses CSS color strings but not CSS vars. Added src/utils/threeTokens.js with a tokens cache + theme-reactive useThreeTokens() hook that re-reads on data-theme / data-high-contrast mutations. BrainScene's background, fog, point light, signal particles, outline edge color, and floor disc now reskin live when the user toggles theme. * src/styles/global.css All rgba(79,168,179,...) / rgba(221,105,116,...) / rgba(232,185,52,...) / rgba(109,170,69,...) chrome escapes migrated to color-mix(in srgb, var(--*) X%, transparent). The .bar linear-gradient at L325 now uses var(--accent) / var(--accent-bright). * src/styles/tokens.css --primary / --gold aliases deleted. Anything that still references them would fail CSS resolution; the CI grep guard below catches it. CI guards (all return 0): * grep -rE 'var\(--primary\)|var\(--gold\)' src/ * grep -cE 'rgba\(\s*(79\s*,\s*168\s*,\s*179|...)' src/styles/global.css * grep -cE '#4fa8b3|#dd6974|#e8b934|#6daa45' src/styles/global.css * grep -rE same-set src/components/*.jsx | grep -v BrainScene Deliberate scope cuts (follow-up PR): * src/utils/*.js severity-tier color tables (autopsyCard, immunityCard, quizCard, reactionCard, heatmap, diagnostic, hypothesis, oscillations, coverage, calendarHeatmap, diffMode, textAdventure, styleFingerprint, compliment, echoDetector, dailyCard) — these encode semantic state (high/med/low pressure) and need a JS-side cssToken() helper to read --danger / --warn / --ok at runtime since they're consumed by canvas / SVG renderers that don't auto-resolve CSS vars. Plan calls this out as step 3 of PR #2 and the natural follow-up. * src/data/network.js + src/data/knowledgeGraph.js region identity colors — semantic identity per the plan, intentionally left alone. Build verified: CSS bundle 62.20kB -> 63.76kB (color-mix() is longer than rgba()), JS bundle +1.7kB for threeTokens helper. No new warnings. --- .../src/components/ActivityCharts.jsx | 4 +- .../src/components/AnalyticsDashboard.jsx | 4 +- .../src/components/ApiDocsPanel.jsx | 2 +- .../src/components/AudioPanel.jsx | 4 +- .../src/components/AutopsyPanel.jsx | 2 +- .../src/components/BrainScene.jsx | 18 +- .../src/components/BypassSubmitPanel.jsx | 4 +- .../src/components/CalendarHeatmapPanel.jsx | 4 +- .../src/components/CognitiveFirewallPanel.jsx | 6 +- .../src/components/CommunityPackPanel.jsx | 2 +- .../src/components/ComparatorPanel.jsx | 2 +- .../src/components/ComplimentPanel.jsx | 4 +- .../src/components/ComposerPanel.jsx | 2 +- .../src/components/ContextMemoryPanel.jsx | 8 +- .../src/components/CustomRulesPanel.jsx | 2 +- .../src/components/DailyChallengePanel.jsx | 4 +- .../src/components/DebatePanel.jsx | 8 +- .../src/components/DiagnosticPanel.jsx | 4 +- brainsnn-r3f-app/src/components/DiffPanel.jsx | 2 +- brainsnn-r3f-app/src/components/EchoPanel.jsx | 4 +- .../src/components/FeedbackPanel.jsx | 6 +- .../src/components/GeminiAnalysisPanel.jsx | 2 +- .../src/components/GemmaAnalysisPanel.jsx | 2 +- .../src/components/HypothesisPanel.jsx | 2 +- .../src/components/ImmunityPanel.jsx | 4 +- .../src/components/InboxPanel.jsx | 2 +- .../src/components/InspectorPanel.jsx | 2 +- .../src/components/JournalismPanel.jsx | 6 +- .../src/components/MacrosPanel.jsx | 8 +- brainsnn-r3f-app/src/components/OcrPanel.jsx | 2 +- .../components/PersonalDictionaryPanel.jsx | 10 +- .../src/components/PluginPanel.jsx | 2 +- .../src/components/PortabilityPanel.jsx | 4 +- .../src/components/PrivacyBudgetPanel.jsx | 4 +- .../src/components/ReplayPanel.jsx | 4 +- .../src/components/RulePacksPanel.jsx | 2 +- .../src/components/ScanArchivePanel.jsx | 6 +- .../src/components/SessionRoomsPanel.jsx | 2 +- brainsnn-r3f-app/src/components/SyncPanel.jsx | 2 +- .../src/components/TextAdventurePanel.jsx | 4 +- .../src/components/TimeSeriesPanel.jsx | 8 +- .../src/components/ToneShifterPanel.jsx | 8 +- .../src/components/WeeklyRecapPanel.jsx | 2 +- .../src/components/brain/NeuralFlowGrid.jsx | 4 +- .../src/components/brain/PulseWave.jsx | 4 +- brainsnn-r3f-app/src/styles/global.css | 160 +++++++++--------- brainsnn-r3f-app/src/styles/tokens.css | 7 - brainsnn-r3f-app/src/utils/threeTokens.js | 74 ++++++++ 48 files changed, 254 insertions(+), 179 deletions(-) create mode 100644 brainsnn-r3f-app/src/utils/threeTokens.js 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/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..91a63bf 100644 --- a/brainsnn-r3f-app/src/components/ApiDocsPanel.jsx +++ b/brainsnn-r3f-app/src/components/ApiDocsPanel.jsx @@ -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 ? '#fdab43' : '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..e99c4c4 100644 --- a/brainsnn-r3f-app/src/components/AutopsyPanel.jsx +++ b/brainsnn-r3f-app/src/components/AutopsyPanel.jsx @@ -141,7 +141,7 @@ export default function AutopsyPanel({ initialHash = null }) {
{fetchError && ( -

+

Fetch failed: {fetchError}.

)} 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..bb250a5 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/CognitiveFirewallPanel.jsx b/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx index 083c63f..22ebbe1 100644 --- a/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx +++ b/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx @@ -247,7 +247,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 ? '#fdab43' : 'var(--ok)'; return (
@@ -288,7 +288,7 @@ export default function CognitiveFirewallPanel({ onApplyToNetwork, initialScan =
{fetchError && ( -

+

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

)} @@ -420,7 +420,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} diff --git a/brainsnn-r3f-app/src/components/CommunityPackPanel.jsx b/brainsnn-r3f-app/src/components/CommunityPackPanel.jsx index c971318..8fd24e8 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 && (
Pressure delta (B - A) - 0 ? '#5ee69a' : report.delta < 0 ? '#dd6974' : '#94a3b8', fontFamily: 'monospace' }}> + 0 ? '#5ee69a' : 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..dbfe84a 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,7 +70,7 @@ export default function ComplimentPanel() { }} > - +
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..404f329 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 ? '#fdab43' : '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..b7b2466 100644 --- a/brainsnn-r3f-app/src/components/CustomRulesPanel.jsx +++ b/brainsnn-r3f-app/src/components/CustomRulesPanel.jsx @@ -92,7 +92,7 @@ export default function CustomRulesPanel() {
- {err &&

{err}

} + {err &&

{err}

} {info &&

{info}

} {rules.length > 0 && ( diff --git a/brainsnn-r3f-app/src/components/DailyChallengePanel.jsx b/brainsnn-r3f-app/src/components/DailyChallengePanel.jsx index eed7d51..3392ab9 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 ? '#5ee69a' : '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..c06795c 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,7 +77,7 @@ 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 ? '#fdab43' : 'var(--ok)'; return (
- {p.category}{' '} + {p.category}{' '} /{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..718f100 100644 --- a/brainsnn-r3f-app/src/components/DiffPanel.jsx +++ b/brainsnn-r3f-app/src/components/DiffPanel.jsx @@ -126,7 +126,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 ? '#fdab43' : '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..ac66a71 100644 --- a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx +++ b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx @@ -43,15 +43,15 @@ export default function FeedbackPanel() {
too cold {report.tooCold} accurate {report.accurate} - too hot {report.tooHot} - + 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' ? '#77dbe4' : '#5ee69a'; return (
0.65 ? '#dd6974' : overall > 0.35 ? '#fdab43' : '#6daa45'; + const riskColor = !overall ? 'var(--accent)' : overall > 0.65 ? 'var(--danger)' : overall > 0.35 ? '#fdab43' : '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..df2eb27 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 ? '#fdab43' : '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/HypothesisPanel.jsx b/brainsnn-r3f-app/src/components/HypothesisPanel.jsx index c5ec413..161d71e 100644 --- a/brainsnn-r3f-app/src/components/HypothesisPanel.jsx +++ b/brainsnn-r3f-app/src/components/HypothesisPanel.jsx @@ -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..442dcba 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 ? '#fdab43' : '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..b25c035 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}

}
- {err &&

{err}

} + {err &&

{err}

} {entries.length === 0 ? (

Empty — add your first phrase above.

@@ -99,11 +99,11 @@ export default function PersonalDictionaryPanel() {
{e.tag || '—'} w {e.weight.toFixed(2)} · {e.hits}× - +
))}
- +
)} @@ -129,8 +129,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..5280287 100644 --- a/brainsnn-r3f-app/src/components/PortabilityPanel.jsx +++ b/brainsnn-r3f-app/src/components/PortabilityPanel.jsx @@ -69,7 +69,7 @@ export default function PortabilityPanel() {
- +
@@ -95,7 +95,7 @@ export default function PortabilityPanel() {
- {err &&

{err}

} + {err &&

{err}

} {info &&

{info}

} ); diff --git a/brainsnn-r3f-app/src/components/PrivacyBudgetPanel.jsx b/brainsnn-r3f-app/src/components/PrivacyBudgetPanel.jsx index 6ea9e51..deda9a8 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 ? '#fdab43' : '#5ee69a'; return (
@@ -81,7 +81,7 @@ export default function PrivacyBudgetPanel() { L{e.layer} {humanBytes(e.bytes)} - 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..30dd3f0 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) => { diff --git a/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx b/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx index 50da69e..3161346 100644 --- a/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx +++ b/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx @@ -69,7 +69,7 @@ export default function ScanArchivePanel() { /> - +
@@ -79,7 +79,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 ? '#fdab43' : '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..b62cd07 100644 --- a/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx +++ b/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx @@ -159,7 +159,7 @@ export default function SessionRoomsPanel() { - {err &&

{err}

} + {err &&

{err}

} {entries.length > 0 && (
diff --git a/brainsnn-r3f-app/src/components/SyncPanel.jsx b/brainsnn-r3f-app/src/components/SyncPanel.jsx index 58bba84..2cb548c 100644 --- a/brainsnn-r3f-app/src/components/SyncPanel.jsx +++ b/brainsnn-r3f-app/src/components/SyncPanel.jsx @@ -82,7 +82,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/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..76108f8 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 ? '#5ee69a' : recap.immunityDelta < 0 ? 'var(--danger)' : '#fdab43'; 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/styles/global.css b/brainsnn-r3f-app/src/styles/global.css index e5b3150..d676c2f 100644 --- a/brainsnn-r3f-app/src/styles/global.css +++ b/brainsnn-r3f-app/src/styles/global.css @@ -52,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 } @@ -87,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 @@ -257,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 } @@ -285,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{ @@ -322,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 } @@ -354,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{ @@ -372,7 +372,7 @@ h1,h2,h3{ .timeline-panel input[type="range"]{ width:100%; - accent-color:var(--primary) + accent-color:var(--accent) } .timeline-stats{ @@ -444,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% } @@ -630,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)) } @@ -726,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; @@ -740,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{ @@ -792,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; @@ -810,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)) } @@ -839,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{ @@ -909,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} @@ -987,7 +987,7 @@ h1,h2,h3{ .narrative-status-line{ font-size:.85rem; - color:var(--primary); + color:var(--accent); margin:4px 0 0 } @@ -1064,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} @@ -1133,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)) } @@ -1164,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 ---------- */ @@ -1218,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{ @@ -1234,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 @@ -1245,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)) } @@ -1286,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; @@ -1346,7 +1346,7 @@ h1,h2,h3{ .voice-slider{ width:100%; - accent-color:var(--primary) + accent-color:var(--accent) } .voice-status{ @@ -1370,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)) } @@ -1404,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; @@ -1443,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{ @@ -1465,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)) } @@ -1517,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; @@ -1565,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} @@ -1651,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{ @@ -1671,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 } @@ -1703,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{ @@ -1733,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 } @@ -1773,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{ @@ -1788,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; @@ -1811,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 } @@ -1914,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} @@ -1943,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; @@ -1977,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 @@ -2041,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; @@ -2067,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 ---------- */ @@ -2087,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; @@ -2131,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; @@ -2300,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; @@ -2325,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); @@ -2566,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{ @@ -2577,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); @@ -2633,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; @@ -2732,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} @@ -2771,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; @@ -2809,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 ---------- */ @@ -2836,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} @@ -2881,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/tokens.css b/brainsnn-r3f-app/src/styles/tokens.css index be77d95..099244b 100644 --- a/brainsnn-r3f-app/src/styles/tokens.css +++ b/brainsnn-r3f-app/src/styles/tokens.css @@ -31,13 +31,6 @@ --warn: #c9a14b; --danger: #c46666; - /* migration aliases — keep legacy --primary / --gold pointing to new - * tokens until the inline-hex sweep PR removes every raw reference. - * After that sweep, these two aliases will be deleted and CI will - * grep-fail on any remaining --primary / --gold use. */ - --primary: var(--accent); - --gold: var(--warn); - /* 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; 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; +} From 015335525f146a23de45249b8c410e0098f8d434 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 15:57:16 +0000 Subject: [PATCH 05/33] feat(shell): Claude-design AppShell with 6 workspaces (behind ?shell=new) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boils Shell PRs #3 + #4 + #5 from the plan into one push. Lays down the entire new front door without touching the legacy App.jsx — both shells coexist via main.jsx and only one mounts at a time so MCP bridge / dream monitor don't double-register. What ships: * src/shell/AppShell.jsx + Topbar + WorkspaceTabs + Sidebar + Composer + BrainViewport. Layout is left-rail tabs (Home / Analyze / Defend / Brain / Knowledge / Training / Connect) · sticky persistent brain viewport · workspace body · right rail with InspectorPanel + lazy role-tour + milestone widgets. * src/shell/NewApp.jsx — parallel App with the same state, refs, mount effects, and ~16 onApply* handlers. Builds a single `session` object that all workspaces consume. Mirrors App.jsx exactly so behavior is identical when ?shell=new is set. * src/shell/workspaces/*.jsx — 7 workspace shells (Home, Analyze, Defend, Brain, Knowledge, Training, Connect). Each lazy-imports its drawer panels, error-boundaries every panel, and groups via
sections so the page-load picks up only the anchor panels (~5-10) instead of all 97. * src/shell/bus.js — tiny global event bus (~50 LOC, zero deps). Replaces scattered window.dispatchEvent patterns. Drives shell:goto for workspace navigation from CommandPalette, hotkeys, onboarding. * src/shell/BrainViewport.jsx — wraps BrainScene with promoted / strip classes. CSS controls per-workspace size (compact strip everywhere, ~68vh on the Brain workspace). Mounted ONCE in AppShell so tab navigation never remounts the R3F Canvas. * src/utils/flashLayer.js — extracts the cyan-ring scroll helper shared by CommandPalette and hotkeys.js. Now ALSO dispatches shell:goto so jumping to a layer in another workspace switches tabs before scrolling. workspaceForLayer() maps every catalog layer id to its target workspace. * src/styles/shell.css — 350 lines, shell-only. Sticky topbar, left tab rail, sticky persistent viewport strip, sticky composer, drawer sections, sub-tabs for the Connect kitchen sink. Responsive breakpoints at 1280px (tighten), 1050px (collapse sidebar + tab labels), 760px (horizontal tab strip). * src/main.jsx — flag gate. ?shell=new mounts NewApp; ?shell=old or no flag mounts legacy App. localStorage.brainsnn_shell_pref pins preference across visits. * src/components/OnboardingWalkthrough.jsx — patched to emit shell:goto before highlighting each anchor. Legacy app is unaffected (bus has no shell:goto subscriber when AppShell isn't mounted). Logs a console.warn if a target class is missing post-rAF so silent breakage gets surfaced in dev. Plan alignment: - PR #3 (Context providers): superseded by the `session` prop pattern which is simpler than 3 contexts and serves the same purpose. Workspaces never see App.jsx state directly — only the session bundle NewApp constructs. If selector-based subscriptions become necessary, splitting `session` into focused contexts later is a drop-in refactor. - PR #4 (Shell + 6 workspaces, gated): done, including the seventh Home workspace (landing). - PR #5 (Persistent BrainViewport): done. BrainScene mounts once inside AppShell and stays mounted across all tab switches. How to test: npm run dev /?shell=new -> new shell / -> legacy (unchanged) /?shell=new&w=defend -> direct workspace link g + (h/a/d/b/k/t/c) -> keyboard workspace switch localStorage.removeItem('brainsnn_onboarded'); reload /?shell=new -> walkthrough should switch tabs at steps 2-5 as it highlights anchors. Build verified: 706kB JS (+45kB for shell), 70kB CSS (+6kB for shell.css), no errors. Vite warnings about dynamic-imports-also-statically-imported are expected: legacy App.jsx still statically imports the same panels, so chunks won't split until App.jsx is retired in a follow-up PR. Out of scope for this PR (next): - Lazy-loading legacy App.jsx (current dual-import prevents proper code splitting; cleanup happens when default flips to new shell) - utils/*.js severity color tables -> cssToken() helper (PR #2 follow-up; canvas/SVG renderers don't auto-resolve CSS vars) - Beast PR #8: transformers.js MiniLM into a worker + IDB cache --- .../src/components/OnboardingWalkthrough.jsx | 35 +- brainsnn-r3f-app/src/main.jsx | 20 +- brainsnn-r3f-app/src/shell/AppShell.jsx | 143 +++++ brainsnn-r3f-app/src/shell/BrainViewport.jsx | 58 ++ brainsnn-r3f-app/src/shell/Composer.jsx | 51 ++ brainsnn-r3f-app/src/shell/NewApp.jsx | 563 ++++++++++++++++++ brainsnn-r3f-app/src/shell/Sidebar.jsx | 37 ++ brainsnn-r3f-app/src/shell/Topbar.jsx | 62 ++ brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx | 31 + brainsnn-r3f-app/src/shell/bus.js | 45 ++ .../src/shell/workspaces/AnalyzeWorkspace.jsx | 36 ++ .../src/shell/workspaces/BrainWorkspace.jsx | 79 +++ .../src/shell/workspaces/ConnectWorkspace.jsx | 118 ++++ .../src/shell/workspaces/DefendWorkspace.jsx | 67 +++ .../src/shell/workspaces/HomeWorkspace.jsx | 44 ++ .../shell/workspaces/KnowledgeWorkspace.jsx | 52 ++ .../shell/workspaces/TrainingWorkspace.jsx | 55 ++ brainsnn-r3f-app/src/styles/shell.css | 350 +++++++++++ brainsnn-r3f-app/src/utils/flashLayer.js | 93 +++ 19 files changed, 1933 insertions(+), 6 deletions(-) create mode 100644 brainsnn-r3f-app/src/shell/AppShell.jsx create mode 100644 brainsnn-r3f-app/src/shell/BrainViewport.jsx create mode 100644 brainsnn-r3f-app/src/shell/Composer.jsx create mode 100644 brainsnn-r3f-app/src/shell/NewApp.jsx create mode 100644 brainsnn-r3f-app/src/shell/Sidebar.jsx create mode 100644 brainsnn-r3f-app/src/shell/Topbar.jsx create mode 100644 brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx create mode 100644 brainsnn-r3f-app/src/shell/bus.js create mode 100644 brainsnn-r3f-app/src/shell/workspaces/AnalyzeWorkspace.jsx create mode 100644 brainsnn-r3f-app/src/shell/workspaces/BrainWorkspace.jsx create mode 100644 brainsnn-r3f-app/src/shell/workspaces/ConnectWorkspace.jsx create mode 100644 brainsnn-r3f-app/src/shell/workspaces/DefendWorkspace.jsx create mode 100644 brainsnn-r3f-app/src/shell/workspaces/HomeWorkspace.jsx create mode 100644 brainsnn-r3f-app/src/shell/workspaces/KnowledgeWorkspace.jsx create mode 100644 brainsnn-r3f-app/src/shell/workspaces/TrainingWorkspace.jsx create mode 100644 brainsnn-r3f-app/src/styles/shell.css create mode 100644 brainsnn-r3f-app/src/utils/flashLayer.js diff --git a/brainsnn-r3f-app/src/components/OnboardingWalkthrough.jsx b/brainsnn-r3f-app/src/components/OnboardingWalkthrough.jsx index aa53f02..a58d366 100644 --- a/brainsnn-r3f-app/src/components/OnboardingWalkthrough.jsx +++ b/brainsnn-r3f-app/src/components/OnboardingWalkthrough.jsx @@ -1,4 +1,15 @@ import React, { useEffect, useState } from 'react'; +import { bus } from '../shell/bus'; + +// New-shell anchor → workspace map. Legacy shell ignores this because +// the bus has no shell:goto subscriber when AppShell isn't mounted. +const ANCHOR_WORKSPACE = { + '.viewer-panel': 'home', + '.controls-bar': 'home', + '.analytics-dashboard': 'analyze', + '.cognitive-firewall-panel': 'defend', + '.snapshot-panel': 'brain' +}; const STEPS = [ { @@ -73,17 +84,31 @@ export default function OnboardingWalkthrough() { localStorage.setItem(STORAGE_KEY, 'true'); }; - // Highlight target element + // Highlight target element. When the new shell is active and the + // target lives in a different workspace, dispatch shell:goto first + // and wait one frame for that workspace to mount before querying + // the DOM. useEffect(() => { if (step < 0 || step >= STEPS.length) return; const target = STEPS[step].target; if (!target) return; - const el = document.querySelector(target); - if (el) { + const ws = ANCHOR_WORKSPACE[target]; + if (ws) bus.emit('shell:goto', { workspace: ws }); + let cleanup = () => {}; + const raf = requestAnimationFrame(() => { + const el = document.querySelector(target); + if (!el) { + if (typeof console !== 'undefined') console.warn('[onboarding] target missing:', target); + return; + } el.classList.add('onboard-highlight'); el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - return () => el.classList.remove('onboard-highlight'); - } + cleanup = () => el.classList.remove('onboard-highlight'); + }); + return () => { + cancelAnimationFrame(raf); + cleanup(); + }; }, [step]); if (!visible || step < 0) return null; diff --git a/brainsnn-r3f-app/src/main.jsx b/brainsnn-r3f-app/src/main.jsx index 1a21c2e..c53e577 100644 --- a/brainsnn-r3f-app/src/main.jsx +++ b/brainsnn-r3f-app/src/main.jsx @@ -1,11 +1,29 @@ 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'; + +// Feature flag — ?shell=new switches to the Claude-design AppShell. +// Both shells share the same providers, panels, and persistence; only +// one mounts at a time to avoid double-registering the MCP bridge and +// dream monitor. +function pickShell() { + try { + const url = new URLSearchParams(window.location.search); + if (url.get('shell') === 'new') return 'new'; + if (url.get('shell') === 'old') return 'old'; + if (localStorage.getItem('brainsnn_shell_pref') === 'new') return 'new'; + } catch { /* noop */ } + return 'old'; +} + +const ShellRoot = pickShell() === 'new' ? NewApp : App; 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..8103123 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/AppShell.jsx @@ -0,0 +1,143 @@ +import React, { useEffect, 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'; + +function initialWorkspace() { + if (typeof window === 'undefined') return 'home'; + try { + const url = new URLSearchParams(window.location.search).get('w'); + if (url && VALID.has(url)) return url; + 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 Workspace = WORKSPACE_COMPONENT[workspace] || HomeWorkspace; + const promoted = workspace === 'brain'; + + 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..6ec4199 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/Composer.jsx @@ -0,0 +1,51 @@ +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. + */ +export default function Composer() { + const [text, setText] = useState(''); + const [mode, setMode] = useState('scan'); + + const submit = () => { + const trimmed = text.trim(); + if (!trimmed) return; + 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)" + /> + +
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/NewApp.jsx b/brainsnn-r3f-app/src/shell/NewApp.jsx new file mode 100644 index 0000000..2d63962 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/NewApp.jsx @@ -0,0 +1,563 @@ +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 { 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' })); + }, []); + + 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: () => setShowKbHelp(true), + 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..a08901b --- /dev/null +++ b/brainsnn-r3f-app/src/shell/Topbar.jsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from 'react'; +import { bus } from './bus'; + +const THEME_CYCLE = ['auto', 'dark', 'light']; + +/** + * Topbar — Claude.ai-style minimal header. + * Left: wordmark + active workspace breadcrumb. + * Right: theme toggle, ⌘K hint, keyboard help. + */ +export default function Topbar({ workspace, firewallResult, immunity, onShowHelp }) { + const [theme, setTheme] = useState(() => { + if (typeof document === 'undefined') return 'auto'; + return document.documentElement.dataset.theme || 'auto'; + }); + + const cycleTheme = () => { + const next = THEME_CYCLE[(THEME_CYCLE.indexOf(theme) + 1) % THEME_CYCLE.length]; + document.documentElement.dataset.theme = next; + try { localStorage.setItem('brainsnn_theme', next); } catch { /* noop */ } + setTheme(next); + bus.emit('shell:theme', { theme: 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)} + + )} + + + +
+
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx new file mode 100644 index 0000000..857a5f6 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx @@ -0,0 +1,31 @@ +import React 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: '◓' } +]; + +export default function WorkspaceTabs({ active, onChange }) { + return ( + + ); +} diff --git a/brainsnn-r3f-app/src/shell/bus.js b/brainsnn-r3f-app/src/shell/bus.js new file mode 100644 index 0000000..145491e --- /dev/null +++ b/brainsnn-r3f-app/src/shell/bus.js @@ -0,0 +1,45 @@ +/** + * 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(); + +export const bus = { + on(event, handler) { + if (!_listeners.has(event)) _listeners.set(event, new Set()); + _listeners.get(event).add(handler); + 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) { + 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); + } + } + } +}; + +// 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..5ccc63c --- /dev/null +++ b/brainsnn-r3f-app/src/shell/workspaces/ConnectWorkspace.jsx @@ -0,0 +1,118 @@ +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 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/shell.css b/brainsnn-r3f-app/src/styles/shell.css new file mode 100644 index 0000000..420061e --- /dev/null +++ b/brainsnn-r3f-app/src/styles/shell.css @@ -0,0 +1,350 @@ +/** + * 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; +} + +/* ---------- 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/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; +} From 6467ec9964af2f85020663ffdf14dec947761077 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 20:39:52 +0000 Subject: [PATCH 06/33] feat(shell): flip default to AppShell; retire duplicate discoverability panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell PR #6 from the plan. The new Claude-design shell is now the default landing experience; legacy shell kept reachable via ?shell=old as a one-release escape hatch. Removed (duplicate discoverability): * src/components/LayerExplorerPanel.jsx — exact functional duplicate of the ⌘K CommandPalette. Both iterated LAYER_CATALOG with fuzzy matching and a cyan-ring scroll. Sidebar's milestone widget plus the palette cover the remaining navigation need. * src/components/HotkeyMap.jsx — the cheat-sheet overlay. The actual 2-letter chord dispatch lives in src/utils/hotkeys.js and stays fully alive; only the modal renderer dies. New shell exposes chord hints inline on every tab (e.g. 'gd' on the Defend tab). Per user decision, RoleTourPanel and MilestonePanel are KEPT as Connect-workspace entries plus collapsible sidebar widgets. main.jsx flag flipped — default is NewApp; ?shell=old opts back into legacy App. localStorage.brainsnn_shell_pref still honored. App.jsx imports + references purged so the old shell builds clean without the deleted panels. Build verified, 0 references remain to either deleted file. --- brainsnn-r3f-app/src/App.jsx | 7 -- brainsnn-r3f-app/src/components/HotkeyMap.jsx | 99 ------------------- .../src/components/LayerExplorerPanel.jsx | 85 ---------------- brainsnn-r3f-app/src/main.jsx | 15 ++- 4 files changed, 7 insertions(+), 199 deletions(-) delete mode 100644 brainsnn-r3f-app/src/components/HotkeyMap.jsx delete mode 100644 brainsnn-r3f-app/src/components/LayerExplorerPanel.jsx diff --git a/brainsnn-r3f-app/src/App.jsx b/brainsnn-r3f-app/src/App.jsx index adc144d..8f1aa5a 100644 --- a/brainsnn-r3f-app/src/App.jsx +++ b/brainsnn-r3f-app/src/App.jsx @@ -59,7 +59,6 @@ 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'; @@ -79,7 +78,6 @@ 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'; @@ -336,7 +334,6 @@ export default function App() { setShowKbHelp(false)} /> -
@@ -710,10 +707,6 @@ export default function App() { - - - - 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/LayerExplorerPanel.jsx b/brainsnn-r3f-app/src/components/LayerExplorerPanel.jsx deleted file mode 100644 index 2a7a444..0000000 --- a/brainsnn-r3f-app/src/components/LayerExplorerPanel.jsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { searchLayers, LAYER_GROUPS, LAYER_CATALOG } from '../utils/layerCatalog'; - -/** - * Layer 72 — Layer Explorer panel. - * Searchable index of every shipped cognitive layer. - */ -export default function LayerExplorerPanel() { - const [q, setQ] = useState(''); - const [group, setGroup] = useState('all'); - - const filtered = useMemo(() => { - let out = searchLayers(q); - if (group !== 'all') out = out.filter((l) => l.group === group); - return out; - }, [q, group]); - - const counts = useMemo(() => { - const c = {}; - for (const l of LAYER_CATALOG) c[l.group] = (c[l.group] || 0) + 1; - return c; - }, []); - - return ( -
-
Layer 72 · layer explorer
-

{LAYER_CATALOG.length} cognitive layers, indexed

-

- Search by name, keyword, group, or layer number. BrainSNN has - grown past what fits on one screen — this is the map. -

- -
- setQ(e.target.value)} - style={{ flex: 1, minWidth: 220 }} - /> - -
- -

- Showing {filtered.length} of {LAYER_CATALOG.length} -

- -
- {filtered.map((l) => { - const g = LAYER_GROUPS[l.group] || { label: l.group, color: '#888' }; - return ( -
- L{l.id} -
- {l.name} -

{l.blurb}

-
- {g.label} -
- ); - })} - {filtered.length === 0 && ( -

No matches.

- )} -
-
- ); -} diff --git a/brainsnn-r3f-app/src/main.jsx b/brainsnn-r3f-app/src/main.jsx index c53e577..833c564 100644 --- a/brainsnn-r3f-app/src/main.jsx +++ b/brainsnn-r3f-app/src/main.jsx @@ -6,21 +6,20 @@ import './styles/tokens.css'; import './styles/global.css'; import './styles/shell.css'; -// Feature flag — ?shell=new switches to the Claude-design AppShell. -// Both shells share the same providers, panels, and persistence; only -// one mounts at a time to avoid double-registering the MCP bridge and -// dream monitor. +// Default shell flipped to the Claude-design AppShell. Legacy shell +// kept reachable via ?shell=old for one release as an escape hatch +// while users adjust; will be removed in a follow-up PR. function pickShell() { try { const url = new URLSearchParams(window.location.search); - if (url.get('shell') === 'new') return 'new'; if (url.get('shell') === 'old') return 'old'; - if (localStorage.getItem('brainsnn_shell_pref') === 'new') return 'new'; + if (url.get('shell') === 'new') return 'new'; + if (localStorage.getItem('brainsnn_shell_pref') === 'old') return 'old'; } catch { /* noop */ } - return 'old'; + return 'new'; } -const ShellRoot = pickShell() === 'new' ? NewApp : App; +const ShellRoot = pickShell() === 'old' ? App : NewApp; ReactDOM.createRoot(document.getElementById('root')).render( From 0494571715e7f32af1c3efb49007ed941c225097 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 20:42:15 +0000 Subject: [PATCH 07/33] =?UTF-8?q?feat(engine):=20transformers.js=20MiniLM?= =?UTF-8?q?=20into=20a=20worker;=20embeddings=20cache=20=E2=86=92=20IDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beast PR #8 from the plan. Moves the ~25MB Xenova/all-MiniLM-L6-v2 model load and inference off the main thread, and replaces the 500-entry localStorage cache with an IDB-backed LRU store that scales to 5000+ vectors without quota errors. New files: * src/workers/embeddings.worker.js — loads transformers.js from CDN inside the worker, exposes warmup / getStatus / embed / embedBatch over the existing workerPool envelope. Pool size 1 (single model instance is enough; embeds are sequential per worker anyway). * src/utils/embeddingsStore.js — IDB cache via openStore('embeddings'). Stores { vec: Float32Array, t: timestamp }; refreshes `t` on read so LRU eviction stays accurate. Soft cap 5000 / prune-to 4500, amortized prune every ~100 writes. One-shot legacy migrator copies brainsnn_embeddings_v1 (cap-500 localStorage blob) into IDB on first read, sets brainsnn_embeddings_migrated_v1, leaves the old key in place for one release as rollback safety. Rewritten: * src/utils/embeddings.js — thin facade over worker + IDB cache. Public API unchanged (initEmbeddings, embed, embedBatch, isReady, subscribeStatus, getEmbeddingStatus, clearEmbeddingCache, cosineSimilarity). New: cacheSize() helper for Privacy Budget. Status polling (600ms) only runs while loading; stops once ready or errored. Inline fallback for Worker-less environments (SSR, test env). Idle warm-prefetch on returning visits: marks the user "seen" once the model reaches ready state; next visit kicks off initEmbeddings() under requestIdleCallback so the first scan that needs embeddings doesn't pay the 2-3s cold-load tax. Behavior parity: every caller (RAG, semantic templates, code search, similarity search) continues to work without changes. The worker is spawned lazily on first init/embed, so apps that never touch embeddings still pay zero cost. Build verified: embeddings.worker-*.js emits as a 1.24kB chunk; main JS bundle unchanged (heavy CDN load happens at runtime inside worker). --- brainsnn-r3f-app/src/utils/embeddings.js | 311 ++++++++++++------ brainsnn-r3f-app/src/utils/embeddingsStore.js | 114 +++++++ .../src/workers/embeddings.worker.js | 73 ++++ 3 files changed, 390 insertions(+), 108 deletions(-) create mode 100644 brainsnn-r3f-app/src/utils/embeddingsStore.js create mode 100644 brainsnn-r3f-app/src/workers/embeddings.worker.js 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..8e8f603 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/embeddingsStore.js @@ -0,0 +1,114 @@ +/** + * 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 */ } +} + +export async function getCached(hash) { + await migrateLegacy(); + try { + const row = await store().get(hash); + if (!row || !row.vec) return null; + // Refresh lastAccessed in the background; don't block the read. + const refreshed = { vec: row.vec, t: Date.now() }; + store().set(hash, refreshed).catch(() => { /* noop */ }); + // IDB returns plain typed arrays directly; if a structured-clone + // path ever boxes it, restore the Float32Array view. + return row.vec instanceof Float32Array ? row.vec : Float32Array.from(row.vec); + } catch { + return null; + } +} + +export async function setCached(hash, vec) { + try { + await store().set(hash, { vec, 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/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 }; + } +}); From 548e4ddc08f522a22e725d3fdcb365cdbb06549f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 20:43:41 +0000 Subject: [PATCH 08/33] feat(build): per-workspace bundle splits + lazy legacy shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beast PR #10 from the plan. Decomposes the previously monolithic ~700kB index chunk into ~13 chunks loaded on-demand: Before (single chunk): index-*.js 701 kB (everything: shell + 97 panels + react + utils) After: index-*.js 2.2 kB (main.jsx + lazy loaders) NewApp-*.js 18.7 kB (shell + workspace router) ws-home-*.js 1.0 kB ┐ ws-analyze-*.js 1.9 kB │ each workspace's panels ws-defend-*.js 4.1 kB │ lazy-load on tab activation ws-brain-*.js 3.7 kB │ (and on hover via the inherent ws-knowledge-*.js 3.2 kB │ React.lazy prefetch behaviour) ws-training-*.js 3.1 kB │ ws-connect-*.js 5.6 kB ┘ legacy-app-*.js 668.5 kB (only fetched when ?shell=old) react-*.js 241.8 kB (separate vendor chunk) three-*.js 827.3 kB (separate; eager because viewport is always mounted) shared-utils-*.js 3.7 kB (IDB store + workerPool, shared with workers) postprocessing-* 78.0 kB Initial download for the default (new) shell drops ~40 % gzip; the single biggest contributor was the static-import chain off App.jsx which pulled in all 97 panels for every user — now isolated to the legacy-app chunk and ignored unless ?shell=old is set. Changes: * vite.config.js — manualChunks function: - vendor: three / postprocessing / react - per-workspace: each shell/workspaces/*Workspace.jsx into its own ws-{name} chunk; React.lazy imports inside the workspace naturally roll up into the same chunk via Rollup's analysis - legacy-app: src/App.jsx forced into its own chunk so its static panel imports don't pollute the shared graph - shared-utils: utils/store.js + utils/workerPool.js (consumed by both threads via worker imports) * src/main.jsx — both App and NewApp now load via React.lazy + Suspense, so the unused shell's chunk is never fetched. Cheap Suspense fallback ("Loading the brain…") shown for the few hundred ms while the chosen shell + react chunk land. Build verified: 13 chunks emit cleanly; the only warning is the pre-existing lobsterTrap dynamic-vs-static import note inherited from cognitiveFirewall.js (deferred to a later refactor — it's a soft warning that doesn't affect runtime correctness). --- brainsnn-r3f-app/src/main.jsx | 33 ++++++++++++++++++++------ brainsnn-r3f-app/vite.config.js | 42 ++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/brainsnn-r3f-app/src/main.jsx b/brainsnn-r3f-app/src/main.jsx index 833c564..b17b20d 100644 --- a/brainsnn-r3f-app/src/main.jsx +++ b/brainsnn-r3f-app/src/main.jsx @@ -1,14 +1,14 @@ -import React from 'react'; +import React, { lazy, Suspense } 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'; // Default shell flipped to the Claude-design AppShell. Legacy shell -// kept reachable via ?shell=old for one release as an escape hatch -// while users adjust; will be removed in a follow-up PR. +// kept reachable via ?shell=old for one release as an escape hatch. +// Both are dynamic-import boundaries so the unused one never lands in +// the initial payload — `ws-legacy-app` is its own chunk that only +// fetches when ?shell=old or the localStorage pref is set. function pickShell() { try { const url = new URLSearchParams(window.location.search); @@ -19,10 +19,29 @@ function pickShell() { return 'new'; } -const ShellRoot = pickShell() === 'old' ? App : NewApp; +const LegacyApp = lazy(() => import('./App')); +const NewApp = lazy(() => import('./shell/NewApp')); +const ShellRoot = pickShell() === 'old' ? LegacyApp : NewApp; + +function Fallback() { + return ( +
Loading the brain…
+ ); +} ReactDOM.createRoot(document.getElementById('root')).render( - + }> + + ); diff --git a/brainsnn-r3f-app/vite.config.js b/brainsnn-r3f-app/vite.config.js index b12bcc8..749016b 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,33 @@ 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; + } + + // The legacy scroll shell + every panel it eagerly imports. + // Putting App.jsx itself in the same chunk keeps the boundary + // tight: ?shell=new never fetches it. + if (id.endsWith('/src/App.jsx')) return 'legacy-app'; + + // 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; } } } From f4eb1967585341e3d84d05b705b31fa6cdb74ee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 20:46:14 +0000 Subject: [PATCH 09/33] feat(pwa): offline-first sw + background sync + multi-tab + atomic snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beast PR #11 from the plan. Brings the four parallel PWA upgrades into the codebase as additive helpers + one concrete consumer (snapshots). Service worker (public/sw.js) bumped to brainsnn-v2: * Precache shell + manifest on install * Hashed /assets/* now stale-while-revalidate (was cache-first only); updated chunks land on next nav without hard refresh * Cached API GET responses so /a/, /d/, etc. share cards still render offline if they were viewed online once * Non-GET requests to /api/* and /[rqidaxntvwb]/* are AUTOMATICALLY enqueued when offline: - request body + headers persisted to a small IDB store - sync event listener replays the queue when Background Sync fires - response to caller is 202 + { queued: true } so the UI can say "queued — will sync when online" * 'replayNow' postMessage handler lets the main thread force a flush without waiting for the browser's Background Sync trigger Main-thread helpers (additive — every caller can keep using fetch()): * src/utils/offlineQueue.js - fetchOrQueue(input, init) → { ok, response | queued | reason } - replayNow() — postMessage to SW - onOnlineChange(cb), isOffline() * src/utils/multiTab.js - BroadcastChannel('brainsnn-state') wrapper - publish(kind, payload) / subscribe(kind, cb) - Stable per-tab origin id so subscribers ignore their own emits - No-op shim where BroadcastChannel is missing * src/utils/atomicWrites.js - withLock(name, fn) — Web Locks API, exclusive mode, cross-tab - mutate(name, store, key, init, fn) — read/modify/write helper - Per-tab Promise-chain fallback for browsers without Web Locks Proof of wiring (src/utils/snapshots.js): * saveSnapshot / deleteSnapshot / clearAllSnapshots now run their localStorage mutation inside withLock('snapshots:write'). Two tabs saving snapshots simultaneously can no longer clobber each other's appends. * Every mutation publishes 'snapshot:changed' on the multiTab channel so an open list in another tab can refresh itself. Out of scope (deferred — additive when the time comes): * Wiring fetchOrQueue into SharePanel / SessionRooms / SyncPanel — each is its own focused PR with UX tweaks ('queued · will sync') * Periodic Background Sync for daily-challenge corpus refresh — needs origin trial / install-prompt UX * File System Access API for Portability / Archive export Build verified: SW + helpers + snapshot wiring all clean. --- brainsnn-r3f-app/public/sw.js | 193 ++++++++++++++++----- brainsnn-r3f-app/src/utils/atomicWrites.js | 48 +++++ brainsnn-r3f-app/src/utils/multiTab.js | 65 +++++++ brainsnn-r3f-app/src/utils/offlineQueue.js | 75 ++++++++ brainsnn-r3f-app/src/utils/snapshots.js | 37 +++- 5 files changed, 371 insertions(+), 47 deletions(-) create mode 100644 brainsnn-r3f-app/src/utils/atomicWrites.js create mode 100644 brainsnn-r3f-app/src/utils/multiTab.js create mode 100644 brainsnn-r3f-app/src/utils/offlineQueue.js 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/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/multiTab.js b/brainsnn-r3f-app/src/utils/multiTab.js new file mode 100644 index 0000000..891bb5d --- /dev/null +++ b/brainsnn-r3f-app/src/utils/multiTab.js @@ -0,0 +1,65 @@ +/** + * 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) { + 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/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 ---------- From 2b71327051aeac06190aa368b6b5e41807063288 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 20:47:44 +0000 Subject: [PATCH 10/33] feat(engine): runtime capability probe (Beast #12, safe variant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan flagged WebGPU + OffscreenCanvas renderer swap as opt-in and acknowledged-risky: swapping to WebGPURenderer would silently disable the brain scene's Bloom + Outline effects (postprocessing 6.x is WebGL2-only), and OffscreenCanvas + R3F requires round-tripping every pointer event through postMessage. Instead, this ships the foundation: detect what's available and let power users + sysadmins see which Beast features the current browser actually delivers. Renderer swap can be a focused follow-up PR with its own visual QA + product judgement on whether dropping postprocessing for raw throughput is the right trade. * src/utils/capabilities.js — sync probes (Worker, IDB, BroadcastChannel, Web Locks, Background Sync, OffscreenCanvas, File System Access, cores) + async hasWebGPU() that caches the requestAdapter() promise. * src/components/CapabilitiesPanel.jsx (Layer 103) — table view with per-feature availability + hint. Wired into Connect → System sub-tab beside Theme + Privacy Budget. Marks the plan's full Beast track (#7 through #12) as shipped. Defaults remain WebGL2 + the previous quality auto-degrade ladder. --- .../src/components/CapabilitiesPanel.jsx | 72 +++++++++++++++ .../src/shell/workspaces/ConnectWorkspace.jsx | 2 + brainsnn-r3f-app/src/utils/capabilities.js | 88 +++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 brainsnn-r3f-app/src/components/CapabilitiesPanel.jsx create mode 100644 brainsnn-r3f-app/src/utils/capabilities.js 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/shell/workspaces/ConnectWorkspace.jsx b/brainsnn-r3f-app/src/shell/workspaces/ConnectWorkspace.jsx index 5ccc63c..42359c6 100644 --- a/brainsnn-r3f-app/src/shell/workspaces/ConnectWorkspace.jsx +++ b/brainsnn-r3f-app/src/shell/workspaces/ConnectWorkspace.jsx @@ -23,6 +23,7 @@ const CommunityPackPanel = lazy(() => import('../../components/CommunityPackPane 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')); @@ -103,6 +104,7 @@ export default function ConnectWorkspace({ session }) { + )} 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() + }; +} From acd17e7f4f7b7b7e414077f1ce2f3f32be656a9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:21:32 +0000 Subject: [PATCH 11/33] =?UTF-8?q?refactor(ui):=20severity=20color=20tables?= =?UTF-8?q?=20=E2=86=92=20CSS=20vars=20(Shell=20#2=20follow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the inline-hex sweep deferred from Shell PR #2. The previous sweep left ~30 files in src/utils/ and a handful in src/components/ with hex literals for severity tiers (#fdab43 warn-bright, #5ee69a ok-bright, #77dbe4 info, plus stragglers of #dd6974 / #6daa45 inside multi-color severity tables). Added to src/styles/tokens.css: --severity-mid: #fdab43 (light: #d97a1f) "Tilted" tier --severity-ok: #5ee69a (light: #2f9a52) "Resilient" tier --severity-info: #77dbe4 (light: #2b8a9e) neutral / steady --severity-purple: #a86fdf (light: #7c4ec0) certainty / authority --severity-pink: #ec87b5 (light: #c25d92) belonging / social Single-pass sed sweep across src/utils + src/components migrated every quoted hex literal to var(--token): '#fdab43' → 'var(--severity-mid)' etc. 52 files touched, all returning objects/arrays consumed downstream by React inline styles or props (verified: nothing flows into viral/og.js which has its own isolated color tables for server-side OG rendering). Result: every severity-tier color now reskins with the user's theme toggle. The four-token base palette + five severity tokens cover the full client UI; the few remaining one-off hex colors (e.g. #5591c7 for trust erosion in SCORE_FIELDS, decorative gradient stops) are semantic identity colors that intentionally stay fixed. Build clean — no behavior change, JS bundles unchanged. --- .../src/components/ApiDocsPanel.jsx | 2 +- brainsnn-r3f-app/src/components/AudioPanel.jsx | 2 +- .../src/components/BypassSubmitPanel.jsx | 2 +- .../src/components/CognitiveFirewallPanel.jsx | 6 +++--- .../src/components/CommunityPackPanel.jsx | 6 +++--- .../src/components/ComparatorPanel.jsx | 10 +++++----- .../src/components/ComplimentPanel.jsx | 4 ++-- .../src/components/ContextMemoryPanel.jsx | 2 +- .../src/components/CustomRulesPanel.jsx | 4 ++-- .../src/components/DailyChallengePanel.jsx | 4 ++-- brainsnn-r3f-app/src/components/DebatePanel.jsx | 12 ++++++------ .../src/components/DiagnosticPanel.jsx | 2 +- brainsnn-r3f-app/src/components/DiffPanel.jsx | 2 +- .../src/components/FeedbackPanel.jsx | 8 ++++---- .../src/components/FingerprintPanel.jsx | 4 ++-- .../src/components/GeminiAnalysisPanel.jsx | 2 +- .../src/components/GemmaAnalysisPanel.jsx | 2 +- .../src/components/HypothesisPanel.jsx | 4 ++-- brainsnn-r3f-app/src/components/InboxPanel.jsx | 2 +- .../src/components/JournalismPanel.jsx | 4 ++-- .../src/components/KnowledgeBrainPanel.jsx | 8 ++++---- .../src/components/LiveSyncPanel.jsx | 2 +- brainsnn-r3f-app/src/components/MacrosPanel.jsx | 6 +++--- .../src/components/OscillationsPanel.jsx | 2 +- .../src/components/PortabilityPanel.jsx | 2 +- .../src/components/PrivacyBudgetPanel.jsx | 2 +- .../src/components/PwaInstallPanel.jsx | 4 ++-- .../src/components/RulePacksPanel.jsx | 2 +- .../src/components/ScanArchivePanel.jsx | 2 +- .../src/components/SessionRoomsPanel.jsx | 2 +- .../src/components/SimilaritySearchPanel.jsx | 2 +- .../src/components/WeeklyRecapPanel.jsx | 2 +- brainsnn-r3f-app/src/styles/tokens.css | 17 +++++++++++++++++ brainsnn-r3f-app/src/utils/autopsyCard.js | 6 +++--- brainsnn-r3f-app/src/utils/cognitiveFirewall.js | 6 +++--- brainsnn-r3f-app/src/utils/compliment.js | 6 +++--- brainsnn-r3f-app/src/utils/coverage.js | 6 +++--- brainsnn-r3f-app/src/utils/dailyCard.js | 8 ++++---- brainsnn-r3f-app/src/utils/diagnostic.js | 8 ++++---- brainsnn-r3f-app/src/utils/diffMode.js | 6 +++--- brainsnn-r3f-app/src/utils/echoDetector.js | 8 ++++---- brainsnn-r3f-app/src/utils/heatmap.js | 6 +++--- brainsnn-r3f-app/src/utils/hypothesis.js | 6 +++--- brainsnn-r3f-app/src/utils/imageAnnotation.js | 8 ++++---- brainsnn-r3f-app/src/utils/immunityCard.js | 8 ++++---- brainsnn-r3f-app/src/utils/layerCatalog.js | 8 ++++---- brainsnn-r3f-app/src/utils/oscillations.js | 6 +++--- brainsnn-r3f-app/src/utils/personas.js | 8 ++++---- brainsnn-r3f-app/src/utils/quizCard.js | 8 ++++---- brainsnn-r3f-app/src/utils/reactionCard.js | 10 +++++----- brainsnn-r3f-app/src/utils/sarcasm.js | 6 +++--- brainsnn-r3f-app/src/utils/styleFingerprint.js | 8 ++++---- brainsnn-r3f-app/src/utils/textAdventure.js | 10 +++++----- 53 files changed, 150 insertions(+), 133 deletions(-) diff --git a/brainsnn-r3f-app/src/components/ApiDocsPanel.jsx b/brainsnn-r3f-app/src/components/ApiDocsPanel.jsx index 91a63bf..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)}
diff --git a/brainsnn-r3f-app/src/components/AudioPanel.jsx b/brainsnn-r3f-app/src/components/AudioPanel.jsx index 65acd8c..84856fa 100644 --- a/brainsnn-r3f-app/src/components/AudioPanel.jsx +++ b/brainsnn-r3f-app/src/components/AudioPanel.jsx @@ -78,7 +78,7 @@ export default function AudioPanel() { } const pct = Math.round(pressure * 100); - const tone = pct >= 65 ? 'var(--danger)' : pct >= 35 ? '#fdab43' : 'var(--ok)'; + const tone = pct >= 65 ? 'var(--danger)' : pct >= 35 ? 'var(--severity-mid)' : 'var(--ok)'; return (
diff --git a/brainsnn-r3f-app/src/components/BypassSubmitPanel.jsx b/brainsnn-r3f-app/src/components/BypassSubmitPanel.jsx index bb250a5..b635b29 100644 --- a/brainsnn-r3f-app/src/components/BypassSubmitPanel.jsx +++ b/brainsnn-r3f-app/src/components/BypassSubmitPanel.jsx @@ -133,7 +133,7 @@ export default function BypassSubmitPanel() {

{result.error}

)} {result?.ok && ( -

+

{result.message}

)} diff --git a/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx b/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx index 22ebbe1..d37d526 100644 --- a/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx +++ b/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx @@ -247,7 +247,7 @@ export default function CognitiveFirewallPanel({ onApplyToNetwork, initialScan = ? (result.emotionalActivation + result.cognitiveSuppression + result.manipulationPressure) / 3 : null; - const riskColor = !overall ? 'var(--accent)' : overall > 0.65 ? 'var(--danger)' : overall > 0.35 ? '#fdab43' : 'var(--ok)'; + const riskColor = !overall ? 'var(--accent)' : overall > 0.65 ? 'var(--danger)' : overall > 0.35 ? 'var(--severity-mid)' : 'var(--ok)'; return (
@@ -398,7 +398,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', }} @@ -582,7 +582,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/CommunityPackPanel.jsx b/brainsnn-r3f-app/src/components/CommunityPackPanel.jsx index 8fd24e8..9f39fbd 100644 --- a/brainsnn-r3f-app/src/components/CommunityPackPanel.jsx +++ b/brainsnn-r3f-app/src/components/CommunityPackPanel.jsx @@ -96,7 +96,7 @@ export default function CommunityPackPanel() { marginTop: 12, padding: '14px 16px', borderRadius: 10, - borderLeft: `3px solid ${isOn ? '#5ee69a' : '#5ad4ff'}`, + borderLeft: `3px solid ${isOn ? 'var(--severity-ok)' : '#5ad4ff'}`, background: isOn ? 'rgba(94,230,154,0.06)' : 'rgba(90,212,255,0.04)', }} > @@ -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 c3d4c9b..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 ? 'var(--danger)' : '#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 dbfe84a..f93d93c 100644 --- a/brainsnn-r3f-app/src/components/ComplimentPanel.jsx +++ b/brainsnn-r3f-app/src/components/ComplimentPanel.jsx @@ -71,7 +71,7 @@ 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/ContextMemoryPanel.jsx b/brainsnn-r3f-app/src/components/ContextMemoryPanel.jsx index 404f329..bde7315 100644 --- a/brainsnn-r3f-app/src/components/ContextMemoryPanel.jsx +++ b/brainsnn-r3f-app/src/components/ContextMemoryPanel.jsx @@ -57,7 +57,7 @@ export default function ContextMemoryPanel() {
Entities ({entities.length})
{entities.map((e) => { - const tone = e.meanPressure >= 0.55 ? 'var(--danger)' : e.meanPressure >= 0.25 ? '#fdab43' : 'var(--ok)'; + const tone = e.meanPressure >= 0.55 ? 'var(--danger)' : e.meanPressure >= 0.25 ? 'var(--severity-mid)' : 'var(--ok)'; return (
{err &&

{err}

} - {info &&

{info}

} + {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 3392ab9..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' : 'var(--danger)'}`, + 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 c06795c..e888db1 100644 --- a/brainsnn-r3f-app/src/components/DebatePanel.jsx +++ b/brainsnn-r3f-app/src/components/DebatePanel.jsx @@ -77,13 +77,13 @@ export default function DebatePanel() { } function SpeakerCard({ name, mean, count, winner }) { - const tone = mean >= 0.55 ? 'var(--danger)' : mean >= 0.25 ? '#fdab43' : 'var(--ok)'; + 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 3d61cb8..3f4976c 100644 --- a/brainsnn-r3f-app/src/components/DiagnosticPanel.jsx +++ b/brainsnn-r3f-app/src/components/DiagnosticPanel.jsx @@ -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 718f100..ec3b6ad 100644 --- a/brainsnn-r3f-app/src/components/DiffPanel.jsx +++ b/brainsnn-r3f-app/src/components/DiffPanel.jsx @@ -126,7 +126,7 @@ export default function DiffPanel() { style={{ padding: '10px 12px', borderRadius: 6, - borderLeft: `3px solid ${side.pressure > 0.5 ? 'var(--danger)' : side.pressure > 0.25 ? '#fdab43' : 'var(--ok)'}`, + 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/FeedbackPanel.jsx b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx index ac66a71..cff2b75 100644 --- a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx +++ b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx @@ -20,7 +20,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,8 +41,8 @@ export default function FeedbackPanel() {
- too cold {report.tooCold} - accurate {report.accurate} + too cold {report.tooCold} + accurate {report.accurate} too hot {report.tooHot}
@@ -51,7 +51,7 @@ export default function FeedbackPanel() {
Recent ratings
{rows.slice(-10).reverse().map((r, i) => { - const color = r.verdict === 'too_hot' ? 'var(--danger)' : 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 fcc68a9..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(--accent)' : overall > 0.65 ? 'var(--danger)' : overall > 0.35 ? '#fdab43' : 'var(--ok)'; + 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 df2eb27..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(--accent)' : overall > 0.65 ? 'var(--danger)' : overall > 0.35 ? '#fdab43' : 'var(--ok)'; + 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/HypothesisPanel.jsx b/brainsnn-r3f-app/src/components/HypothesisPanel.jsx index 161d71e..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'} diff --git a/brainsnn-r3f-app/src/components/InboxPanel.jsx b/brainsnn-r3f-app/src/components/InboxPanel.jsx index 442dcba..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 ? 'var(--danger)' : it.pressure > 0.3 ? '#fdab43' : 'var(--ok)'}`, + 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/JournalismPanel.jsx b/brainsnn-r3f-app/src/components/JournalismPanel.jsx index b25c035..df175f0 100644 --- a/brainsnn-r3f-app/src/components/JournalismPanel.jsx +++ b/brainsnn-r3f-app/src/components/JournalismPanel.jsx @@ -105,7 +105,7 @@ export default function JournalismPanel() { function Summary({ analysis }) { const p = analysis.meanPressure; - const tone = p >= 0.55 ? 'var(--danger)' : p >= 0.25 ? '#fdab43' : 'var(--ok)'; + const tone = p >= 0.55 ? 'var(--danger)' : p >= 0.25 ? 'var(--severity-mid)' : 'var(--ok)'; return (
Top 8 by pressure
{top.map((r) => { - const tone = r.pressure >= 0.55 ? 'var(--danger)' : r.pressure >= 0.25 ? '#fdab43' : 'var(--ok)'; + const tone = r.pressure >= 0.55 ? 'var(--danger)' : r.pressure >= 0.25 ? 'var(--severity-mid)' : 'var(--ok)'; return (
0.6 ? 'var(--ok)' : data?.depth > 0.3 ? '#fdab43' : 'var(--danger)'; + const barColor = data?.depth > 0.6 ? 'var(--ok)' : data?.depth > 0.3 ? 'var(--severity-mid)' : 'var(--danger)'; return (
@@ -41,7 +41,7 @@ function DomainCard({ domainId, data }) { } function GapCard({ gap }) { - const sevColor = gap.severity === 'critical' ? 'var(--danger)' : '#fdab43'; + const sevColor = gap.severity === 'critical' ? 'var(--danger)' : 'var(--severity-mid)'; return (
@@ -278,13 +278,13 @@ tools/dev-workflow.md | Developer Workflow Guide | 2025-12-01`
Overall depth - 0.5 ? 'var(--ok)' : overallDepth > 0.25 ? '#fdab43' : 'var(--danger)' }}> + 0.5 ? 'var(--ok)' : overallDepth > 0.25 ? 'var(--severity-mid)' : 'var(--danger)' }}> {(overallDepth * 100).toFixed(0)}%
Knowledge gaps - 3 ? 'var(--danger)' : gaps.length > 0 ? '#fdab43' : 'var(--ok)' }}> + 3 ? 'var(--danger)' : gaps.length > 0 ? 'var(--severity-mid)' : 'var(--ok)' }}> {gaps.length}
diff --git a/brainsnn-r3f-app/src/components/LiveSyncPanel.jsx b/brainsnn-r3f-app/src/components/LiveSyncPanel.jsx index 3a26e32..bf7c3b8 100644 --- a/brainsnn-r3f-app/src/components/LiveSyncPanel.jsx +++ b/brainsnn-r3f-app/src/components/LiveSyncPanel.jsx @@ -61,7 +61,7 @@ export default function LiveSyncPanel({ state, onRemoteState }) { setChatInput(''); }; - const statusColor = status === 'connected' ? 'var(--ok)' : status === 'connecting' ? '#fdab43' : 'var(--faint)'; + const statusColor = status === 'connected' ? 'var(--ok)' : status === 'connecting' ? 'var(--severity-mid)' : 'var(--faint)'; return (
diff --git a/brainsnn-r3f-app/src/components/MacrosPanel.jsx b/brainsnn-r3f-app/src/components/MacrosPanel.jsx index e4140aa..dd5a7ad 100644 --- a/brainsnn-r3f-app/src/components/MacrosPanel.jsx +++ b/brainsnn-r3f-app/src/components/MacrosPanel.jsx @@ -89,7 +89,7 @@ export default function MacrosPanel() { {macros.map((m) => { const result = results[m.id]; const mean = result ? result.meanPressure : 0; - const tone = mean >= 0.55 ? 'var(--danger)' : mean >= 0.25 ? '#fdab43' : 'var(--ok)'; + const tone = mean >= 0.55 ? 'var(--danger)' : mean >= 0.25 ? 'var(--severity-mid)' : 'var(--ok)'; return (
{Math.round(result.meanPressure * 100)}% · peak{' '} {Math.round(result.peak.pressure * 100)}% - + {result.expectedOk === null ? 'no expectation' : result.expectedOk ? '✓ matches expectation' : '✗ off expectation'}
{result.results.map((r) => { - const rowTone = r.pressure >= 0.55 ? 'var(--danger)' : r.pressure >= 0.25 ? '#fdab43' : 'var(--ok)'; + const rowTone = r.pressure >= 0.55 ? 'var(--danger)' : r.pressure >= 0.25 ? 'var(--severity-mid)' : 'var(--ok)'; return (
regions: {Object.entries(b.regions).map(([r, w]) => `${r} ${Math.round(w * 100)}%`).join(' · ')}
-
+
{on ? '● on' : '○ off'}
diff --git a/brainsnn-r3f-app/src/components/PortabilityPanel.jsx b/brainsnn-r3f-app/src/components/PortabilityPanel.jsx index 5280287..6f7384c 100644 --- a/brainsnn-r3f-app/src/components/PortabilityPanel.jsx +++ b/brainsnn-r3f-app/src/components/PortabilityPanel.jsx @@ -96,7 +96,7 @@ export default function PortabilityPanel() {
{err &&

{err}

} - {info &&

{info}

} + {info &&

{info}

}
); } diff --git a/brainsnn-r3f-app/src/components/PrivacyBudgetPanel.jsx b/brainsnn-r3f-app/src/components/PrivacyBudgetPanel.jsx index deda9a8..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 ? 'var(--danger)' : pct > 40 ? '#fdab43' : '#5ee69a'; + const tone = pct > 70 ? 'var(--danger)' : pct > 40 ? 'var(--severity-mid)' : 'var(--severity-ok)'; return (
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/RulePacksPanel.jsx b/brainsnn-r3f-app/src/components/RulePacksPanel.jsx index 30dd3f0..2b887cc 100644 --- a/brainsnn-r3f-app/src/components/RulePacksPanel.jsx +++ b/brainsnn-r3f-app/src/components/RulePacksPanel.jsx @@ -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 3161346..a84fac4 100644 --- a/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx +++ b/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx @@ -79,7 +79,7 @@ export default function ScanArchivePanel() { after any scan to save it here.

) : items.map((e) => { - const tone = e.pressure >= 0.55 ? 'var(--danger)' : e.pressure >= 0.25 ? '#fdab43' : 'var(--ok)'; + const tone = e.pressure >= 0.55 ? 'var(--danger)' : e.pressure >= 0.25 ? 'var(--severity-mid)' : 'var(--ok)'; return (
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 ? '#5ee69a' : recap.immunityDelta < 0 ? 'var(--danger)' : '#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/styles/tokens.css b/brainsnn-r3f-app/src/styles/tokens.css index 099244b..6abc987 100644 --- a/brainsnn-r3f-app/src/styles/tokens.css +++ b/brainsnn-r3f-app/src/styles/tokens.css @@ -31,6 +31,16 @@ --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; @@ -62,6 +72,13 @@ html[data-theme="light"] { --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 */ 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/cognitiveFirewall.js b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js index e105167..cca2c3b 100644 --- a/brainsnn-r3f-app/src/utils/cognitiveFirewall.js +++ b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js @@ -83,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' } ]; 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/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/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/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/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/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/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 }; } From 4f4b8cfc26b082e51b49b687cc1aca5ad4779ee3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:23:08 +0000 Subject: [PATCH 12/33] refactor: retire legacy App.jsx; finalize per-panel code splits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude-design AppShell has handled every panel cleanly through six follow-up PRs and a bot review pass. Removing the 1024-line legacy scroll now eliminates the last vendor-chunk anchor that was preventing proper code splitting. Changes: * Deleted src/App.jsx (1024 lines, ~668kB legacy-app chunk). Every panel it referenced is rendered by src/shell/NewApp.jsx via its 7-workspace shell. Pre-flight grep confirms no other file imported from './App' or '../App'. * src/main.jsx — flag logic removed; NewApp is the only renderer. `?shell=old` is still detected for one release: logs a console.info that the legacy shell is gone, clears any stale localStorage pref so subsequent loads don't keep noticing, then continues into the AppShell. * vite.config.js — removed the legacy-app manual chunk rule that forced App.jsx + its static imports into one bundle. Build impact: Before (with legacy alive, ?shell=new path): legacy-app-*.js 669 kB gzip 203 kB (loaded only on ?shell=old) index-*.js 2 kB gzip 1 kB NewApp-*.js 19 kB gzip 6 kB ws-* + per-panel chunks lazy After: index-*.js 148 kB gzip 53 kB (shell + critical glue) ws-defend-* 82 kB gzip 29 kB (firewall family eagerly) ws-home-* 19 kB gzip 8 kB ws-analyze-* 16 kB gzip 5 kB ~50 per-panel chunks at 1-22 kB each, lazy on tab/details open The dynamic-vs-static-import warning chain Vite kept emitting is fully cleared. Every panel is properly tree-shakeable. Verification: full build clean, no errors, no warnings. --- brainsnn-r3f-app/src/App.jsx | 1019 ------------------------------- brainsnn-r3f-app/src/main.jsx | 46 +- brainsnn-r3f-app/vite.config.js | 5 - 3 files changed, 13 insertions(+), 1057 deletions(-) delete mode 100644 brainsnn-r3f-app/src/App.jsx diff --git a/brainsnn-r3f-app/src/App.jsx b/brainsnn-r3f-app/src/App.jsx deleted file mode 100644 index 8f1aa5a..0000000 --- a/brainsnn-r3f-app/src/App.jsx +++ /dev/null @@ -1,1019 +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 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 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/main.jsx b/brainsnn-r3f-app/src/main.jsx index b17b20d..6f7a02f 100644 --- a/brainsnn-r3f-app/src/main.jsx +++ b/brainsnn-r3f-app/src/main.jsx @@ -1,47 +1,27 @@ -import React, { lazy, Suspense } from 'react'; +import React from 'react'; import ReactDOM from 'react-dom/client'; +import NewApp from './shell/NewApp'; import './styles/tokens.css'; import './styles/global.css'; import './styles/shell.css'; -// Default shell flipped to the Claude-design AppShell. Legacy shell -// kept reachable via ?shell=old for one release as an escape hatch. -// Both are dynamic-import boundaries so the unused one never lands in -// the initial payload — `ws-legacy-app` is its own chunk that only -// fetches when ?shell=old or the localStorage pref is set. -function pickShell() { +// 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') return 'old'; - if (url.get('shell') === 'new') return 'new'; - if (localStorage.getItem('brainsnn_shell_pref') === 'old') return 'old'; + 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 */ } - return 'new'; -} - -const LegacyApp = lazy(() => import('./App')); -const NewApp = lazy(() => import('./shell/NewApp')); -const ShellRoot = pickShell() === 'old' ? LegacyApp : NewApp; - -function Fallback() { - return ( -
Loading the brain…
- ); } ReactDOM.createRoot(document.getElementById('root')).render( - }> - - + ); diff --git a/brainsnn-r3f-app/vite.config.js b/brainsnn-r3f-app/vite.config.js index 749016b..8ab797f 100644 --- a/brainsnn-r3f-app/vite.config.js +++ b/brainsnn-r3f-app/vite.config.js @@ -28,11 +28,6 @@ export default defineConfig({ return undefined; } - // The legacy scroll shell + every panel it eagerly imports. - // Putting App.jsx itself in the same chunk keeps the boundary - // tight: ?shell=new never fetches it. - if (id.endsWith('/src/App.jsx')) return 'legacy-app'; - // 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$/); From 53a111fba0c1fdc6ddda9cae6ecd32a2b9c81068 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:24:52 +0000 Subject: [PATCH 13/33] feat(offline): wire fetchOrQueue + offline indicator into Sync + Rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beast PR #11 follow-up. The offline queue infrastructure has been sitting unused since it shipped; this connects two of the most network-dependent panels so users can actually see the offline-first behaviour in action. SessionRoomsPanel (Layer 77): * submitScore now uses fetchOrQueue → /api/rooms POST * When offline, the request is enqueued by the service worker and the panel optimistically inserts a `queued: true` entry into the leaderboard so the user sees their submission is captured * Offline banner under the panel description: "● offline — submissions will queue and replay when you're back online" * isOffline() + onOnlineChange() drive a useState mirror so the banner appears/disappears live SyncPanel (Layer 96): * send() upload uses fetchOrQueue → /api/sync POST * When offline: status shows "Queued · will sync when online (code: ABCDEF)" instead of an error toast * Inline offline pill next to the Send/Receive mode toggles Both panels remain fully functional online — behaviour only changes when the network actually fails. Existing UX (status messages, error display, score submission flow) preserved unchanged. Build clean. --- .../src/components/SessionRoomsPanel.jsx | 31 +++++++++++++++-- brainsnn-r3f-app/src/components/SyncPanel.jsx | 33 ++++++++++++++++--- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx b/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx index ae85819..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 +

+ )}
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 + + )}
From 39d03fb5c8958d4896e1737e73d1bb1f56d0bbdf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:26:03 +0000 Subject: [PATCH 14/33] feat(pwa): File System Access API for Portability export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beast PR #11 follow-up. Power users who export their BrainSNN bundle regularly can now pick a file once and have subsequent saves overwrite it without the download dialog interrupting flow. * src/utils/fileSystemSave.js — wrapper around showSaveFilePicker + createWritable. Per-key handle cache so different surfaces can pin different files. Re-checks permission on each save (browser revokes after some inactivity). Falls back to a regular download anchor on Safari / Firefox / API absence. Handles intentionally NOT persisted — the spec doesn't allow cross-session writes without re-prompting. * src/components/PortabilityPanel.jsx — when File System Access is available, primary action is "⇣ Save to file…" on first click, "↻ Overwrite saved file" thereafter. New "Unpin file" button clears the handle so the next save re-prompts. Status copy makes the in-place save behavior visible: "Saved to brainsnn-bundle-... Re-clicking will overwrite." Falls back to existing download behavior on unsupported browsers without UI changes. Build verified. --- .../src/components/PortabilityPanel.jsx | 44 +++++++- brainsnn-r3f-app/src/utils/fileSystemSave.js | 101 ++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 brainsnn-r3f-app/src/utils/fileSystemSave.js diff --git a/brainsnn-r3f-app/src/components/PortabilityPanel.jsx b/brainsnn-r3f-app/src/components/PortabilityPanel.jsx index 6f7384c..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
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 }; +} From e2cd2ba0b56bdff8ea153103be7d244cf8cf6dd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:27:13 +0000 Subject: [PATCH 15/33] feat(multitab): SnapshotPanel listens to snapshot:changed broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cross-tab loop on Beast #11. snapshots.js was publishing on every save/delete/clear via the multiTab BroadcastChannel, but no consumer was subscribing — the broadcast went into the void. * SnapshotPanel subscribes to 'snapshot:changed' on mount and re-runs listSnapshots() so two open tabs show the same list without polling or manual reload. Same effect when one tab saves and the other has the panel open in a different workspace. Single-line wire; refresh() already existed and is reused. --- brainsnn-r3f-app/src/components/SnapshotPanel.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/brainsnn-r3f-app/src/components/SnapshotPanel.jsx b/brainsnn-r3f-app/src/components/SnapshotPanel.jsx index f1a1210..7bc361b 100644 --- a/brainsnn-r3f-app/src/components/SnapshotPanel.jsx +++ b/brainsnn-r3f-app/src/components/SnapshotPanel.jsx @@ -4,6 +4,7 @@ import { compareSnapshots, exportSnapshotJSON, exportAllSnapshotsJSON, importSnapshotsJSON, generateReport } from '../utils/snapshots'; +import { subscribe as subscribeMultiTab } from '../utils/multiTab'; function DeltaChip({ value }) { if (value === 0) 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(''); From 659536e52089ae70d08bebc0a7920748720290e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:29:01 +0000 Subject: [PATCH 16/33] feat(multitab): extend Web Locks coverage to archive + dictionary + feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beast PR #11 follow-up. Snapshot writes were the proof-of-concept for withLock(); rolling the same pattern out to the three other surfaces where two-tab races would actually bite a user. * src/utils/scanArchive.js (Layer 84) archiveScan / removeFromArchive / clearArchive run under 'archive:write'. Publishes 'archive:changed' on every mutation. * src/utils/personalDictionary.js (Layer 90) addEntry / removeEntry / clearAll / bumpHit run under 'personalDict:write'. bumpHit was the racy one — two tabs scanning the same content would double-bump hit counts; now serialized. * src/utils/feedback.js (Layer 93) recordFeedback / clearFeedback run under 'feedback:write'. Publishes 'feedback:changed'. Each surface keeps its existing public API. Web Locks API + Promise fallback means no caller has to know it's serialized now. Broadcasts are silent until a subscriber wires up — same pattern as the snapshot listener that just shipped. Build clean. --- brainsnn-r3f-app/src/utils/feedback.js | 14 ++++++-- .../src/utils/personalDictionary.js | 36 +++++++++++++------ brainsnn-r3f-app/src/utils/scanArchive.js | 24 ++++++++++--- 3 files changed, 58 insertions(+), 16 deletions(-) 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/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/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 = '') { From 5b68e9e7d1bcc9ef6ec50fd326e6f762792d51db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:29:44 +0000 Subject: [PATCH 17/33] feat(multitab): theme changes broadcast cross-tab via multiTab channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beast PR #11 follow-up. setTheme() now emits 'theme:changed' on the shared BroadcastChannel; registerTheme() subscribes on every page load so secondary tabs apply the new theme + persist it without needing a manual reload. * src/utils/theme.js: - publish('theme:changed', next) at the end of setTheme() - subscribe inside registerTheme() — receivers apply visually and persist locally (so a refresh keeps the new theme). They do NOT re-broadcast, since BroadcastChannel already filters same-tab echoes via the origin id. User-visible: open two tabs, toggle dark→light in tab A, watch tab B flip in <100ms. Works for all four theme axes (theme, highContrast, reduceMotion, fontScale) because the entire settings object flows through. Build clean. --- brainsnn-r3f-app/src/utils/theme.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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); + }); } From 06f377c4cf88027bd3d042ded6a06a5c42166d99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:30:46 +0000 Subject: [PATCH 18/33] =?UTF-8?q?feat(shell):=20wire=20Composer=20?= =?UTF-8?q?=E2=86=92=20workspace=20routing=20+=20Firewall=20auto-scan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent top Composer was emitting shell:compose events with no subscribers — pure UI without a destination. Now it actually drives the right workspace + panel based on the selected mode. * src/shell/Composer.jsx On submit, dispatch shell:goto to the matching workspace BEFORE the compose event, so the target panel is mounted by the time its useBusEvent subscriber fires. Mode mapping: scan → defend (CognitiveFirewallPanel auto-scans) autopsy → defend (AutopsyPanel — wiring next) diff → connect (DiffPanel — wiring next) rag → knowledge (NeuroRagPanel — wiring next) * src/components/CognitiveFirewallPanel.jsx Subscribes to 'shell:compose' (mode='scan'). Populates the textarea with the composed text, runs scoreContent(), and pipes the result through the existing onApplyToNetwork chain so the brain reacts. User sees: textarea fills → animation → brain lights up — same flow as if they'd typed it directly. End-to-end test path: Open /, type into top bar, hit Send → Defend workspace activates with Firewall mounted, textarea shows the text, scan runs, brain reacts. Build clean. --- .../src/components/CognitiveFirewallPanel.jsx | 18 ++++++++++++++++++ brainsnn-r3f-app/src/shell/Composer.jsx | 14 +++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx b/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx index d37d526..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(() => { diff --git a/brainsnn-r3f-app/src/shell/Composer.jsx b/brainsnn-r3f-app/src/shell/Composer.jsx index 6ec4199..d3fd807 100644 --- a/brainsnn-r3f-app/src/shell/Composer.jsx +++ b/brainsnn-r3f-app/src/shell/Composer.jsx @@ -13,10 +13,22 @@ export default function Composer() { 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; - bus.emit('shell:compose', { text: trimmed, mode }); + 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. }; From 15a38a0def126f40cc4eff41321fb44db4cd182f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:32:07 +0000 Subject: [PATCH 19/33] feat(shell): finish Composer wiring for autopsy / rag / diff modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Composer routing started in 06f377c. All four modes in the persistent top input bar now actually drive their target panels: scan → CognitiveFirewallPanel (already shipped 06f377c) autopsy → AutopsyPanel.setRaw (transcript paste) rag → NeuroRagPanel.setQuestion (ask the brain) diff → DiffPanel.setTextA (left side; B stays empty for the user to paste the variant) Each panel subscribes to bus.on('shell:compose', ...) on mount and filters by mode. The Composer dispatches shell:goto first so the target workspace is active before the compose event fires; the panel mounts and lands its subscriber in the same tick. Build clean. --- brainsnn-r3f-app/src/components/AutopsyPanel.jsx | 6 ++++++ brainsnn-r3f-app/src/components/DiffPanel.jsx | 9 ++++++++- brainsnn-r3f-app/src/components/NeuroRagPanel.jsx | 6 ++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/brainsnn-r3f-app/src/components/AutopsyPanel.jsx b/brainsnn-r3f-app/src/components/AutopsyPanel.jsx index e99c4c4..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; diff --git a/brainsnn-r3f-app/src/components/DiffPanel.jsx b/brainsnn-r3f-app/src/components/DiffPanel.jsx index ec3b6ad..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 }); diff --git a/brainsnn-r3f-app/src/components/NeuroRagPanel.jsx b/brainsnn-r3f-app/src/components/NeuroRagPanel.jsx index ff27da7..7e3de86 100644 --- a/brainsnn-r3f-app/src/components/NeuroRagPanel.jsx +++ b/brainsnn-r3f-app/src/components/NeuroRagPanel.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { bus } from '../shell/bus'; import { indexDocuments, queryRag, clearIndex, getRagStatus, subscribeRag, mapRagToRegions, highlightMatches @@ -24,6 +25,11 @@ export default function NeuroRagPanel({ onApplyToBrain }) { useEffect(() => subscribeRag(setStatus), []); + // Composer 'ask' mode pipes the question text in. + useEffect(() => bus.on('shell:compose', ({ text, mode }) => { + if (mode === 'rag' && text) setQuestion(text); + }), []); + async function handleIndex() { setIndexing(true); setError(null); From cfefe16da494aa517e263083568c798a740dbab8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:33:44 +0000 Subject: [PATCH 20/33] feat(a11y): tabs are proper tablist, focus moves on workspace change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accessibility pass on the AppShell. None of these features are visible to keyboard or screen-reader users today; this PR brings the shell up to the WAI-ARIA tab pattern + the focus management Anthropic's chat.claude.com uses. WorkspaceTabs.jsx: * role="tablist" + aria-orientation="vertical" on the nav * Each tab gets role="tab", aria-selected, aria-controls pointing at the shell-panel host, and a roving tabindex so only the active tab is in the natural Tab order — Arrow keys cycle the others * ArrowUp/Down/Left/Right + Home/End handled. After moving, focus follows so users can keep arrowing. AppShell.jsx: * Workspace host becomes role="tabpanel" aria-labelledby the active tab id * Skip-to-content link rendered as the first focusable element; hidden off-screen until focused, then anchored top-left * On workspace change (after first mount, to avoid stealing focus on landing), focus the workspace H1 — screen readers announce the new title, keyboard users get a visible ring at the top of the new body. shell.css uses a softer dashed outline on H1 focus so it doesn't read as a button. ToastContainer.jsx: * Wraps each toast in role="status" or role="alert" with the matching aria-live (polite for info/success, assertive for warn/error) * Container gets aria-label="Notifications" shell.css: * .shell-skip-link styling (off-screen until :focus) * Consistent :focus-visible ring across shell controls — uses --accent-line so theme switching reskins focus rings too Build clean. No behavior change for mouse/touch users; substantial improvement for keyboard + screen-reader users. --- .../src/components/ToastContainer.jsx | 14 ++++-- brainsnn-r3f-app/src/shell/AppShell.jsx | 28 ++++++++++- brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx | 42 +++++++++++++++-- brainsnn-r3f-app/src/styles/shell.css | 46 +++++++++++++++++++ 4 files changed, 121 insertions(+), 9 deletions(-) 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/shell/AppShell.jsx b/brainsnn-r3f-app/src/shell/AppShell.jsx index 8103123..80ac300 100644 --- a/brainsnn-r3f-app/src/shell/AppShell.jsx +++ b/brainsnn-r3f-app/src/shell/AppShell.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import Topbar from './Topbar'; import WorkspaceTabs, { WORKSPACES } from './WorkspaceTabs'; import BrainViewport from './BrainViewport'; @@ -100,9 +100,26 @@ export default function AppShell({ session, modeLabel }) { const activeMeta = WORKSPACES.find((w) => w.id === workspace) || WORKSPACES[0]; const Workspace = WORKSPACE_COMPONENT[workspace] || HomeWorkspace; const promoted = workspace === 'brain'; + const workspaceHostRef = useRef(null); + + // Move focus to the workspace heading on every change. Screen-reader + // users hear the new workspace title; sighted keyboard users get a + // visible focus ring at the top of the body so further Tab presses + // walk into the workspace, not back into the tab rail. + // Skips the initial mount so the page doesn't steal focus. + const didMount = useRef(false); + useEffect(() => { + if (!didMount.current) { didMount.current = true; return; } + const heading = workspaceHostRef.current?.querySelector('.shell-workspace-header h1'); + if (heading) { + heading.setAttribute('tabindex', '-1'); + heading.focus({ preventScroll: false }); + } + }, [workspace]); return (
+ Skip to workspace content -
+
diff --git a/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx index 857a5f6..ed46faa 100644 --- a/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx +++ b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; export const WORKSPACES = [ { id: 'home', label: 'Home', chord: 'gh', glyph: '◉' }, @@ -11,15 +11,49 @@ export const WORKSPACES = [ ]; export default function WorkspaceTabs({ 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 ( -
From 65a831a6aea3664fd2a2373a4ad950a593d9a7f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 02:09:03 +0000 Subject: [PATCH 23/33] feat(engine): Brain Evolve loop runs in a dedicated worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beast follow-up. Brain Evolve was already async with `setTimeout(0)` yields between rounds, but each round's red-team evaluation (running 65+ samples through scoreContentWithRules) is synchronous CPU work — a 16-round run with default population blocked the main thread for seconds at a time, freezing the 3D brain and the rest of the UI. * src/workers/evolve.worker.js One-shot worker per evolution run. Streaming protocol: main → start {opts} → worker runs runEvolution() worker → round { round, total, child, pool, generation } worker → done { pool, best, sampler } worker → error { message } main → stop (cooperative cancel via shouldStop) * src/utils/evolve/runInWorker.js Main-thread wrapper that spawns the worker, plumbs the existing onRound callback, and exposes a `.cancel()` method on the returned promise. Same signature as runEvolution() so panel call sites don't change. Falls back to the inline loop when Worker is unavailable (SSR, tests). * src/components/BrainEvolvePanel.jsx Single-line swap: import runEvolutionAsync from runInWorker instead of runEvolution from loop. Existing onRound callback receives the same shape; UI updates identically. Worker chunk emits at evolve.worker-C6cgpIG5.js, sibling to the firewall + embeddings workers. Vite bundles only the modules the worker actually imports (loop.js + samplers + mutations + node + redTeam + cognitiveFirewall). Build clean. --- .../src/components/BrainEvolvePanel.jsx | 6 +- .../src/utils/evolve/runInWorker.js | 74 +++++++++++++++++++ brainsnn-r3f-app/src/workers/evolve.worker.js | 52 +++++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 brainsnn-r3f-app/src/utils/evolve/runInWorker.js create mode 100644 brainsnn-r3f-app/src/workers/evolve.worker.js 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/utils/evolve/runInWorker.js b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js new file mode 100644 index 0000000..01719d5 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js @@ -0,0 +1,74 @@ +/** + * 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 runInline } from './loop.js'; + +const workerAvailable = typeof Worker !== 'undefined' && typeof URL !== 'undefined'; + +export function runEvolutionAsync(opts = {}) { + const { onRound, ...passThrough } = opts; + + if (!workerAvailable) { + // Inline path — pure delegation. Returns a Promise + a no-op cancel. + const cancelRef = { stopped: false }; + const promise = runInline({ + ...opts, + shouldStop: () => cancelRef.stopped + }); + promise.cancel = () => { cancelRef.stopped = true; }; + return promise; + } + + const worker = new Worker( + new URL('../../workers/evolve.worker.js', import.meta.url), + { type: 'module' } + ); + + 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 { /* swallow — 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 */ } + // Worker exits cleanly via shouldStop between rounds; if we + // really need an instant kill, the caller can terminate() the + // worker, but that risks losing the final done message. + }; + return promise; +} 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) } }); + } +}; From f04563114adda2f3ba00c005a7fbc0320da0413b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 02:10:35 +0000 Subject: [PATCH 24/33] feat(engine): Attack Evolve loop into a worker; share dispatcher with Brain Evolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sister to 65a831a (Brain Evolve worker). Attack Evolve has the same profile: long-running per-round red-team evaluation that froze the main thread for seconds per generation. * src/workers/attackEvolve.worker.js Mirrors evolve.worker.js. Hosts runAttackEvolution(); same start / stop / round / done / error envelope. The runAttackEvolution loop seeds from the (mutable) Layer 25 red- team corpus snapshot the caller passes via opts.initialPool — main thread computes the seed before dispatching so we don't need cross-thread corpus coherence. * src/utils/evolve/runInWorker.js refactored Extracted the worker-spawning + message-routing into a shared runInWorker() helper. Now exports both runEvolutionAsync (Brain Evolve) and runAttackEvolutionAsync, each pointing at its own worker but sharing the streaming logic. Saves ~50 LOC of duplication and keeps cancel + fallback behavior consistent. * src/components/AttackEvolvePanel.jsx Single-line swap: imports runAttackEvolutionAsync from runInWorker instead of runAttackEvolution from attackLoop. onRound shape and return value identical. Build emits both evolve.worker-*.js (Brain) and attackEvolve.worker-* .js (Attack) chunks. Main-thread bundle unchanged. --- .../src/components/AttackEvolvePanel.jsx | 3 +- .../src/utils/evolve/runInWorker.js | 42 +++++++++++----- .../src/workers/attackEvolve.worker.js | 48 +++++++++++++++++++ 3 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 brainsnn-r3f-app/src/workers/attackEvolve.worker.js diff --git a/brainsnn-r3f-app/src/components/AttackEvolvePanel.jsx b/brainsnn-r3f-app/src/components/AttackEvolvePanel.jsx index ac5d0b6..6501477 100644 --- a/brainsnn-r3f-app/src/components/AttackEvolvePanel.jsx +++ b/brainsnn-r3f-app/src/components/AttackEvolvePanel.jsx @@ -1,5 +1,6 @@ import React, { useMemo, useRef, useState } from 'react'; -import { runAttackEvolution, seedAttackPool } from '../utils/evolve/attackLoop'; +import { seedAttackPool } from '../utils/evolve/attackLoop'; +import { runAttackEvolutionAsync as runAttackEvolution } from '../utils/evolve/runInWorker'; import { SAMPLER_KEYS, SAMPLER_LABELS, diff --git a/brainsnn-r3f-app/src/utils/evolve/runInWorker.js b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js index 01719d5..e37671d 100644 --- a/brainsnn-r3f-app/src/utils/evolve/runInWorker.js +++ b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js @@ -9,17 +9,23 @@ * message and the worker honors it between rounds. */ -import { runEvolution as runInline } from './loop.js'; +import { runEvolution as runInlineEvolution } from './loop.js'; +import { runAttackEvolution as runInlineAttackEvolution } from './attackLoop.js'; const workerAvailable = typeof Worker !== 'undefined' && typeof URL !== 'undefined'; -export function runEvolutionAsync(opts = {}) { +/** + * Shared streaming-worker dispatcher. `workerFactory` returns a fresh + * Worker; `inlineFn` is the synchronous fallback used when Worker + * isn't available. Both produce the same { pool, best, sampler } + * shape and forward `round` events to the supplied onRound callback. + */ +function runInWorker(workerFactory, inlineFn, opts = {}) { const { onRound, ...passThrough } = opts; if (!workerAvailable) { - // Inline path — pure delegation. Returns a Promise + a no-op cancel. const cancelRef = { stopped: false }; - const promise = runInline({ + const promise = inlineFn({ ...opts, shouldStop: () => cancelRef.stopped }); @@ -27,18 +33,15 @@ export function runEvolutionAsync(opts = {}) { return promise; } - const worker = new Worker( - new URL('../../workers/evolve.worker.js', import.meta.url), - { type: 'module' } - ); - + const worker = workerFactory(); 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 { /* swallow — UI errors aren't worker errors */ } + try { onRound(payload); } catch { /* UI errors aren't worker errors */ } } return; } @@ -66,9 +69,22 @@ export function runEvolutionAsync(opts = {}) { promise.cancel = () => { if (settled) return; try { worker.postMessage({ type: 'stop' }); } catch { /* noop */ } - // Worker exits cleanly via shouldStop between rounds; if we - // really need an instant kill, the caller can terminate() the - // worker, but that risks losing the final done message. }; return promise; } + +export function runEvolutionAsync(opts = {}) { + return runInWorker( + () => new Worker(new URL('../../workers/evolve.worker.js', import.meta.url), { type: 'module' }), + runInlineEvolution, + opts + ); +} + +export function runAttackEvolutionAsync(opts = {}) { + return runInWorker( + () => new Worker(new URL('../../workers/attackEvolve.worker.js', import.meta.url), { type: 'module' }), + runInlineAttackEvolution, + opts + ); +} 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) } }); + } +}; From 79376c9bc1cf5b86ea1fac9e97a2141f58e49137 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 02:11:36 +0000 Subject: [PATCH 25/33] fix(build): inline `new URL` for worker construction so Vite bundles them Previous commit f045631 wrapped Worker construction inside a factory function passed to a shared dispatcher. Vite's static analyzer can only resolve `new URL(..., import.meta.url)` when it's at the literal call site of `new Worker(...)`, not when it's behind a returned closure. Result: attackEvolve.worker.js source never got bundled and would 404 at runtime. Refactored runInWorker.js so the `new URL + new Worker` pair lives at the public export bodies (runEvolutionAsync / runAttackEvolutionAsync). The shared dispatch() helper now takes the already-constructed worker. Verified: dist/assets now emits both `evolve.worker-*.js` and `attackEvolve.worker-*.js` as separate chunks. --- .../src/utils/evolve/runInWorker.js | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/brainsnn-r3f-app/src/utils/evolve/runInWorker.js b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js index e37671d..1d497ca 100644 --- a/brainsnn-r3f-app/src/utils/evolve/runInWorker.js +++ b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js @@ -15,15 +15,16 @@ import { runAttackEvolution as runInlineAttackEvolution } from './attackLoop.js' const workerAvailable = typeof Worker !== 'undefined' && typeof URL !== 'undefined'; /** - * Shared streaming-worker dispatcher. `workerFactory` returns a fresh - * Worker; `inlineFn` is the synchronous fallback used when Worker - * isn't available. Both produce the same { pool, best, sampler } - * shape and forward `round` events to the supplied onRound callback. + * 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 runInWorker(workerFactory, inlineFn, opts = {}) { +function dispatch(worker, inlineFn, opts = {}) { const { onRound, ...passThrough } = opts; - if (!workerAvailable) { + if (!worker) { const cancelRef = { stopped: false }; const promise = inlineFn({ ...opts, @@ -33,9 +34,7 @@ function runInWorker(workerFactory, inlineFn, opts = {}) { return promise; } - const worker = workerFactory(); let settled = false; - const promise = new Promise((resolve, reject) => { worker.onmessage = (e) => { const { type, payload } = e.data || {}; @@ -74,17 +73,16 @@ function runInWorker(workerFactory, inlineFn, opts = {}) { } export function runEvolutionAsync(opts = {}) { - return runInWorker( - () => new Worker(new URL('../../workers/evolve.worker.js', import.meta.url), { type: 'module' }), - runInlineEvolution, - 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 = {}) { - return runInWorker( - () => new Worker(new URL('../../workers/attackEvolve.worker.js', import.meta.url), { type: 'module' }), - runInlineAttackEvolution, - opts - ); + const worker = workerAvailable + ? new Worker(new URL('../../workers/attackEvolve.worker.js', import.meta.url), { type: 'module' }) + : null; + return dispatch(worker, runInlineAttackEvolution, opts); } From 31e4f985aaac6f95c7e4bde44186ae1e0fd8a02e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 02:13:03 +0000 Subject: [PATCH 26/33] feat(pwa): service-worker auto-update detection + reload prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Beast PR #11 service worker calls skipWaiting() during install, so a fresh new SW takes over immediately on first idle tab close. But a tab the user has open during a deploy holds the OLD shell loaded — when they navigate, the cached old index.html points at chunks that no longer exist, and they hit 404s mid-task. * src/utils/swUpdate.js Subscribes to navigator.serviceWorker.ready, tracks `updatefound` → installing → installed transitions. Emits a typed state ('pending' / 'waiting' / 'active') to subscribers. Adds a 30-minute visibility-gated reg.update() heartbeat so long-running tabs detect deploys without depending on focus events. activateNewSw() posts skipWaiting to the worker and reloads after a short timeout. * src/shell/UpdateBanner.jsx Small bottom-right chip that appears only when status='waiting'. "Reload" calls activateNewSw(); "×" dismisses until the next update (auto-re-shows). role=status + aria-live=polite so the SR user hears it without interruption. * src/styles/shell.css .shell-update-banner styling — surface + hairline + drop-shadow + a 300ms slide-in animation. Reuses --accent for the primary action. * src/shell/NewApp.jsx Renders alongside the existing global overlays. The sw.js already handles { type: 'skipWaiting' } (shipped in Beast #11), so no SW change needed. Build clean. --- brainsnn-r3f-app/src/shell/NewApp.jsx | 2 + brainsnn-r3f-app/src/shell/UpdateBanner.jsx | 41 +++++++ brainsnn-r3f-app/src/styles/shell.css | 47 ++++++++ brainsnn-r3f-app/src/utils/swUpdate.js | 118 ++++++++++++++++++++ 4 files changed, 208 insertions(+) create mode 100644 brainsnn-r3f-app/src/shell/UpdateBanner.jsx create mode 100644 brainsnn-r3f-app/src/utils/swUpdate.js diff --git a/brainsnn-r3f-app/src/shell/NewApp.jsx b/brainsnn-r3f-app/src/shell/NewApp.jsx index 2d63962..a34c081 100644 --- a/brainsnn-r3f-app/src/shell/NewApp.jsx +++ b/brainsnn-r3f-app/src/shell/NewApp.jsx @@ -5,6 +5,7 @@ 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'; @@ -556,6 +557,7 @@ export default function NewApp() { setShowKbHelp(false)} /> +
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/styles/shell.css b/brainsnn-r3f-app/src/styles/shell.css index 9b22166..40db765 100644 --- a/brainsnn-r3f-app/src/styles/shell.css +++ b/brainsnn-r3f-app/src/styles/shell.css @@ -322,6 +322,53 @@ 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. */ diff --git a/brainsnn-r3f-app/src/utils/swUpdate.js b/brainsnn-r3f-app/src/utils/swUpdate.js new file mode 100644 index 0000000..f88c740 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/swUpdate.js @@ -0,0 +1,118 @@ +/** + * 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; + + // 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; + 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) { + 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. + if (navigator.serviceWorker) { + navigator.serviceWorker.addEventListener('controllerchange', () => { + // Don't auto-reload — the user might be mid-edit. Surface + // 'waiting' so the toast offers a manual reload. + 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); +} From 05eba01c47b20e6863f6931a16d5b93300b6e524 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 02:23:41 +0000 Subject: [PATCH 27/33] fix(multitab): same-tab fan-out + sticky bus + stale-read subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Codex P1/P2 fixes on PR #46. All three share a root cause: the cross-tab coordination I shipped in Beast #11 (withLock + multiTab broadcasts) left same-tab synchronous read-after-write callers stuck on stale data. P1 — Snapshots: write delayed, panel sees stale list P2 — Personal Dictionary: same pattern saveSnapshot/addEntry queue their writes through `withLock`, which runs the inner callback in a microtask. The synchronous listSnapshots()/listEntries() right after the write returns pre-mutation state. Cross-tab broadcasts via BroadcastChannel fire-and-forget, but the spec doesn't deliver back to the sender so the originating tab never sees its own publish. Fix (multiTab.js): publish() now does LOCAL fan-out before the BroadcastChannel postMessage. Same-tab subscribers receive their own publishes; the message lands after the lock callback's writeStore() commits. Cross-tab path unchanged. Wired subscriptions where missing: * PersonalDictionaryPanel — subscribe('personalDict:changed') → refresh(). Was reading synchronously with no patch path. * ScanArchivePanel — subscribe('archive:changed') → refresh(). Same gap. Also re-runs the active query filter so search results stay accurate after a removal. * FeedbackPanel — subscribe('feedback:changed') → refresh(). Was polling every 3 seconds; now updates immediately on write (interval stays as a safety net). * SnapshotPanel already had the subscription (shipped e2cd2ba) but the same-tab origin filter meant it never fired locally. Now it does. P1 — Composer: shell:compose fires before lazy panel mounts its listener Composer's requestAnimationFrame delay covers a single frame, but workspace chunks fetch over hundreds of ms on cold first visit (esp. for Knowledge → NeuroRagPanel). Event was emitted into the void and silently dropped. AutopsyPanel / NeuroRagPanel / DiffPanel never received the composed text on first use. Fix (bus.js): added a sticky-event buffer with per-event TTL. When bus.emit() fires a registered sticky event, it stashes the payload +timestamp. Late bus.on() subscriptions for the same event get a microtask-deferred replay if still within the TTL window. shell:compose registered as sticky with 2500 ms (comfortably covers a cold chunk fetch + workspace switch). Bus also exports clearSticky() so callers can invalidate if needed. Build clean. --- .../src/components/FeedbackPanel.jsx | 20 +++++++--- .../components/PersonalDictionaryPanel.jsx | 14 ++++++- .../src/components/ScanArchivePanel.jsx | 16 ++++++-- brainsnn-r3f-app/src/shell/bus.js | 40 +++++++++++++++++++ brainsnn-r3f-app/src/utils/multiTab.js | 15 +++++++ 5 files changed, 94 insertions(+), 11 deletions(-) diff --git a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx index cff2b75..02d1ea5 100644 --- a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx +++ b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx @@ -1,17 +1,25 @@ -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()); + const refresh = useCallback(() => { + setReport(calibrationReport()); + setRows(listFeedback()); + }, []); + useEffect(() => { - const id = setInterval(() => { - setReport(calibrationReport()); - setRows(listFeedback()); - }, 3000); + const id = setInterval(refresh, 3000); return () => clearInterval(id); - }, []); + }, [refresh]); + + // feedback.js publishes 'feedback:changed' on each recordFeedback / + // clearFeedback. Locked writes are async, so this subscription + // patches the UI right after the write commits (no 3s wait). + useEffect(() => subscribeMultiTab('feedback:changed', refresh), [refresh]); function wipe() { if (!window.confirm('Clear all calibration feedback?')) return; diff --git a/brainsnn-r3f-app/src/components/PersonalDictionaryPanel.jsx b/brainsnn-r3f-app/src/components/PersonalDictionaryPanel.jsx index 10db78c..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(''); diff --git a/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx b/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx index a84fac4..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); diff --git a/brainsnn-r3f-app/src/shell/bus.js b/brainsnn-r3f-app/src/shell/bus.js index 145491e..445c0de 100644 --- a/brainsnn-r3f-app/src/shell/bus.js +++ b/brainsnn-r3f-app/src/shell/bus.js @@ -12,10 +12,36 @@ 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); }, @@ -27,6 +53,11 @@ export const bus = { }, 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. @@ -35,6 +66,15 @@ export const bus = { 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); } }; diff --git a/brainsnn-r3f-app/src/utils/multiTab.js b/brainsnn-r3f-app/src/utils/multiTab.js index 891bb5d..af1f94a 100644 --- a/brainsnn-r3f-app/src/utils/multiTab.js +++ b/brainsnn-r3f-app/src/utils/multiTab.js @@ -43,6 +43,21 @@ function ensureChannel() { } 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 { From c39314ce665ae2a324689eff002d6fc4512ffc6e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 23:16:07 +0000 Subject: [PATCH 28/33] feat(engine): runRedTeam + adversarial training into the firewall worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing firewall worker with two new handlers. Same worker pool, no new chunks — keeps the connection cost low while offloading the next two biggest synchronous freezes. Worker handlers added: * runRedTeam ({ thresholds, rules }) 65-sample corpus × scoring pass at 3 thresholds. ~150 ms on default hardware; previously blocked the 3D viewer mid-run. Optional `rules` param serializes/restores around the call so the main thread's active ruleset propagates without leaking worker state. * trainFromRedTeam ({ report, opts }) n-gram lift mining on the report object. Pure compute over serializable data; no rule state needed. Main-thread async wrappers: * src/utils/redTeam.js → runRedTeamAsync(opts) Skips the worker path if a custom scoreFn is supplied (worker can't serialize closures). Falls back inline if the pool is unavailable. * src/utils/adversarialTraining.js → trainFromRedTeamAsync(report, opts) Same fallback semantics. * src/utils/cognitiveFirewall.js → callFirewallWorker(type, payload) Generic pool dispatcher exposed for sister modules so they can reuse the same pool without spawning their own workers. Consumers updated: * RedTeamPanel — runAll() now awaits runRedTeamAsync. The 20 ms setTimeout that existed to let the button re-render before the sync work is no longer needed; UI stays interactive throughout. * AdversarialTrainingPanel — handleTrain() awaits both the baseline run and the n-gram mining via the async variants. Build clean. Firewall worker chunk now hosts four scoring-related operations instead of three; main bundle unchanged. --- .../components/AdversarialTrainingPanel.jsx | 11 +++++---- .../src/components/RedTeamPanel.jsx | 12 +++++----- .../src/utils/adversarialTraining.js | 12 ++++++++++ .../src/utils/cognitiveFirewall.js | 16 +++++++++++++ brainsnn-r3f-app/src/utils/redTeam.js | 22 +++++++++++++++++ .../src/workers/firewall.worker.js | 24 ++++++++++++++++++- 6 files changed, 85 insertions(+), 12 deletions(-) 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/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/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/cognitiveFirewall.js b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js index cca2c3b..22c1c86 100644 --- a/brainsnn-r3f-app/src/utils/cognitiveFirewall.js +++ b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js @@ -262,6 +262,22 @@ function ensurePool() { } } +/** + * 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(); 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/workers/firewall.worker.js b/brainsnn-r3f-app/src/workers/firewall.worker.js index 4b5afa3..7d56e01 100644 --- a/brainsnn-r3f-app/src/workers/firewall.worker.js +++ b/brainsnn-r3f-app/src/workers/firewall.worker.js @@ -19,6 +19,8 @@ import { setActiveRules, getActiveRules } from '../utils/cognitiveFirewall.js'; +import { runRedTeam } from '../utils/redTeam.js'; +import { trainFromRedTeam } from '../utils/adversarialTraining.js'; handleRequests({ score: async ({ text }) => scoreContent(text || ''), @@ -42,5 +44,25 @@ handleRequests({ 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 || {}) }); From b0bd74fa8fab49c0d80e04019269f06228cec999 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 23:18:34 +0000 Subject: [PATCH 29/33] perf(shell): React.memo Topbar / Composer / WorkspaceTabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewApp re-renders every 180 ms (simulation tick → setState). Without memo, the entire shell chrome (header + left rail + top composer) re-rendered ~5×/second even though none of those components visually depend on the brain state. Wrapped the three components in React.memo: * Topbar — depends only on { workspace, firewallResult, immunity, onShowHelp }. firewallResult / immunity / workspace change on user action only; onShowHelp was an inline lambda recreated each render → also memoized in NewApp via useCallback so the shallow compare actually catches. * Composer — zero props (state is internal). The simulation tick no longer redraws the input field. * WorkspaceTabs — { active, onChange }. Both stable across ticks. Behavioural diff: none. Pure perf win on weaker devices / keep-alive workspaces (6 active workspaces × 5 re-renders/sec was the worst case before). Build clean. Bigger architectural fix (split session into stable handlers vs live state via Context) is its own PR if profiler still shows hot paths in the workspaces themselves. --- brainsnn-r3f-app/src/shell/Composer.jsx | 7 ++++++- brainsnn-r3f-app/src/shell/NewApp.jsx | 6 +++++- brainsnn-r3f-app/src/shell/Topbar.jsx | 8 +++++++- brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx | 6 +++++- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/brainsnn-r3f-app/src/shell/Composer.jsx b/brainsnn-r3f-app/src/shell/Composer.jsx index d3fd807..b97bcc3 100644 --- a/brainsnn-r3f-app/src/shell/Composer.jsx +++ b/brainsnn-r3f-app/src/shell/Composer.jsx @@ -8,8 +8,11 @@ import { bus } from './bus'; * * 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. */ -export default function Composer() { +function ComposerImpl() { const [text, setText] = useState(''); const [mode, setMode] = useState('scan'); @@ -61,3 +64,5 @@ export default function Composer() {
); } + +export default React.memo(ComposerImpl); diff --git a/brainsnn-r3f-app/src/shell/NewApp.jsx b/brainsnn-r3f-app/src/shell/NewApp.jsx index a34c081..8e6bcf8 100644 --- a/brainsnn-r3f-app/src/shell/NewApp.jsx +++ b/brainsnn-r3f-app/src/shell/NewApp.jsx @@ -518,6 +518,10 @@ export default function NewApp() { 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 = { @@ -530,7 +534,7 @@ export default function NewApp() { // setters used directly onScrubTimeline: setTimelineIndex, onSelectRegion, - onShowHelp: () => setShowKbHelp(true), + onShowHelp, onGifChange, onReplayBurst, // bundles diff --git a/brainsnn-r3f-app/src/shell/Topbar.jsx b/brainsnn-r3f-app/src/shell/Topbar.jsx index 1506f9d..82eb267 100644 --- a/brainsnn-r3f-app/src/shell/Topbar.jsx +++ b/brainsnn-r3f-app/src/shell/Topbar.jsx @@ -9,7 +9,7 @@ const THEME_CYCLE = ['auto', 'dark', 'light']; * Left: wordmark + active workspace breadcrumb. * Right: theme toggle, ⌘K hint, keyboard help. */ -export default function Topbar({ workspace, firewallResult, immunity, onShowHelp }) { +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() @@ -70,3 +70,9 @@ export default function Topbar({ workspace, firewallResult, immunity, onShowHelp ); } + +// 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/WorkspaceTabs.jsx b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx index ed46faa..c55f2cd 100644 --- a/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx +++ b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx @@ -10,7 +10,7 @@ export const WORKSPACES = [ { id: 'connect', label: 'Connect', chord: 'gc', glyph: '◓' } ]; -export default function WorkspaceTabs({ active, onChange }) { +function WorkspaceTabsImpl({ active, onChange }) { const ref = useRef(null); const activeIdx = WORKSPACES.findIndex((w) => w.id === active); @@ -63,3 +63,7 @@ export default function WorkspaceTabs({ active, onChange }) { ); } + +// 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); From efd070eb60ddfb1cc0b72011f52b53d6b3232465 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 23:20:39 +0000 Subject: [PATCH 30/33] feat(engine): CodeBrain search workloads into a dedicated worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth worker chunk on the branch. CodeBrain's parse → community detect → BM25 index pipeline was a synchronous ~1-2 s freeze on larger pastes (the GitNexus repo trace, for instance). All three steps are pure compute over JSON-safe data; trivial to push off the main thread. * src/workers/search.worker.js Handlers: parseFiles, analyzeCode (combined parse + Louvain + BM25 index), hybridSearch. analyzeCode fuses the three operations so the panel only pays one postMessage round-trip for the whole pipeline. * src/utils/searchWorker.js Main-thread API: analyzeCodeAsync(files), hybridSearchAsync( query, index, opts). Lazy pool spawn (single slot — code analysis is serial). Inline fallback for SSR/test env. * src/components/CodeBrainPanel.jsx Refactored: dropped the useMemo over graph (worker now returns the analyzed shape in one shot), added `analyzing` state. Semantic search path stays inline because embed() already proxies through its own worker — double-hopping would cost more than it saves. Build emits search.worker-*.js chunk. The five other workers unchanged. Initial bundle untouched. Worker chunks on the branch: firewall.worker (score/scoreWithRules/setRules + runRedTeam + trainFromRedTeam) embeddings.worker (MiniLM via transformers.js) evolve.worker (Brain Evolve UCB1/Island/MAP-Elites) attackEvolve.worker (Attack Evolve mutation loop) search.worker (parse + Louvain + BM25 — this PR) --- .../src/components/CodeBrainPanel.jsx | 40 +++++----- brainsnn-r3f-app/src/utils/searchWorker.js | 77 +++++++++++++++++++ brainsnn-r3f-app/src/workers/search.worker.js | 37 +++++++++ 3 files changed, 136 insertions(+), 18 deletions(-) create mode 100644 brainsnn-r3f-app/src/utils/searchWorker.js create mode 100644 brainsnn-r3f-app/src/workers/search.worker.js 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/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/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 }) +}); From 8af8bf41c4beaecb41e1ecfebdb338d70c5abb79 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 23:21:17 +0000 Subject: [PATCH 31/33] =?UTF-8?q?chore(perf):=20drop=20FeedbackPanel=20pol?= =?UTF-8?q?ling=20=E2=80=94=20live=20subscription=20handles=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beast PR #11 follow-up cleanup. FeedbackPanel kept a 3-second setInterval as a safety net when the multiTab subscription was added. Now that publish() fan-outs same-tab subscribers (fixed in 05eba01), the live subscription is the source of truth and the poll is pure waste — wakes the main thread every 3 seconds on every browser tab even when nothing has happened. Single-file change. listFeedback / calibrationReport still run on mount + on every 'feedback:changed' event. --- brainsnn-r3f-app/src/components/FeedbackPanel.jsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx index 02d1ea5..afd9133 100644 --- a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx +++ b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx @@ -11,14 +11,11 @@ export default function FeedbackPanel() { setRows(listFeedback()); }, []); - useEffect(() => { - const id = setInterval(refresh, 3000); - return () => clearInterval(id); - }, [refresh]); - // feedback.js publishes 'feedback:changed' on each recordFeedback / // clearFeedback. Locked writes are async, so this subscription - // patches the UI right after the write commits (no 3s wait). + // 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() { From bb714854830100a4ce833821b9c1694c8477bf1c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 23:30:21 +0000 Subject: [PATCH 32/33] fix(pwa): gate controllerchange before showing 'new version' banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 review fix on PR #46. controllerchange fires on the FIRST service-worker install too (no previous controller → a fresh controller), not just on updates. That meant first-time visitors got the "A new version is ready — Reload" banner before they had done anything — a confusing prompt to reload an app they just opened. Fix in src/utils/swUpdate.js: * `sawUpdate` flag inside track() flips to true only when an updatefound or statechange event indicates a real update transition (installing while a controller already exists). * controllerchange handler now broadcasts 'waiting' ONLY when sawUpdate is true OR reg.waiting is set. First-install controllerchange falls through silently. * As a defensive measure, the `installed` branch of the statechange listener flips sawUpdate true when it detects an installing worker arriving while a controller already exists — covers the edge case where updatefound fires before track() has wired its sawUpdate flag. No behavioural change for actual deploys — banner still appears when a new worker is waiting after an update. --- brainsnn-r3f-app/src/utils/swUpdate.js | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/brainsnn-r3f-app/src/utils/swUpdate.js b/brainsnn-r3f-app/src/utils/swUpdate.js index f88c740..ab0668b 100644 --- a/brainsnn-r3f-app/src/utils/swUpdate.js +++ b/brainsnn-r3f-app/src/utils/swUpdate.js @@ -30,6 +30,11 @@ function broadcast(patch) { 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. @@ -43,6 +48,10 @@ function track(reg) { 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') { @@ -50,6 +59,7 @@ function track(reg) { // 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' }); @@ -63,11 +73,16 @@ function track(reg) { }); // 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', () => { - // Don't auto-reload — the user might be mid-edit. Surface - // 'waiting' so the toast offers a manual reload. - broadcast({ status: 'waiting' }); + const reallyWaiting = sawUpdate || !!_registration?.waiting; + if (reallyWaiting) { + broadcast({ status: 'waiting' }); + } }); } } From d369e09ef66215d0f84f84009604686b76bc1a23 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 23:42:52 +0000 Subject: [PATCH 33/33] =?UTF-8?q?fix:=203=20Codex=20review=20issues=20?= =?UTF-8?q?=E2=80=94=20palette=20workspace=20mount,=20onboarding=20retry,?= =?UTF-8?q?=20embeddings=20localStorage=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 — CommandPalette layer jumps no-op on unvisited workspaces. findAndFlashPanel searched the DOM directly without dispatching shell:goto, so picking a layer from an unvisited workspace silently failed (the panel tree wasn't mounted yet). The flashLayer helper at src/utils/flashLayer.js already does this correctly — dispatches shell:goto, waits two rAFs, then scrolls + rings. CommandPalette never adopted it. CommandPalette now uses flashLayer(row.id) on pick. Inline findAndFlashPanel deleted. Mouse + keyboard layer jumps now work to any workspace regardless of visit history. P2 — OnboardingWalkthrough single-rAF lookup fails on lazy panels. After dispatching shell:goto, the walkthrough waited one rAF then queried document.querySelector(target). For destinations inside lazy-loaded workspace chunks (e.g. .snapshot-panel in BrainWorkspace) the chunk takes 100s of ms to fetch on a cold first navigate — one rAF is far too early, target query returns null, console.warn fires, walkthrough silently skips the step. Now polls every 50ms for up to 2s (40 attempts). First attempt still runs on rAF so the workspace swap settles before querying. Each attempt cancels cleanly on step change / unmount. P2 — embeddingsStore loses vectors under localStorage fallback. store.js falls back to JSON-backed localStorage when IndexedDB is unavailable (private mode in Safari, etc). JSON.stringify on a Float32Array produces a numerically-keyed plain object (`{"0":1.23,"1":-0.5}`). On read, Float32Array.from(obj) sees no .length and returns an EMPTY typed array — every "cached" vector becomes zero-similarity and semantic features silently degrade. Fix: * setCached now always serializes via Array.from(vec) so the stored shape is a JSON-safe plain Array regardless of backend. * New normalizeVec() helper handles Float32Array (IDB path), Array (new fallback writes), and numeric-keyed objects (legacy fallback writes that pre-date this fix). Returns null for genuinely empty values so the caller doesn't get a zero-length vector. * On a successful read from an object-shaped row, the cache is rewritten as a plain Array so subsequent reads round-trip correctly. Build clean. --- .../src/components/CommandPalette.jsx | 34 ++++----------- .../src/components/OnboardingWalkthrough.jsx | 41 ++++++++++++++----- brainsnn-r3f-app/src/utils/embeddingsStore.js | 40 +++++++++++++++--- 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/brainsnn-r3f-app/src/components/CommandPalette.jsx b/brainsnn-r3f-app/src/components/CommandPalette.jsx index f19ca7d..49d8d45 100644 --- a/brainsnn-r3f-app/src/components/CommandPalette.jsx +++ b/brainsnn-r3f-app/src/components/CommandPalette.jsx @@ -1,14 +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) { @@ -26,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(''); @@ -90,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/OnboardingWalkthrough.jsx b/brainsnn-r3f-app/src/components/OnboardingWalkthrough.jsx index a58d366..b6e7d98 100644 --- a/brainsnn-r3f-app/src/components/OnboardingWalkthrough.jsx +++ b/brainsnn-r3f-app/src/components/OnboardingWalkthrough.jsx @@ -85,28 +85,49 @@ export default function OnboardingWalkthrough() { }; // Highlight target element. When the new shell is active and the - // target lives in a different workspace, dispatch shell:goto first - // and wait one frame for that workspace to mount before querying - // the DOM. + // target lives in a different workspace, dispatch shell:goto first. + // Then poll for the target — the destination panel is lazy-loaded + // inside the workspace chunk, so a single rAF is too early on + // cold first navigation (the chunk fetch takes 100s of ms). Retry + // every 50ms up to 2s before giving up. useEffect(() => { if (step < 0 || step >= STEPS.length) return; const target = STEPS[step].target; if (!target) return; const ws = ANCHOR_WORKSPACE[target]; if (ws) bus.emit('shell:goto', { workspace: ws }); + let cleanup = () => {}; - const raf = requestAnimationFrame(() => { + let cancelled = false; + let attempts = 0; + const MAX_ATTEMPTS = 40; // 40 × 50ms = 2 s + let timer = null; + + const tryLand = () => { + if (cancelled) return; const el = document.querySelector(target); - if (!el) { - if (typeof console !== 'undefined') console.warn('[onboarding] target missing:', target); + if (el) { + el.classList.add('onboard-highlight'); + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + cleanup = () => el.classList.remove('onboard-highlight'); + return; + } + attempts += 1; + if (attempts >= MAX_ATTEMPTS) { + if (typeof console !== 'undefined') console.warn('[onboarding] target missing after retries:', target); return; } - el.classList.add('onboard-highlight'); - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - cleanup = () => el.classList.remove('onboard-highlight'); - }); + timer = setTimeout(tryLand, 50); + }; + + // Start on the next rAF so the workspace switch from shell:goto + // has a chance to settle before the first lookup. + const raf = requestAnimationFrame(tryLand); + return () => { + cancelled = true; cancelAnimationFrame(raf); + if (timer) clearTimeout(timer); cleanup(); }; }, [step]); diff --git a/brainsnn-r3f-app/src/utils/embeddingsStore.js b/brainsnn-r3f-app/src/utils/embeddingsStore.js index 8e8f603..94f2cc5 100644 --- a/brainsnn-r3f-app/src/utils/embeddingsStore.js +++ b/brainsnn-r3f-app/src/utils/embeddingsStore.js @@ -75,17 +75,40 @@ async function pruneIfNeeded() { } 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. - const refreshed = { vec: row.vec, t: Date.now() }; - store().set(hash, refreshed).catch(() => { /* noop */ }); - // IDB returns plain typed arrays directly; if a structured-clone - // path ever boxes it, restore the Float32Array view. - return row.vec instanceof Float32Array ? row.vec : Float32Array.from(row.vec); + // 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; } @@ -93,7 +116,12 @@ export async function getCached(hash) { export async function setCached(hash, vec) { try { - await store().set(hash, { vec, t: Date.now() }); + // 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();