import React, { useEffect, useRef, useState } from 'react';
import Icon from './Icon.jsx';
import { useStudio, questionsFor, readiness, contextKey, log, visibleWorkspaces } from './store.jsx';
import { makeDemoSuite, generationStages } from './generation.js';
import { Badge, Button, Empty, Modal, Note, Search } from './ui.jsx';
import Dashboard from './pages/Dashboard.jsx';
import Workspaces from './pages/Workspaces.jsx';
import Baselines from './pages/Baselines.jsx';
import Evidence from './pages/Evidence.jsx';
import Versions from './pages/Versions.jsx';
import Chat from './pages/Chat.jsx';
import Readiness from './pages/Readiness.jsx';
import Suites from './pages/Suites.jsx';
import Templates from './pages/Templates.jsx';
import Approvals from './pages/Approvals.jsx';
import Graph from './pages/Graph.jsx';
import Admin from './pages/Admin.jsx';
import Login from './pages/Login.jsx';
import './studio.css';
export const navigation = [
{ group: 'TEAM SPACE', items: [ ['overview', 'Overview', 'overview'], ['workspaces', 'Workspaces', 'folder'], ['baselines', 'System Baselines', 'book'], ['templates', 'Template library', 'checkSquare'] ] },
{ group: 'CURRENT WORKSPACE', items: [ ['workspace', 'CR evidence', 'file'], ['versions', 'Knowledge versions', 'branch'], ['chat', 'Workspace chat', 'chat'], ['readiness', 'Generation readiness', 'spark'], ['suites', 'Test suites', 'checkSquare'], ['graph', 'Dependency graph', 'graph'] ] },
{ group: 'GOVERNANCE', items: [ ['approvals', 'System Baseline updates', 'shield'], ['admin', 'Administration', 'settings'] ] },
];
const pages = navigation.flatMap(g => g.items);
const readRoute = () => { const [page = 'overview', query = ''] = location.hash.slice(1).split('?'); return { page: page || 'overview', create: query === 'create' }; };
export default function App() {
const { state: s, mutate, workspace: w, toast, notify, saveError } = useStudio();
const [route, setRoute] = useState(readRoute), [modal, setModal] = useState(''), [query, setQuery] = useState(''), [menuOpen, setMenuOpen] = useState(false), [teamOpen, setTeamOpen] = useState(false);
const [dark, setDark] = useState(() => { try { return localStorage.getItem('ccil-preview-theme') === 'dark'; } catch { return false; } });
const main = useRef(null), { page, create } = route;
const teamWorkspaces = visibleWorkspaces(s);
const questions = teamWorkspaces.flatMap(w => questionsFor(s, w).filter(q => q.status !== 'resolved'));
function navigate(value) { location.hash = value; setRoute(readRoute()); setMenuOpen(false); setTeamOpen(false); main.current?.scrollTo(0, 0); }
useEffect(() => { const update = () => { setRoute(readRoute()); main.current?.scrollTo(0, 0); }; window.addEventListener('hashchange', update); return () => window.removeEventListener('hashchange', update); }, []);
useEffect(() => { document.documentElement.dataset.theme = dark ? 'dark' : 'light'; try { localStorage.setItem('ccil-preview-theme', dark ? 'dark' : 'light'); } catch { /* Theme still works when storage is unavailable. */ } }, [dark]);
useEffect(() => { const keys = e => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setQuery(''); setModal(m => m ? '' : 'command'); } if (e.key === 'Escape') { setTeamOpen(false); setMenuOpen(false); } }; window.addEventListener('keydown', keys); return () => window.removeEventListener('keydown', keys); }, []);
useEffect(() => {
const timers = s.jobs.filter(j => j.status === 'running').map(j => setTimeout(() => mutate(d => {
const job = d.jobs.find(item => item.id === j.id); if (job?.status !== 'running') return;
const owner = d.workspaces.find(w => w.id === job.workspaceId);
if (!owner || contextKey(owner) !== job.context) { job.status = 'Cancelled'; job.stage = 'Knowledge context changed. Start a new run.'; return; }
const scoped = { ...d, session: { ...d.session, team: owner.team } };
if (!job.provisional && !readiness(scoped, owner).ready) { job.status = 'Failed'; job.stage = 'Readiness changed. Review the current evidence before retrying.'; return; }
job.stageIndex += 1; job.stage = generationStages[job.stageIndex];
if (job.stageIndex >= 3) {
let suite;
try { suite = makeDemoSuite(scoped, owner, job.templateId, job.provisional); }
catch (error) { job.status = 'Failed'; job.stage = error.message; return; }
if (!suite.tests.length) { job.status = 'Failed'; job.stage = 'No source-linked rules are available for the local generator.'; return; }
d.suites.unshift(suite); job.status = 'Completed'; job.suiteId = suite.id; log(d, `Completed test suite for ${owner.ref}`);
}
}), 900));
return () => timers.forEach(clearTimeout);
}, [s.jobs, s.workspaces, s.questions, s.documents, s.shares]);
let content;
switch (page) {
case 'overview': content = <Dashboard navigate={navigate}/>; break;
case 'workspaces': content = <Workspaces navigate={navigate} create={create}/>; break;
case 'baselines': content = <Baselines navigate={navigate} create={create}/>; break;
case 'workspace': content = <Evidence navigate={navigate}/>; break;
case 'versions': content = <Versions navigate={navigate}/>; break;
case 'chat': content = <Chat navigate={navigate}/>; break;
case 'readiness': content = <Readiness navigate={navigate}/>; break;
case 'suites': content = <Suites navigate={navigate}/>; break;
case 'templates': content = <Templates navigate={navigate}/>; break;
case 'approvals': content = <Approvals navigate={navigate}/>; break;
case 'graph': content = <Graph navigate={navigate}/>; break;
case 'admin': content = <Admin navigate={navigate}/>; break;
case 'login': content = <Login navigate={navigate}/>; break;
default: content = <Empty title="Page not found" description="Choose a page from the studio navigation." action={<Button onClick={() => navigate('overview')}>Back to overview</Button>}/>;
}
const baseline = s.baselines.find(b => b.id === w?.baselineId), title = pages.find(item => item[0] === page)?.[1] || 'Sign in';
function signOut() { mutate(d => { d.session.authenticated = false; }); navigate('login'); }
if (page === 'login' || !s.session.authenticated) return <Login navigate={navigate}/>;
return <div className="app-shell"><a className="skip-link" href="#main-content" onClick={e => { e.preventDefault(); document.getElementById('main-content')?.focus(); }}>Skip to content</a>{menuOpen && <button className="sidebar-backdrop" aria-label="Close navigation" onClick={() => setMenuOpen(false)}/>}
<aside className={`sidebar ${menuOpen ? 'is-open' : ''}`}><button className="brand" onClick={() => navigate('overview')} aria-label="CCIL Test Studio home"><span className="brand-symbol"><Icon name="layers" size={24}/></span><span><strong>CCIL <span>Test Studio</span></strong><small>EVIDENCE. CLARITY. CONFIDENCE.</small></span></button><div className="team-switcher-wrap"><button className="team-switcher" onClick={signOut} aria-label="Change team or sign out"><span className="team-avatar">{s.session.team[0]}</span><span><strong>{s.session.team}</strong><small>Shared team workspace</small></span><Icon name="down" size={14}/></button></div><nav className="main-nav" aria-label="Main navigation">{navigation.map(group => <div className="nav-group" key={group.group}><span className="nav-group-label">{group.group}</span>{group.items.map(([id, label, icon]) => <button className={`nav-item ${id === page ? 'active' : ''}`} key={id} aria-current={id === page ? 'page' : undefined} onClick={() => navigate(id)}><Icon name={icon} size={18}/><span>{label}</span>{id === 'workspaces' && <span className="nav-count">{teamWorkspaces.length}</span>}{id === 'chat' && questionsFor(s, w).some(q => q.status !== 'resolved') && <span className="nav-count nav-count-amber">{questionsFor(s, w).filter(q => q.status !== 'resolved').length}</span>}{id === 'approvals' && s.candidates.some(c => c.team === s.session.team && c.status === 'Pending review') && <span className="nav-count">{s.candidates.filter(c => c.team === s.session.team && c.status === 'Pending review').length}</span>}</button>)}</div>)}</nav><div className="sidebar-bottom"><button className="sidebar-baseline" onClick={() => navigate(w ? 'versions' : 'baselines')}><span className="baseline-symbol"><Icon name="layers" size={17}/></span><span><small>Selected Baseline</small><strong>{baseline?.ref || 'Choose a workspace'}{baseline && <span>v{baseline.version}</span>}</strong></span>{baseline && <span className="tiny-dot green-dot"/>}</button><button className="profile" onClick={signOut}><span className="profile-avatar"><Icon name="users" size={18}/></span><span><strong>{`${s.session.team} ${s.session.role === 'administrator' ? 'Administrator' : 'User'}`}</strong><small>Sign out / change team</small></span><Icon name="down" size={14}/></button></div></aside>
<div className="app-body"><header className="topbar"><button className="icon-button mobile-menu" onClick={() => setMenuOpen(true)} aria-label="Open navigation"><Icon name="menu"/></button><div className="breadcrumbs"><span>{s.session.team}</span><Icon name="chevron" size={12}/><strong>{title}</strong></div><select className="header-workspace" value={w?.id || ''} aria-label="Active workspace" onChange={e => { mutate(d => { d.activeWorkspaceId = e.target.value; }); notify('Active workspace context changed.'); }}><option value="">Select workspace</option>{teamWorkspaces.map(w => <option key={w.id} value={w.id}>{w.ref} · {w.name}</option>)}</select><div className="topbar-actions"><button className="global-search" onClick={() => { setQuery(''); setModal('command'); }} aria-label="Search pages"><Icon name="search" size={16}/><span>Jump to…</span><kbd>Ctrl K</kbd></button><span className="topbar-divider"/><button className="icon-button" onClick={() => setDark(!dark)} aria-label={dark ? 'Switch to light theme' : 'Switch to dark theme'}><Icon name={dark ? 'sun' : 'moon'} size={18}/></button><button className="icon-button notification-button" onClick={() => setModal('notifications')} aria-label="Open attention queue"><Icon name="bell" size={19}/>{questions.length > 0 && <span/>}</button></div></header><main ref={main} id="main-content" className={`main-content page-${page}`} tabIndex={-1}>{saveError && <p className="storage-error" role="alert">{saveError}</p>}<div key={`${page}:${w?.id || s.session.team}`}>{content}</div><footer className="app-footer"><span>CCIL Test Studio <span>·</span> Evidence-led testing</span></footer></main></div>
{toast && <div className="toast" role="status"><Icon name="check" size={17}/><span>{toast}</span><button onClick={() => notify('')} aria-label="Dismiss notification"><Icon name="close" size={15}/></button></div>}
{modal === 'command' && <Modal title="Go anywhere" close={() => setModal('')}><Search value={query} onChange={setQuery} placeholder="Search studio pages…"/><div className="command-results">{pages.filter(([, label]) => label.toLowerCase().includes(query.toLowerCase())).map(([id, label, icon]) => <button key={id} onClick={() => { navigate(id); setModal(''); }}><Icon name={icon} size={18}/>{label}<Icon name="arrow" size={16}/></button>)}</div>{!pages.some(([, label]) => label.toLowerCase().includes(query.toLowerCase())) && <Empty title="No matching pages" description="Try another page name."/>}</Modal>}
{modal === 'notifications' && <Modal title="Your attention queue" close={() => setModal('')}>{questions.map(q => <button className="history-row" key={q.id} onClick={() => { mutate(d => { d.activeWorkspaceId = q.workspaceId; }); navigate('chat'); setModal(''); }}><Icon name="chat" size={17}/><span><strong>{q.title}</strong><small>{s.workspaces.find(w => w.id === q.workspaceId)?.ref} · {q.status}</small></span><Icon name="chevron" size={15}/></button>)}{!questions.length && <Empty title="No open clarifications" description="Your team has no pending evidence questions."/>}</Modal>}
</div>;
}
For further actions, you may consider blocking this person and/or reporting abuse
Top comments (0)