diff --git a/packages/cspec-app/index.html b/packages/cspec-app/index.html index a7b4d43..d606cda 100644 --- a/packages/cspec-app/index.html +++ b/packages/cspec-app/index.html @@ -3,8 +3,27 @@ + + + + cspec Editor +
diff --git a/packages/cspec-app/src/main.tsx b/packages/cspec-app/src/main.tsx index 50360d9..1de02e5 100644 --- a/packages/cspec-app/src/main.tsx +++ b/packages/cspec-app/src/main.tsx @@ -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"; @@ -38,6 +41,7 @@ interface AppFile extends CspecSourceFile { } type Panel = "problems" | "references" | "build"; +type Theme = "dark" | "light"; declare global { interface Window { @@ -45,6 +49,13 @@ declare global { } } +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(demoFiles); const [activeFile, setActiveFile] = useState(demoFiles[1].file); @@ -55,9 +66,21 @@ function App() { const [highlight, setHighlight] = useState(null); const [cursor, setCursor] = useState(0); const [workspaceRoot, setWorkspaceRoot] = useState(null); + const [theme, setTheme] = useState(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]); @@ -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."); @@ -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() { @@ -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) { @@ -133,8 +163,11 @@ function App() {
@@ -206,13 +244,16 @@ function App() {
- +
{panel === "problems" && (
- {index.diagnostics.length === 0 ?

No diagnostics.

: index.diagnostics.map((diag, index) => ( + {index.diagnostics.length === 0 ?

No problems detected.

: index.diagnostics.map((diag, index) => (
- {active?.dirty ? "Dirty" : "Saved"} - {sourceMode ? "Source mode" : "Hybrid mode"} - {currentDiagnostics.length ? `${currentDiagnostics.length} issue(s)` : "Valid"} - {currentBlock ? `Block ${currentBlock.id}` : "No block selected"} + + {active?.dirty ? <>Unsaved : <>Saved} + + {sourceMode ? "Source" : "Hybrid"} + + {currentDiagnostics.length + ? <>{currentDiagnostics.length} issue{currentDiagnostics.length === 1 ? "" : "s"} + : <>Valid} + + + {currentBlock ? <>S{currentBlock.id} : No block selected} + +
@@ -351,6 +403,23 @@ function lineAt(source: string, offset: number): number { return line; } +function Breadcrumb({ file }: { file?: string }) { + if (!file) return No file; + const parts = file.split("/"); + const leaf = parts.pop() ?? file; + return ( + + {parts.map((part, index) => ( + + {part} + + + ))} + {leaf} + + ); +} + function blockAtCursor(file: string, offset: number, index: CspecWorkspaceIndex) { return index.blocks .filter((block) => block.file === file && offset >= block.range.start && offset <= block.range.end) diff --git a/packages/cspec-app/src/styles.css b/packages/cspec-app/src/styles.css index 8f63de3..4bed5c4 100644 --- a/packages/cspec-app/src/styles.css +++ b/packages/cspec-app/src/styles.css @@ -1,34 +1,257 @@ +/* =========================================================================== + Graphite — dark-first graphite-and-ink IDE for cspec. + Single source of truth: every color in this file AND in the CodeMirror + cspecTheme object is a CSS custom property. Dark is the :root default; + [data-theme="light"] overrides it. One flip recolors the whole app, + editor surface included. + =========================================================================== */ + :root { - color: #1f2933; - background: #f4f6f8; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + /* --- theme-independent tokens --- */ + --font-ui: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Monaco, Consolas, monospace; + + --r-sm: 6px; + --r-md: 8px; + --r-pill: 999px; + + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + + --ease: cubic-bezier(0.2, 0.8, 0.2, 1); + --dur-fast: 120ms; + --dur: 180ms; + --dur-slow: 240ms; + + /* --- dark theme (default) --- */ + color-scheme: dark; + + --bg: #0e0f13; + --surface: #16181d; + --surface-raised: #1c1f26; + --surface-inset: #101216; + --border: #2a2e37; + --border-strong: #353a45; + --text-primary: #e7e9ee; + --text-muted: #9aa1ad; + --text-faint: #6b7280; + + --accent: #6e7bff; + --accent-hover: #828dff; + --accent-active: #5965f0; + --accent-contrast: #ffffff; + --accent-fill: rgba(110, 123, 255, 0.14); + --accent-fill-strong: rgba(110, 123, 255, 0.22); + --accent-ring: rgba(110, 123, 255, 0.5); + + --ref: #22b8cf; + --ref-fill: rgba(34, 184, 207, 0.14); + --ref-ring: rgba(34, 184, 207, 0.45); + + --success: #3fb950; + --success-fill: rgba(63, 185, 80, 0.16); + --warn: #e3a008; + --warn-fill: rgba(227, 160, 8, 0.16); + --error: #f0584b; + --error-fill: rgba(240, 88, 75, 0.14); + --error-ring: rgba(240, 88, 75, 0.45); + --info: #4aa3ff; + + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-2: 0 4px 16px rgba(0, 0, 0, 0.45); + --shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.55); + + /* --- CodeMirror surface tokens (dark) --- */ + --cm-bg: #16181d; + --cm-text: #e7e9ee; + --cm-gutter-text: #6b7280; + --cm-gutter-active: #6e7bff; + --cm-activeline: rgba(110, 123, 255, 0.06); + --cm-selection: rgba(110, 123, 255, 0.24); + --cm-cursor: #6e7bff; + --cm-block-rail: #6e7bff; + --cm-block-bg: #1c1f26; + --cm-block-title: #e7e9ee; + --cm-block-meta: #9aa1ad; + --cm-marker-bg: rgba(110, 123, 255, 0.22); + --cm-marker-text: #a9b2ff; + --cm-collapse-bg: #16181d; + --cm-collapse-border: #2a2e37; + --cm-collapse-text: #9aa1ad; + --cm-collapsed-rail: #353a45; + --cm-collapsed-text: #9aa1ad; + --cm-indent-guide: #2a2e37; + --cm-indent-guide-active: rgba(110, 123, 255, 0.3); + --cm-embed-bg: #1c1f26; + --cm-embed-border: #2a2e37; + --cm-embed-rail: #22b8cf; + --cm-embed-label: #5fd0e0; + --cm-embed-error-rail: #f0584b; + --cm-embed-error-bg: rgba(240, 88, 75, 0.08); + --cm-embed-error-label: #f0584b; + --cm-inlinecode-bg: rgba(255, 255, 255, 0.07); + --cm-inlinecode-text: #e7e9ee; + --cm-tok-keyword: #b79cff; + --cm-tok-tagName: #5fd0e0; + --cm-tok-attribute: #e3a008; + --cm-tok-string: #7fd88f; + --cm-tok-punctuation: #9aa1ad; + --cm-tok-embed: #ff8a7a; + + color: var(--text-primary); + background: var(--bg); + font-family: var(--font-ui); +} + +[data-theme="light"] { + color-scheme: light; + + --bg: #f6f7f9; + --surface: #ffffff; + --surface-raised: #fbfbfd; + --surface-inset: #f1f3f6; + --border: #e2e5ea; + --border-strong: #d2d7df; + --text-primary: #1a1d23; + --text-muted: #646b78; + --text-faint: #8a92a0; + + --accent: #5965f0; + --accent-hover: #6e7bff; + --accent-active: #4753e0; + --accent-contrast: #ffffff; + --accent-fill: rgba(89, 101, 240, 0.1); + --accent-fill-strong: rgba(89, 101, 240, 0.16); + --accent-ring: rgba(89, 101, 240, 0.4); + + --ref: #0e9bb5; + --ref-fill: rgba(14, 155, 181, 0.1); + --ref-ring: rgba(14, 155, 181, 0.4); + + --success: #1f9d4d; + --success-fill: rgba(31, 157, 77, 0.12); + --warn: #c77700; + --warn-fill: rgba(199, 119, 0, 0.12); + --error: #d43a2f; + --error-fill: rgba(212, 58, 47, 0.1); + --error-ring: rgba(212, 58, 47, 0.4); + --info: #1f77d6; + + --shadow-1: 0 1px 2px rgba(20, 23, 30, 0.06); + --shadow-2: 0 4px 14px rgba(20, 23, 30, 0.08); + --shadow-pop: 0 10px 28px rgba(20, 23, 30, 0.12); + + --cm-bg: #ffffff; + --cm-text: #1a1d23; + --cm-gutter-text: #8a92a0; + --cm-gutter-active: #5965f0; + --cm-activeline: rgba(89, 101, 240, 0.06); + --cm-selection: rgba(89, 101, 240, 0.18); + --cm-cursor: #5965f0; + --cm-block-rail: #5965f0; + --cm-block-bg: #fbfbfd; + --cm-block-title: #1a1d23; + --cm-block-meta: #646b78; + --cm-marker-bg: rgba(89, 101, 240, 0.14); + --cm-marker-text: #4753e0; + --cm-collapse-bg: #ffffff; + --cm-collapse-border: #e2e5ea; + --cm-collapse-text: #646b78; + --cm-collapsed-rail: #d2d7df; + --cm-collapsed-text: #646b78; + --cm-indent-guide: #e2e5ea; + --cm-indent-guide-active: rgba(89, 101, 240, 0.3); + --cm-embed-bg: #fbfbfd; + --cm-embed-border: #e2e5ea; + --cm-embed-rail: #0e9bb5; + --cm-embed-label: #0b7e94; + --cm-embed-error-rail: #d43a2f; + --cm-embed-error-bg: rgba(212, 58, 47, 0.06); + --cm-embed-error-label: #d43a2f; + --cm-inlinecode-bg: #eef1f4; + --cm-inlinecode-text: #1a1d23; + --cm-tok-keyword: #7c3fe4; + --cm-tok-tagName: #0b7e94; + --cm-tok-attribute: #8a4b0f; + --cm-tok-string: #0b6bcb; + --cm-tok-punctuation: #646b78; + --cm-tok-embed: #c0392b; } * { box-sizing: border-box; } +html, body { margin: 0; + height: 100%; + background: var(--bg); + color: var(--text-primary); +} + +body { + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } button, input { font: inherit; + color: inherit; +} + +button { + cursor: pointer; +} + +::selection { + background: var(--accent-fill-strong); +} + +/* themed scrollbars */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} +*::-webkit-scrollbar-thumb { + background: var(--border-strong); + border: 3px solid transparent; + background-clip: padding-box; + border-radius: var(--r-pill); +} +*::-webkit-scrollbar-thumb:hover { + background: var(--text-faint); + background-clip: padding-box; +} +*::-webkit-scrollbar-corner { + background: transparent; } +/* ============================ layout ============================ */ + .app { display: grid; grid-template-columns: 260px minmax(0, 1fr); height: 100vh; overflow: hidden; + background: var(--bg); + transition: background-color var(--dur-slow) var(--ease), color var(--dur-slow) var(--ease); } .sidebar { display: grid; grid-template-rows: auto auto minmax(0, 1fr); - border-right: 1px solid #d9e1e8; - background: #ffffff; + border-right: 1px solid var(--border); + background: var(--surface); min-width: 0; } @@ -43,31 +266,80 @@ input { .sidebarHeader { justify-content: space-between; - padding: 10px; - border-bottom: 1px solid #e5ebf0; + height: 44px; + padding: 0 var(--sp-3); + border-bottom: 1px solid var(--border); +} + +.sidebarBrand { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.01em; + color: var(--text-primary); +} + +.sidebarBrand svg { + color: var(--accent); +} + +.sidebarHeaderActions { + display: inline-flex; + align-items: center; + gap: var(--sp-1); } .iconButton { display: grid; place-items: center; - width: 32px; - height: 32px; - border: 1px solid #ccd7e0; - border-radius: 6px; - background: #ffffff; - color: #31475c; - cursor: pointer; + width: 30px; + height: 30px; + border: 0; + border-radius: var(--r-sm); + background: transparent; + color: var(--text-muted); + transition: background-color var(--dur) var(--ease), color var(--dur) var(--ease), + box-shadow var(--dur) var(--ease); +} + +.iconButton:hover { + background: var(--surface-raised); + color: var(--text-primary); + box-shadow: var(--shadow-1); +} + +.iconButton:active { + transform: translateY(0.5px); } .filter { display: flex; align-items: center; - gap: 8px; - margin: 10px; - padding: 8px 9px; - border: 1px solid #d8e1e8; - border-radius: 6px; - background: #f8fafc; + gap: var(--sp-2); + height: 32px; + margin: var(--sp-2) var(--sp-3) var(--sp-3); + padding: 0 9px; + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--surface-raised); + transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); +} + +.filter svg { + color: var(--text-faint); + transition: color var(--dur) var(--ease); + flex: none; +} + +.filter:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-ring); +} + +.filter:focus-within svg { + color: var(--accent); } .filter input { @@ -76,26 +348,44 @@ input { border: 0; outline: 0; background: transparent; + color: var(--text-primary); + font-size: 13px; +} + +.filter input::placeholder { + color: var(--text-muted); } .fileTree { overflow: auto; - padding: 4px 8px 10px; + padding: var(--sp-1) var(--sp-2) var(--sp-2); + display: flex; + flex-direction: column; + gap: 1px; } .fileItem { + position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; - gap: 8px; + gap: var(--sp-2); width: 100%; - min-height: 34px; + min-height: 30px; border: 0; - border-radius: 6px; + border-radius: var(--r-sm); background: transparent; - color: #2a3a48; - cursor: pointer; + color: var(--text-primary); + padding: 0 8px; text-align: left; + font-size: 13px; + transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); +} + +.fileItem > svg { + color: var(--text-muted); + flex: none; + transition: color var(--dur-fast) var(--ease); } .fileItem span { @@ -104,72 +394,261 @@ input { white-space: nowrap; } +.fileItem:hover { + background: var(--surface-raised); +} + .fileItem.active { - background: #e8f1ff; - color: #17345f; + background: var(--accent-fill); + color: var(--text-primary); + font-weight: 500; +} + +.fileItem.active::before { + content: ""; + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 2px; + border-radius: var(--r-pill); + background: var(--accent); + transform: scaleX(1); + transform-origin: left center; + animation: railIn var(--dur) var(--ease); +} + +.fileItem.active > svg { + color: var(--accent); } .dot { width: 8px; height: 8px; - border-radius: 50%; - background: #e49b23; + border-radius: var(--r-pill); + background: var(--warn); + flex: none; + animation: dotPulse 600ms var(--ease); } .errorIcon { - color: #c24141; + color: var(--error); + flex: none; } .workspace { display: grid; - grid-template-rows: auto minmax(0, 1fr) 190px auto; + grid-template-rows: auto minmax(0, 1fr) 200px auto; min-width: 0; min-height: 0; } +/* ============================ toolbar ============================ */ + .toolbar { justify-content: space-between; - gap: 16px; - height: 50px; - padding: 0 14px; - border-bottom: 1px solid #d9e1e8; - background: #ffffff; + gap: var(--sp-4); + height: 46px; + padding: 0 var(--sp-3); + border-bottom: 1px solid var(--border); + background: var(--surface); + box-shadow: var(--shadow-1); + position: relative; + z-index: 2; } .titleCluster, .toolbarActions { display: flex; align-items: center; - gap: 8px; + gap: var(--sp-2); min-width: 0; } -.titleCluster strong { +.crumbIcon { + color: var(--accent); + flex: none; +} + +.breadcrumb { + display: inline-flex; + align-items: center; + gap: 2px; + min-width: 0; + overflow: hidden; +} + +.breadcrumb .crumb { + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +.breadcrumb .crumbSep { + color: var(--text-faint); + flex: none; +} + +.breadcrumb strong { + color: var(--text-primary); + font-size: 13px; + font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.toolbarActions button, -.tabs button { +/* button tiers */ +.btn-primary, +.btn-tonal, +.btn-ghost { display: inline-flex; align-items: center; gap: 7px; - min-height: 32px; - border: 1px solid #ccd7e0; - border-radius: 6px; - background: #ffffff; - color: #263947; - cursor: pointer; + height: 32px; + padding: 0 12px; + border-radius: var(--r-sm); + font-size: 13px; + font-weight: 600; + white-space: nowrap; + transition: background-color var(--dur) var(--ease), border-color var(--dur) var(--ease), + box-shadow var(--dur) var(--ease), transform var(--dur-fast) var(--ease); } -.toolbarActions button { - padding: 0 10px; +.btn-primary svg, +.btn-tonal svg, +.btn-ghost svg { + flex: none; +} + +.btn-primary { + background: var(--accent); + color: var(--accent-contrast); + border: 1px solid transparent; + box-shadow: var(--shadow-1); +} +.btn-primary:hover { + background: var(--accent-hover); + box-shadow: var(--shadow-2); +} +.btn-primary:active { + background: var(--accent-active); + transform: translateY(0.5px); +} + +.btn-tonal { + background: var(--accent-fill); + color: var(--accent); + border: 1px solid var(--accent-ring); +} +.btn-tonal:hover { + background: var(--accent-fill-strong); +} +.btn-tonal:active { + transform: translateY(0.5px); +} + +.btn-ghost { + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border); +} +.btn-ghost:hover { + background: var(--surface-raised); + border-color: var(--border-strong); +} +.btn-ghost:active { + transform: translateY(0.5px); +} + +.btn-primary:disabled, +.btn-tonal:disabled, +.btn-ghost:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +/* one-shot success / error feedback on Save & Check */ +.pulse-success { + position: relative; +} +.pulse-success::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + animation: successRing 450ms var(--ease); +} +.pulse-error { + animation: errorFlash 450ms var(--ease); +} + +/* segmented Source / Hybrid control */ +.segmented { + position: relative; + display: inline-flex; + align-items: center; + height: 32px; + padding: 2px; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--surface-inset); +} + +.segmentedThumb { + position: absolute; + top: 2px; + bottom: 2px; + left: 2px; + width: calc(50% - 2px); + border-radius: calc(var(--r-md) - 2px); + background: var(--surface-raised); + box-shadow: var(--shadow-1); + transition: transform var(--dur) var(--ease); } +.seg { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + flex: 1; + min-width: 72px; + height: 100%; + padding: 0 12px; + border: 0; + border-radius: calc(var(--r-md) - 2px); + background: transparent; + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + transition: color var(--dur) var(--ease); +} + +.seg svg { + flex: none; +} + +.seg.active { + color: var(--text-primary); + font-weight: 600; +} + +.seg.active svg { + color: var(--accent); +} + +/* ============================ editor / preview ============================ */ + .editorGrid { display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(300px, 0.75fr); + grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.7fr); min-width: 0; min-height: 0; } @@ -182,8 +661,8 @@ input { } .editorPane { - border-right: 1px solid #d9e1e8; - background: #ffffff; + border-right: 1px solid var(--border); + background: var(--cm-bg); } .cmHost { @@ -193,66 +672,149 @@ input { .previewPane { display: grid; grid-template-rows: auto minmax(0, 1fr); - background: #fbfcfd; + background: var(--bg); } .paneHeader { - gap: 8px; - height: 38px; - padding: 0 12px; - border-bottom: 1px solid #e3e9ef; - color: #435566; - font-size: 13px; - font-weight: 700; + gap: var(--sp-2); + height: 34px; + padding: 0 var(--sp-3); + border-bottom: 1px solid var(--border); + background: var(--surface); + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.paneHeader svg { + color: var(--text-muted); } .previewPane pre, .buildOutput { margin: 0; - padding: 14px; + padding: var(--sp-4); overflow: auto; white-space: pre-wrap; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-family: var(--font-mono); font-size: 13px; - line-height: 1.5; + line-height: 1.55; + color: var(--text-primary); +} + +.previewPane pre { + background: var(--surface-inset); + box-shadow: inset 0 1px 0 var(--accent-fill); } +/* intentional empty / idle states */ .emptyState { display: flex; + flex-direction: column; align-items: center; - gap: 8px; - padding: 14px; - color: #8a4a18; + justify-content: center; + gap: var(--sp-2); + max-width: 280px; + margin: auto; + padding: var(--sp-6); + text-align: center; +} + +.emptyIcon { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: var(--r-pill); + background: var(--warn-fill); + color: var(--warn); + margin-bottom: var(--sp-1); +} + +.emptyState strong { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.emptyState span { + font-size: 12px; + line-height: 1.5; + color: var(--text-muted); } +/* ============================ bottom panel ============================ */ + .bottomPanel { display: grid; grid-template-rows: auto minmax(0, 1fr); - border-top: 1px solid #d9e1e8; - background: #ffffff; + border-top: 1px solid var(--border); + background: var(--surface); + box-shadow: var(--shadow-1); min-height: 0; + position: relative; + z-index: 1; } .tabs { - gap: 6px; - padding: 8px 10px; - border-bottom: 1px solid #e3e9ef; + gap: var(--sp-1); + height: 38px; + padding: 0 var(--sp-3); + border-bottom: 1px solid var(--border); } .tabs button { - padding: 0 9px; + display: inline-flex; + align-items: center; + gap: 7px; + height: 30px; + padding: 0 10px; + border: 0; + border-radius: var(--r-sm); + background: transparent; + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + transition: background-color var(--dur) var(--ease), color var(--dur) var(--ease), + box-shadow var(--dur) var(--ease); +} + +.tabs button svg { + flex: none; +} + +.tabs button:hover { + background: var(--surface-raised); + color: var(--text-primary); } .tabs button.selected { - background: #eef5ff; - border-color: #9dbbe0; - color: #17345f; + color: var(--accent); + background: var(--accent-fill); + font-weight: 600; + box-shadow: inset 0 -2px 0 0 var(--accent); +} + +.tabBadge { + display: inline-grid; + place-items: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: var(--r-pill); + background: var(--error-fill); + color: var(--error); + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; } .panelList, .referenceGrid { overflow: auto; - padding: 8px 10px; + padding: var(--sp-2) var(--sp-3); } .panelList button, @@ -260,51 +822,288 @@ input { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.25fr); align-items: center; - gap: 9px; + gap: var(--sp-2); width: 100%; min-height: 32px; border: 0; - border-radius: 6px; + border-radius: var(--r-sm); background: transparent; + color: var(--text-primary); text-align: left; - cursor: pointer; + transition: background-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); } -.panelList button:hover, -.referenceGrid button:hover { - background: #f1f5f9; +.panelList button:hover { + background: var(--surface-raised); +} + +.panelList button svg { + color: var(--error); + flex: none; +} + +.panelList button span { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.panelList button strong { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .panelList p { - margin: 8px 2px; - color: #536575; + display: flex; + align-items: center; + gap: var(--sp-2); + margin: var(--sp-2); + color: var(--text-muted); + font-size: 13px; } .referenceGrid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 4px 10px; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: var(--sp-2); + align-content: start; } .referenceGrid button { grid-template-columns: auto minmax(0, 1fr); + gap: var(--sp-1) var(--sp-2); + padding: var(--sp-2) 10px; + border: 1px solid var(--border); + background: var(--surface-raised); +} + +.referenceGrid button:hover { + border-color: var(--ref-ring); + box-shadow: var(--shadow-1); +} + +.referenceGrid button svg { + color: var(--ref); + flex: none; +} + +.referenceGrid button > span { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .referenceGrid small { grid-column: 2; - color: #697b8d; + color: var(--text-muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } +.buildOutput { + background: var(--surface-inset); + color: var(--text-muted); + font-size: 13px; +} + +/* ============================ status bar ============================ */ + .statusbar { - gap: 14px; - min-height: 28px; - padding: 0 12px; - border-top: 1px solid #d9e1e8; - background: #263947; - color: #ecf3f8; - font-size: 12px; + height: 26px; + padding: 0 var(--sp-2); + border-top: 1px solid var(--border); + background: var(--surface-raised); + color: var(--text-muted); + font-size: 11px; + font-weight: 600; +} + +.statusSeg { + display: inline-flex; + align-items: center; + gap: 6px; + height: 100%; + padding: 0 var(--sp-2); + border-left: 1px solid var(--border); + white-space: nowrap; +} + +.statusSeg:first-child { + border-left: 0; +} + +.statusSeg svg { + flex: none; +} + +.statusSeg.is-success { + color: var(--success); +} +.statusSeg.is-warn { + color: var(--warn); +} +.statusSeg.is-error { + color: var(--error); } +.statusSeg.is-error, +.statusSeg .count { + font-variant-numeric: tabular-nums; +} + +.statusDot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: var(--r-pill); + background: var(--warn); + animation: dotPulse 600ms var(--ease); +} + +.statusMarker { + display: grid; + place-items: center; + width: 15px; + height: 15px; + border-radius: 4px; + background: var(--accent-fill); + color: var(--accent); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; +} + +.themeToggle { + display: grid; + place-items: center; + width: 24px; + height: 24px; + margin-left: auto; + border: 0; + border-radius: var(--r-pill); + background: transparent; + color: var(--text-muted); + transition: background-color var(--dur) var(--ease), color var(--dur) var(--ease); +} + +.themeToggle:hover { + background: var(--surface); + color: var(--text-primary); +} + +.themeToggle svg { + animation: iconIn var(--dur-slow) var(--ease); +} + +/* ============================ focus ring (no layout shift) ============================ */ + +.iconButton:focus-visible, +.btn-primary:focus-visible, +.btn-tonal:focus-visible, +.btn-ghost:focus-visible, +.seg:focus-visible, +.tabs button:focus-visible, +.fileItem:focus-visible, +.panelList button:focus-visible, +.themeToggle:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--accent-ring); +} + +.referenceGrid button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--ref-ring); +} + +.fileItem.active:focus-visible { + box-shadow: inset 2px 0 0 0 var(--accent), 0 0 0 2px var(--accent-ring); +} + +/* ============================ motion ============================ */ + +@keyframes railIn { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +@keyframes dotPulse { + 0% { + transform: scale(1); + filter: brightness(1); + } + 40% { + transform: scale(1.3); + filter: brightness(1.3); + } + 100% { + transform: scale(1); + filter: brightness(1); + } +} + +@keyframes successRing { + 0% { + box-shadow: 0 0 0 0 var(--success); + opacity: 0.9; + } + 100% { + box-shadow: 0 0 0 7px transparent; + opacity: 0; + } +} + +@keyframes errorFlash { + 0%, + 100% { + box-shadow: var(--shadow-1); + } + 30%, + 70% { + box-shadow: 0 0 0 2px var(--error-ring); + border-color: var(--error); + } +} + +@keyframes iconIn { + from { + opacity: 0; + transform: rotate(-30deg) scale(0.8); + } + to { + opacity: 1; + transform: rotate(0) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} + +/* ============================ responsive ============================ */ + @media (max-width: 900px) { .app { grid-template-columns: 210px minmax(0, 1fr); @@ -322,7 +1121,8 @@ input { align-items: stretch; flex-direction: column; height: auto; - padding: 8px; + padding: var(--sp-2); + gap: var(--sp-2); } .toolbarActions { diff --git a/packages/cspec-app/test.js b/packages/cspec-app/test.js index f1fc89d..2cb5d6a 100644 --- a/packages/cspec-app/test.js +++ b/packages/cspec-app/test.js @@ -19,7 +19,7 @@ test("app renders the demo workspace with editor, panels, and compiled preview", assert.match(text, /Be specific and avoid vague language/); assert.match(text, /Problems/); assert.match(text, /References/); - assert.match(text, /No diagnostics/); + assert.match(text, /No problems detected/); } finally { await server.close(); } diff --git a/packages/cspec-editor/src/index.ts b/packages/cspec-editor/src/index.ts index c0746f4..bbfc131 100644 --- a/packages/cspec-editor/src/index.ts +++ b/packages/cspec-editor/src/index.ts @@ -3,7 +3,7 @@ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; import { markdown } from "@codemirror/lang-markdown"; import { linter, type Diagnostic as CmDiagnostic } from "@codemirror/lint"; import { EditorSelection, EditorState, StateEffect, StateField, type Extension, type Range } from "@codemirror/state"; -import { Decoration, EditorView, keymap, ViewPlugin, type DecorationSet, type ViewUpdate, WidgetType } from "@codemirror/view"; +import { Decoration, drawSelection, EditorView, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers, ViewPlugin, type DecorationSet, type ViewUpdate, WidgetType } from "@codemirror/view"; import { tags } from "@lezer/highlight"; import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { @@ -115,6 +115,11 @@ export function createCspecEditorExtensions(options: CspecEditorOptions): Extens collapsedContentDecorations(options.file), history(), markdown(), + lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), + drawSelection(), + EditorView.lineWrapping, syntaxHighlighting(cspecHighlightStyle), keymap.of([...defaultKeymap, ...historyKeymap]), autocompletion({ override: [(context) => cspecCompletions(context, options.file)] }), @@ -856,13 +861,49 @@ const cspecHighlightStyle = HighlightStyle.define([ { tag: tags.link, textDecoration: "underline" } ]); +// Every color below is a CSS custom property (defined once in the app's styles.css on +// :root for dark and [data-theme="light"] for light). The CodeMirror DOM mounts under +// the document root, so these var() lookups resolve against the active theme — one +// [data-theme] flip recolors the whole editor with no duplicated theme object. Fallback +// values keep the editor legible if the variables are ever absent (e.g. standalone use). const cspecTheme = EditorView.theme({ "&": { height: "100%", - fontSize: "14px" + fontSize: "14px", + color: "var(--cm-text, #1a1d23)", + background: "var(--cm-bg, #ffffff)" }, ".cm-scroller": { - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" + fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)", + lineHeight: "1.6" + }, + ".cm-content": { + caretColor: "var(--cm-cursor, #5965f0)" + }, + ".cm-gutters": { + background: "var(--cm-bg, #ffffff)", + color: "var(--cm-gutter-text, #8a92a0)", + border: "none" + }, + ".cm-lineNumbers .cm-gutterElement": { + fontVariantNumeric: "tabular-nums", + padding: "0 6px 0 12px" + }, + ".cm-activeLineGutter": { + background: "transparent", + color: "var(--cm-gutter-active, #5965f0)" + }, + ".cm-activeLine": { + background: "var(--cm-activeline, rgba(89,101,240,0.06))" + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "var(--cm-cursor, #5965f0)" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { + background: "var(--cm-selection, rgba(89,101,240,0.18))" + }, + ".cm-selectionMatch": { + background: "var(--cm-selection, rgba(89,101,240,0.18))" }, ".cm-cspec-blockHeader": { boxSizing: "border-box", @@ -870,10 +911,10 @@ const cspecTheme = EditorView.theme({ gridTemplateColumns: "22px 22px minmax(0, 1fr) auto", alignItems: "center", gap: "7px", - borderLeft: "2px solid #4f8cff", - background: "linear-gradient(90deg, #eef5ff 0%, #f7fbff 100%)", - color: "#17345f", - fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif", + borderLeft: "2px solid var(--cm-block-rail, #5965f0)", + background: "var(--cm-block-bg, #fbfbfd)", + color: "var(--cm-block-title, #1a1d23)", + fontFamily: "var(--font-ui, Inter, ui-sans-serif, system-ui, sans-serif)", fontWeight: "700", padding: "6px 8px", margin: "0 0 4px", @@ -884,31 +925,39 @@ const cspecTheme = EditorView.theme({ placeItems: "center", width: "20px", height: "20px", - border: "1px solid #b7c9dc", - borderRadius: "4px", - background: "#ffffff", - color: "#31506f", + border: "1px solid var(--cm-collapse-border, #e2e5ea)", + borderRadius: "6px", + background: "var(--cm-collapse-bg, #ffffff)", + color: "var(--cm-collapse-text, #646b78)", cursor: "pointer", - font: "700 11px/1 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", - padding: "0" + font: "700 11px/1 var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)", + padding: "0", + transition: "border-color var(--dur, 180ms) var(--ease, ease), color var(--dur, 180ms) var(--ease, ease)" + }, + ".cm-cspec-collapseButton:hover": { + borderColor: "var(--accent, #5965f0)", + color: "var(--accent, #5965f0)" }, ".cm-cspec-collapseButton svg": { width: "14px", height: "14px" }, ".cm-cspec-collapseButton:disabled": { - color: "#9aaabd", + borderColor: "var(--cm-collapse-border, #e2e5ea)", + color: "var(--cm-collapse-text, #646b78)", cursor: "default", - opacity: "0.75" + opacity: "0.55" }, ".cm-cspec-blockMarker": { display: "grid", placeItems: "center", width: "20px", height: "20px", - borderRadius: "50%", - background: "#d8e8ff", - color: "#17345f", + borderRadius: "6px", + background: "var(--cm-marker-bg, rgba(89,101,240,0.14))", + color: "var(--cm-marker-text, #4753e0)", + fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)", + fontWeight: "700", fontSize: "11px" }, ".cm-cspec-blockTitle": { @@ -917,15 +966,20 @@ const cspecTheme = EditorView.theme({ whiteSpace: "nowrap" }, ".cm-cspec-blockMeta": { - color: "#536b84", + color: "var(--cm-block-meta, #646b78)", fontSize: "11px", - fontWeight: "600" + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: "0.04em", + border: "1px solid var(--cm-indent-guide, #e2e5ea)", + borderRadius: "999px", + padding: "1px 7px" }, ".cm-cspec-collapsedBlock": { boxSizing: "border-box", - borderLeft: "2px solid #b7c9dc", - color: "#536b84", - fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif", + borderLeft: "2px solid var(--cm-collapsed-rail, #d2d7df)", + color: "var(--cm-collapsed-text, #646b78)", + fontFamily: "var(--font-ui, Inter, ui-sans-serif, system-ui, sans-serif)", fontSize: "12px", fontWeight: "700", margin: "0 0 4px calc((var(--cspec-depth, 0) + 1) * 18px)", @@ -933,61 +987,75 @@ const cspecTheme = EditorView.theme({ }, ".cm-cspec-indentedLine": { paddingLeft: "calc(var(--cspec-depth, 0) * 18px + 8px)", - backgroundImage: "repeating-linear-gradient(to right, transparent 0 17px, #d7e1ec 17px 18px)", + backgroundImage: "repeating-linear-gradient(to right, transparent 0 17px, var(--cm-indent-guide, #e2e5ea) 17px 18px)", backgroundRepeat: "no-repeat", backgroundSize: "calc(var(--cspec-depth, 0) * 18px) 100%" }, ".cm-cspec-embed": { boxSizing: "border-box", - border: "1px solid #c9d8e8", + border: "1px solid var(--cm-embed-border, #e2e5ea)", + borderLeft: "2px solid var(--cm-embed-rail, #0e9bb5)", borderRadius: "6px", - background: "#f8fbff", + background: "var(--cm-embed-bg, #fbfbfd)", + boxShadow: "var(--shadow-1, 0 1px 2px rgba(20,23,30,0.06))", cursor: "default", margin: "6px 0", - padding: "8px" + padding: "8px", + transition: "box-shadow var(--dur, 180ms) var(--ease, ease)" + }, + ".cm-cspec-embed:hover": { + boxShadow: "var(--shadow-2, 0 4px 14px rgba(20,23,30,0.08))" }, ".cm-cspec-embed-unresolved": { - borderColor: "#d45b5b", - background: "#fff7f7" + borderColor: "var(--cm-embed-error-rail, #d43a2f)", + borderLeftColor: "var(--cm-embed-error-rail, #d43a2f)", + background: "var(--cm-embed-error-bg, rgba(212,58,47,0.06))" }, ".cm-cspec-embedLabel": { - borderBottom: "1px solid #c9d8e8", - color: "#31506f", - fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif", - fontSize: "12px", - fontWeight: "700", + borderBottom: "1px solid var(--cm-embed-border, #e2e5ea)", + color: "var(--cm-embed-label, #0b7e94)", + fontFamily: "var(--font-ui, Inter, ui-sans-serif, system-ui, sans-serif)", + fontSize: "11px", + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: "0.04em", paddingBottom: "5px", marginBottom: "6px" }, + ".cm-cspec-embed-unresolved .cm-cspec-embedLabel": { + color: "var(--cm-embed-error-label, #d43a2f)", + borderBottomColor: "var(--cm-embed-error-rail, #d43a2f)" + }, ".cm-cspec-embed pre": { margin: "0", whiteSpace: "pre-wrap", - fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif" + fontFamily: "var(--font-ui, Inter, ui-sans-serif, system-ui, sans-serif)" }, ".cm-cspec-inlineCode": { - background: "#eef1f4", + background: "var(--cm-inlinecode-bg, #eef1f4)", + color: "var(--cm-inlinecode-text, #1a1d23)", borderRadius: "4px", padding: "0 3px" }, ".cm-cspec-mdx-keyword": { - color: "#8a3ffc", + color: "var(--cm-tok-keyword, #7c3fe4)", fontWeight: "700" }, ".cm-cspec-mdx-tagName": { - color: "#0f766e", + color: "var(--cm-tok-tagName, #0b7e94)", fontWeight: "700" }, ".cm-cspec-mdx-attribute": { - color: "#8a4b0f" + color: "var(--cm-tok-attribute, #8a4b0f)" }, ".cm-cspec-mdx-string": { - color: "#0b6bcb" + color: "var(--cm-tok-string, #0b6bcb)" }, ".cm-cspec-mdx-punctuation": { - color: "#6b7280" + color: "var(--cm-tok-punctuation, #646b78)" }, ".cm-cspec-mdx-embed": { - color: "#b42318", + color: "var(--cm-tok-embed, #c0392b)", fontWeight: "700" } });