Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/cspec-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,27 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="data:," />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
/>
<title>cspec Editor</title>
<script>
// Resolve the theme before React mounts to avoid a flash of the wrong theme.
(function () {
try {
var stored = localStorage.getItem("cspec-theme");
var theme = stored || (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
document.documentElement.dataset.theme = theme;
} catch (e) {
document.documentElement.dataset.theme = "dark";
}
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
105 changes: 87 additions & 18 deletions packages/cspec-app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import {
BookOpen,
Box,
CheckCircle2,
ChevronRight,
Code2,
FileCode2,
FileText,
FolderOpen,
Hammer,
Moon,
PanelRightOpen,
RefreshCcw,
Save,
Search,
Sun,
Terminal,
Workflow
} from "lucide-react";
Expand All @@ -38,13 +41,21 @@ interface AppFile extends CspecSourceFile {
}

type Panel = "problems" | "references" | "build";
type Theme = "dark" | "light";

declare global {
interface Window {
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>;
}
}

function initialTheme(): Theme {
if (typeof document !== "undefined" && document.documentElement.dataset.theme) {
return document.documentElement.dataset.theme === "light" ? "light" : "dark";
}
return "dark";
}

function App() {
const [files, setFiles] = useState<AppFile[]>(demoFiles);
const [activeFile, setActiveFile] = useState(demoFiles[1].file);
Expand All @@ -55,9 +66,21 @@ function App() {
const [highlight, setHighlight] = useState<CspecJumpTarget | null>(null);
const [cursor, setCursor] = useState(0);
const [workspaceRoot, setWorkspaceRoot] = useState<FileSystemDirectoryHandle | null>(null);
const [theme, setTheme] = useState<Theme>(initialTheme);
const [savePulse, setSavePulse] = useState(0);
const [checkPulse, setCheckPulse] = useState({ n: 0, ok: true });
const filesRef = useRef(files);
filesRef.current = files;

useEffect(() => {
document.documentElement.dataset.theme = theme;
try {
localStorage.setItem("cspec-theme", theme);
} catch {
/* storage may be unavailable */
}
}, [theme]);

const active = files.find((file) => file.file === activeFile) ?? files[0];
const index = useMemo(() => createWorkspaceIndex(files), [files]);
const preview = useMemo(() => active ? compileMarkdownPreview(active.file, active.source, files) : null, [active, files]);
Expand All @@ -69,6 +92,10 @@ function App() {
setFiles((current) => current.map((file) => file.file === activeFile ? { ...file, source, dirty: true } : file));
}

function toggleTheme() {
setTheme((current) => (current === "dark" ? "light" : "dark"));
}

async function openWorkspace() {
if (!window.showDirectoryPicker) {
setBuildOutput("This browser does not expose the File System Access API. The demo workspace remains editable.");
Expand Down Expand Up @@ -102,6 +129,7 @@ function App() {
}
setFiles((current) => current.map((file) => file.file === active.file ? { ...file, dirty: false } : file));
setBuildOutput(`Saved ${active.file}.`);
setSavePulse((n) => n + 1);
}

async function runBuild() {
Expand All @@ -117,8 +145,10 @@ function App() {
}

function runCheck() {
setBuildOutput(index.diagnostics.length ? `${index.diagnostics.length} diagnostic(s) found.` : "cspec check passed.");
const failed = index.diagnostics.length > 0;
setBuildOutput(failed ? `${index.diagnostics.length} diagnostic(s) found.` : "cspec check passed.");
setPanel("problems");
setCheckPulse((p) => ({ n: p.n + 1, ok: !failed }));
}

function jumpTo(target: CspecJumpTarget) {
Expand All @@ -133,8 +163,11 @@ function App() {
<div className="app">
<aside className="sidebar">
<div className="sidebarHeader">
<button className="iconButton" onClick={openWorkspace} title="Open workspace"><FolderOpen size={18} /></button>
<button className="iconButton" onClick={() => setBuildOutput("Workspace refreshed.")} title="Refresh workspace"><RefreshCcw size={18} /></button>
<span className="sidebarBrand"><BookOpen size={16} />cspec</span>
<div className="sidebarHeaderActions">
<button className="iconButton" onClick={openWorkspace} title="Open workspace" aria-label="Open workspace"><FolderOpen size={17} /></button>
<button className="iconButton" onClick={() => setBuildOutput("Workspace refreshed.")} title="Refresh workspace" aria-label="Refresh workspace"><RefreshCcw size={17} /></button>
</div>
</div>
<label className="filter">
<Search size={15} />
Expand Down Expand Up @@ -166,14 +199,18 @@ function App() {
<main className="workspace">
<header className="toolbar">
<div className="titleCluster">
<FileCode2 size={18} />
<strong>{active?.file ?? "No file"}</strong>
<FileCode2 className="crumbIcon" size={18} />
<Breadcrumb file={active?.file} />
</div>
<div className="toolbarActions">
<button onClick={() => setSourceModeState((value) => !value)}><Code2 size={16} />{sourceMode ? "Hybrid" : "Source"}</button>
<button onClick={saveFile}><Save size={16} />Save</button>
<button onClick={runCheck}><CheckCircle2 size={16} />Check</button>
<button onClick={runBuild}><Hammer size={16} />Build</button>
<div className="segmented" role="group" aria-label="Editor mode">
<span className="segmentedThumb" aria-hidden style={{ transform: sourceMode ? "translateX(0)" : "translateX(100%)" }} />
<button className={`seg ${sourceMode ? "active" : ""}`} onClick={() => setSourceModeState(true)} aria-pressed={sourceMode}><Code2 size={15} />Source</button>
<button className={`seg ${sourceMode ? "" : "active"}`} onClick={() => setSourceModeState(false)} aria-pressed={!sourceMode}><BookOpen size={15} />Hybrid</button>
</div>
<button key={`save-${savePulse}`} className={`btn-tonal ${savePulse ? "pulse-success" : ""}`} onClick={saveFile}><Save size={16} />Save</button>
<button key={`check-${checkPulse.n}`} className={`btn-ghost ${checkPulse.n ? (checkPulse.ok ? "pulse-success" : "pulse-error") : ""}`} onClick={runCheck}><CheckCircle2 size={16} />Check</button>
<button className="btn-primary" onClick={runBuild}><Hammer size={16} />Build</button>
</div>
</header>

Expand All @@ -194,25 +231,29 @@ function App() {
)}
</div>
<aside className="previewPane">
<div className="paneHeader"><PanelRightOpen size={16} />Compiled Markdown</div>
<div className="paneHeader"><PanelRightOpen size={14} />Compiled Markdown</div>
{preview?.ok ? <pre>{preview.markdown}</pre> : (
<div className="emptyState">
<AlertCircle size={18} />
<span>Preview paused until diagnostics are resolved.</span>
<div className="emptyIcon"><AlertCircle size={18} /></div>
<strong>Preview paused</strong>
<span>Resolve diagnostics to see the compiled Markdown.</span>
</div>
)}
</aside>
</section>

<section className="bottomPanel">
<div className="tabs">
<button className={panel === "problems" ? "selected" : ""} onClick={() => setPanel("problems")}><AlertCircle size={15} />Problems</button>
<button className={panel === "problems" ? "selected" : ""} onClick={() => setPanel("problems")}>
<AlertCircle size={15} />Problems
{index.diagnostics.length > 0 && <span className="tabBadge">{index.diagnostics.length}</span>}
</button>
<button className={panel === "references" ? "selected" : ""} onClick={() => setPanel("references")}><Workflow size={15} />References</button>
<button className={panel === "build" ? "selected" : ""} onClick={() => setPanel("build")}><Terminal size={15} />Build Output</button>
</div>
{panel === "problems" && (
<div className="panelList">
{index.diagnostics.length === 0 ? <p>No diagnostics.</p> : index.diagnostics.map((diag, index) => (
{index.diagnostics.length === 0 ? <p><CheckCircle2 size={15} color="var(--success)" />No problems detected.</p> : index.diagnostics.map((diag, index) => (
<button key={index} onClick={() => jumpTo({ file: diag.file, from: diag.from, to: diag.to })}>
<AlertCircle size={15} />
<span>{diag.file}: line {lineAt(files.find((file) => file.file === diag.file)?.source ?? "", diag.from)}</span>
Expand All @@ -236,10 +277,21 @@ function App() {
</section>

<footer className="statusbar">
<span>{active?.dirty ? "Dirty" : "Saved"}</span>
<span>{sourceMode ? "Source mode" : "Hybrid mode"}</span>
<span>{currentDiagnostics.length ? `${currentDiagnostics.length} issue(s)` : "Valid"}</span>
<span>{currentBlock ? `Block ${currentBlock.id}` : "No block selected"}</span>
<span className={`statusSeg ${active?.dirty ? "is-warn" : "is-success"}`}>
{active?.dirty ? <><i className="statusDot" />Unsaved</> : <><CheckCircle2 size={13} />Saved</>}
</span>
<span className="statusSeg"><Code2 size={13} />{sourceMode ? "Source" : "Hybrid"}</span>
<span className={`statusSeg ${currentDiagnostics.length ? "is-error" : "is-success"}`}>
{currentDiagnostics.length
? <><AlertCircle size={13} /><span className="count">{currentDiagnostics.length}</span>&nbsp;issue{currentDiagnostics.length === 1 ? "" : "s"}</>
: <><CheckCircle2 size={13} />Valid</>}
</span>
<span className="statusSeg">
{currentBlock ? <><span className="statusMarker">S</span>{currentBlock.id}</> : <span style={{ color: "var(--text-faint)" }}>No block selected</span>}
</span>
<button className="themeToggle" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle color theme">
{theme === "dark" ? <Sun size={14} key="sun" /> : <Moon size={14} key="moon" />}
</button>
</footer>
</main>
</div>
Expand Down Expand Up @@ -351,6 +403,23 @@ function lineAt(source: string, offset: number): number {
return line;
}

function Breadcrumb({ file }: { file?: string }) {
if (!file) return <strong>No file</strong>;
const parts = file.split("/");
const leaf = parts.pop() ?? file;
return (
<span className="breadcrumb">
{parts.map((part, index) => (
<React.Fragment key={index}>
<span className="crumb">{part}</span>
<ChevronRight className="crumbSep" size={12} />
</React.Fragment>
))}
<strong>{leaf}</strong>
</span>
);
}

function blockAtCursor(file: string, offset: number, index: CspecWorkspaceIndex) {
return index.blocks
.filter((block) => block.file === file && offset >= block.range.start && offset <= block.range.end)
Expand Down
Loading