collab/public/index.htmlblame
View source
bdb18c91<!DOCTYPE html>
bdb18c92<html lang="en">
bdb18c93<head>
bdb18c94<meta charset="UTF-8">
bdb18c95<meta name="viewport" content="width=device-width, initial-scale=1.0">
2a9592c6<title>Grove Collab</title>
bf6031c7<link rel="icon" type="image/svg+xml" href="/favicon.svg">
bdb18c98<link href="https://fonts.googleapis.com/css2?family=Libre+Caslon+Text:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
38b80fd9<script src="/mermaid.min.js"></script>
bdb18c910<script src="/socket.io/socket.io.js"></script>
bdb18c911<script type="module" id="cm-loader">
bdb18c912 // CodeMirror 6 — single local bundle (no duplicate @codemirror/state)
bdb18c913 import {
bdb18c914 EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection,
bdb18c915 EditorState,
bdb18c916 defaultKeymap, indentWithTab, history, historyKeymap,
bdb18c917 HighlightStyle, syntaxHighlighting, StreamLanguage, bracketMatching,
bdb18c918 tags,
bdb18c919 closeBrackets,
bdb18c920 Y, yCollab
bdb18c921 } from "/cm-bundle.js";
bdb18c922
bdb18c923 // ── Custom Mermaid stream parser ──
bdb18c924 const mermaidParser = {
bdb18c925 startState() { return { inBlock: false, blockType: null }; },
bdb18c926 token(stream, state) {
bdb18c927 // Comments
bdb18c928 if (stream.match(/^%%/)) { stream.skipToEnd(); return "lineComment"; }
bdb18c929
bdb18c930 // Strings
bdb18c931 if (stream.match(/^"[^"]*"/)) return "string";
bdb18c932
bdb18c933 // Diagram-type keywords (at start of doc or after whitespace)
bdb18c934 if (stream.sol() && stream.match(/^(graph|flowchart|erDiagram|classDiagram|stateDiagram-v2|stateDiagram|sequenceDiagram|gantt|pie|gitGraph|mindmap|timeline|journey|quadrantChart|sankey|xychart)\b/)) {
bdb18c935 return "keyword";
bdb18c936 }
bdb18c937
bdb18c938 // Block keywords
bdb18c939 if (stream.match(/^(subgraph|end|direction|class|state|section|participant|actor|loop|alt|else|opt|par|critical|break|note|over|of|left|right|title)\b/)) {
bdb18c940 return "keyword";
bdb18c941 }
bdb18c942
bdb18c943 // ER cardinality symbols
bdb18c944 if (stream.match(/^\|\|--|[{o|]/) || stream.match(/^[|o][|{]--[|o][|{]/)) return "operator";
bdb18c945
bdb18c946 // Arrows
bdb18c947 if (stream.match(/^==>/) || stream.match(/^-\.->/) || stream.match(/^\.->/) || stream.match(/^-->/) || stream.match(/^---/) || stream.match(/^-\.-/) || stream.match(/^===/) || stream.match(/^-->/) || stream.match(/^-->/)) return "operator";
bdb18c948 if (stream.match(/^--|>/)) return "operator";
bdb18c949 if (stream.match(/^\.\.>/)) return "operator";
bdb18c950
bdb18c951 // Pipe labels |"text"|
bdb18c952 if (stream.match(/^\|/)) return "bracket";
bdb18c953
bdb18c954 // Brackets for node shapes
bdb18c955 if (stream.match(/^[\[\](){}]/)) return "bracket";
bdb18c956
bdb18c957 // Data types in ER diagrams
bdb18c958 if (stream.match(/^(int|text|real|bool|datetime|float|string|void)\b/)) return "typeName";
bdb18c959
bdb18c960 // PK, FK, UK markers
bdb18c961 if (stream.match(/^(PK|FK|UK)\b/)) return "annotation";
bdb18c962
bdb18c963 // Visibility markers in class diagrams
bdb18c964 if (stream.match(/^[+#~-](?=[A-Za-z])/)) return "operator";
bdb18c965
bdb18c966 // Identifiers / words
bdb18c967 if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) return "variableName";
bdb18c968
bdb18c969 // Numbers
bdb18c970 if (stream.match(/^[0-9]+(\.[0-9]+)?/)) return "number";
bdb18c971
bdb18c972 // Colon
bdb18c973 if (stream.match(/^:/)) return "punctuation";
bdb18c974
bdb18c975 // Skip anything else
bdb18c976 stream.next();
bdb18c977 return null;
bdb18c978 }
bdb18c979 };
bdb18c980
bdb18c981 const mermaidLang = StreamLanguage.define(mermaidParser);
bdb18c982
bdb18c983 // ── Grove theme (matches our CSS vars) ──
bdb18c984 const groveTheme = EditorView.theme({
bdb18c985 "&": {
bdb18c986 fontSize: "12px",
bdb18c987 fontFamily: "'JetBrains Mono', Menlo, monospace",
bdb18c988 backgroundColor: "#eae6df",
bdb18c989 color: "#2c2824",
bdb18c990 flex: "1",
bdb18c991 overflow: "hidden",
bdb18c992 },
bdb18c993 "&.cm-focused": { outline: "none" },
bdb18c994 ".cm-scroller": { overflow: "auto", fontFamily: "inherit" },
bdb18c995 ".cm-content": { padding: "12px 8px", caretColor: "#4d8a78" },
bdb18c996 ".cm-gutters": {
bdb18c997 backgroundColor: "#e2ddd5",
bdb18c998 color: "#a09888",
bdb18c999 border: "none",
bdb18c9100 borderRight: "1px solid #d9d3ca",
bdb18c9101 minWidth: "36px",
bdb18c9102 },
bdb18c9103 ".cm-activeLineGutter": { backgroundColor: "#d9d3ca", color: "#4a4540" },
bdb18c9104 ".cm-activeLine": { backgroundColor: "#e2ddd520" },
bdb18c9105 ".cm-cursor": { borderLeftColor: "#4d8a78", borderLeftWidth: "2px" },
bdb18c9106 ".cm-selectionBackground": { backgroundColor: "#4d8a7830 !important" },
bdb18c9107 "&.cm-focused .cm-selectionBackground": { backgroundColor: "#4d8a7840 !important" },
bdb18c9108 ".cm-matchingBracket": { backgroundColor: "#4d8a7830", outline: "1px solid #4d8a7860" },
bdb18c9109 }, { dark: false });
bdb18c9110
bdb18c9111 const groveHighlight = HighlightStyle.define([
bdb18c9112 { tag: tags.keyword, color: "#7c4dff", fontWeight: "500" },
bdb18c9113 { tag: tags.string, color: "#2d6b56" },
bdb18c9114 { tag: tags.lineComment, color: "#a09888", fontStyle: "italic" },
bdb18c9115 { tag: tags.operator, color: "#c56200" },
bdb18c9116 { tag: tags.bracket, color: "#7a746c" },
bdb18c9117 { tag: tags.typeName, color: "#0277bd" },
bdb18c9118 { tag: tags.annotation, color: "#c62828", fontWeight: "600" },
bdb18c9119 { tag: tags.variableName, color: "#2c2824" },
bdb18c9120 { tag: tags.number, color: "#0277bd" },
bdb18c9121 { tag: tags.punctuation, color: "#7a746c" },
bdb18c9122 ]);
bdb18c9123
bdb18c9124 // Expose Yjs so the main script can create docs/providers
bdb18c9125 window._Y = Y;
bdb18c9126 window._yCollab = yCollab;
bdb18c9127
bdb18c9128 // Expose a factory so the main script can create the editor
bdb18c9129 // If ytext is provided, uses collaborative Yjs mode; otherwise standalone
bdb18c9130 window._cmCreateEditor = function(parentEl, initialCode, onChange, ytext, undoManager) {
bdb18c9131 const collabMode = ytext && undoManager;
bdb18c9132 const extensions = [
bdb18c9133 lineNumbers(),
bdb18c9134 highlightActiveLine(),
bdb18c9135 highlightActiveLineGutter(),
bdb18c9136 drawSelection(),
bdb18c9137 bracketMatching(),
bdb18c9138 closeBrackets(),
bdb18c9139 collabMode ? [] : history(),
bdb18c9140 keymap.of([...defaultKeymap, ...(collabMode ? [] : historyKeymap), indentWithTab]),
bdb18c9141 mermaidLang,
bdb18c9142 groveTheme,
bdb18c9143 syntaxHighlighting(groveHighlight),
bdb18c9144 EditorView.updateListener.of((update) => {
bdb18c9145 if (update.docChanged && onChange) {
bdb18c9146 onChange(update.state.doc.toString());
bdb18c9147 }
bdb18c9148 }),
bdb18c9149 EditorView.lineWrapping,
bdb18c9150 ];
bdb18c9151 if (collabMode) {
bdb18c9152 extensions.push(yCollab(ytext, null, { undoManager }));
bdb18c9153 }
bdb18c9154 const view = new EditorView({
bdb18c9155 state: EditorState.create({
bdb18c9156 doc: collabMode ? ytext.toString() : initialCode,
bdb18c9157 extensions: extensions.flat(),
bdb18c9158 }),
bdb18c9159 parent: parentEl,
bdb18c9160 });
bdb18c9161 return view;
bdb18c9162 };
bdb18c9163
bdb18c9164 // Signal that CM is ready
bdb18c9165 window.dispatchEvent(new Event("cm-ready"));
bdb18c9166</script>
bdb18c9167<style>
bdb18c9168 :root {
bdb18c9169 /* Grove light theme */
bdb18c9170 --bg-page: #faf8f5;
bdb18c9171 --bg-card: #f2efe9;
bdb18c9172 --bg-inset: #eae6df;
bdb18c9173 --bg-hover: #e2ddd5;
bdb18c9174 --bg-input: #ffffff;
bdb18c9175
bdb18c9176 --text-primary: #2c2824;
bdb18c9177 --text-secondary: #4a4540;
bdb18c9178 --text-muted: #7a746c;
bdb18c9179 --text-faint: #a09888;
bdb18c9180
bdb18c9181 --accent: #4d8a78;
bdb18c9182 --accent-hover: #3d7a68;
bdb18c9183 --accent-subtle: #e8f2ee;
bdb18c9184 --accent-text: #ffffff;
bdb18c9185
bdb18c9186 --border: #d9d3ca;
bdb18c9187 --border-subtle: #e8e3db;
bdb18c9188 --divide: #e2ddd580;
bdb18c9189
bdb18c9190 --note-bg: #e8f2ee;
bdb18c9191 --note-text: #2d6b56;
bdb18c9192 --note-border: #b0d4c5;
bdb18c9193
bdb18c9194 --drawer-width: 260px;
bdb18c9195 }
bdb18c9196
bdb18c9197 * { margin: 0; padding: 0; box-sizing: border-box; }
bdb18c9198
bdb18c9199 body {
bdb18c9200 font-family: 'Libre Caslon Text', Georgia, serif;
bdb18c9201 background: var(--bg-page);
bdb18c9202 color: var(--text-primary);
bdb18c9203 overflow: hidden;
bdb18c9204 height: 100vh;
bdb18c9205 display: flex;
bdb18c9206 flex-direction: column;
bdb18c9207 }
bdb18c9208
bdb18c9209 code, pre, .mono {
bdb18c9210 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9211 }
bdb18c9212
bdb18c9213 ::-webkit-scrollbar { width: 6px; height: 6px; }
bdb18c9214 ::-webkit-scrollbar-track { background: transparent; }
bdb18c9215 ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
bdb18c9216 ::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
bdb18c9217
bdb18c9218 /* Top bar */
bdb18c9219 .topbar {
bdb18c9220 display: flex;
bdb18c9221 align-items: center;
e629dcb222 justify-content: space-between;
e629dcb223 height: 3.5rem;
e629dcb224 padding: 0 0.75rem;
bdb18c9225 border-bottom: 1px solid var(--border);
bdb18c9226 flex-shrink: 0;
bdb18c9227 z-index: 100;
bdb18c9228 }
bdb18c9229
e629dcb230 @media (min-width: 640px) {
e629dcb231 .topbar { padding: 0 1.5rem; }
e629dcb232 }
e629dcb233
e629dcb234 .topbar-left {
e629dcb235 display: flex;
e629dcb236 align-items: center;
e629dcb237 gap: 0.375rem;
e629dcb238 font-size: 0.875rem;
e629dcb239 min-width: 0;
e629dcb240 }
e629dcb241
e629dcb242 @media (min-width: 640px) {
e629dcb243 .topbar-left { gap: 0.75rem; }
e629dcb244 }
e629dcb245
bdb18c9246 .topbar .logo {
bdb18c9247 display: flex;
bdb18c9248 align-items: center;
e629dcb249 gap: 0.5rem;
e629dcb250 text-decoration: none;
e629dcb251 flex-shrink: 0;
e629dcb252 }
e629dcb253
e629dcb254 .topbar .logo-text {
e629dcb255 font-size: 1.125rem;
e629dcb256 font-weight: 500;
bdb18c9257 color: var(--text-primary);
e629dcb258 margin-left: 0.25rem;
bdb18c9259 }
bdb18c9260
e629dcb261 .topbar .breadcrumb-sep {
e629dcb262 color: var(--text-faint);
e629dcb263 }
e629dcb264
e629dcb265 .topbar .breadcrumb-item {
bdb18c9266 color: var(--text-muted);
e629dcb267 text-decoration: none;
e629dcb268 overflow: hidden;
e629dcb269 text-overflow: ellipsis;
e629dcb270 white-space: nowrap;
bdb18c9271 }
bdb18c9272
e629dcb273 .topbar .breadcrumb-item:hover {
e629dcb274 text-decoration: underline;
e629dcb275 }
e629dcb276
e629dcb277 .topbar .breadcrumb-current {
e629dcb278 color: var(--text-primary);
e629dcb279 overflow: hidden;
e629dcb280 text-overflow: ellipsis;
e629dcb281 white-space: nowrap;
e629dcb282 }
e629dcb283
e629dcb284 .topbar-right {
e629dcb285 display: flex;
e629dcb286 align-items: center;
e629dcb287 gap: 0.5rem;
e629dcb288 flex-shrink: 0;
bdb18c9289 }
bdb18c9290
bdb18c9291 .topbar .presence {
bdb18c9292 display: flex;
bdb18c9293 align-items: center;
bdb18c9294 gap: 4px;
bdb18c9295 }
bdb18c9296
bdb18c9297 .presence-dot {
e629dcb298 width: 1.75rem;
e629dcb299 height: 1.75rem;
bdb18c9300 border-radius: 50%;
bdb18c9301 display: flex;
bdb18c9302 align-items: center;
bdb18c9303 justify-content: center;
e629dcb304 font-size: 0.6875rem;
e629dcb305 font-weight: 600;
bdb18c9306 color: #fff;
bdb18c9307 cursor: default;
bdb18c9308 position: relative;
bdb18c9309 }
bdb18c9310
bdb18c9311 .presence-dot .tooltip {
bdb18c9312 display: none;
bdb18c9313 position: absolute;
bdb18c9314 bottom: -28px;
bdb18c9315 left: 50%;
bdb18c9316 transform: translateX(-50%);
e629dcb317 background: var(--bg-card);
bdb18c9318 color: var(--text-primary);
bdb18c9319 padding: 2px 8px;
bdb18c9320 font-size: 11px;
bdb18c9321 white-space: nowrap;
bdb18c9322 border: 1px solid var(--border);
e629dcb323 z-index: 200;
bdb18c9324 }
bdb18c9325
bdb18c9326 .presence-dot:hover .tooltip { display: block; }
bdb18c9327
bdb18c9328 .topbar .actions {
bdb18c9329 display: flex;
e629dcb330 gap: 0.5rem;
bdb18c9331 }
bdb18c9332
bdb18c9333 .btn {
e629dcb334 padding: 0.375rem 0.75rem;
bdb18c9335 border: 1px solid var(--border);
e629dcb336 background: none;
e629dcb337 color: var(--text-muted);
e629dcb338 font-size: 0.875rem;
e629dcb339 font-family: inherit;
bdb18c9340 cursor: pointer;
e629dcb341 transition: background-color 0.15s;
bdb18c9342 }
bdb18c9343
e629dcb344 .btn:hover { background: var(--bg-hover); }
bdb18c9345 .btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
bdb18c9346 .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
bdb18c9347
7f3fce0348 .topbar .profile-pill {
e629dcb349 display: flex;
e629dcb350 align-items: center;
7f3fce0351 gap: 6px;
7f3fce0352 padding: 4px 10px;
7f3fce0353 border: 1px solid var(--border);
7f3fce0354 border-radius: 9999px;
7f3fce0355 background: none;
7f3fce0356 color: var(--text-muted);
7f3fce0357 font-size: 0.8125rem;
e629dcb358 font-family: inherit;
7f3fce0359 cursor: pointer;
7f3fce0360 }
7f3fce0361
7f3fce0362 .topbar .profile-pill:hover {
7f3fce0363 background: var(--bg-hover);
e629dcb364 }
e629dcb365
bdb18c9366 /* Left drawer */
bdb18c9367 .drawer {
bdb18c9368 width: var(--drawer-width);
bdb18c9369 background: var(--bg-card);
bdb18c9370 border-right: 1px solid var(--border);
bdb18c9371 display: flex;
bdb18c9372 flex-direction: column;
bdb18c9373 flex-shrink: 0;
bdb18c9374 overflow: hidden;
bdb18c9375 }
bdb18c9376
bdb18c9377
bdb18c9378 .drawer-header {
bdb18c9379 padding: 12px 16px;
e629dcb380 font-size: 0.75rem;
bdb18c9381 font-weight: 500;
bdb18c9382 color: var(--text-muted);
bdb18c9383 text-transform: uppercase;
bdb18c9384 letter-spacing: 0.5px;
bdb18c9385 border-bottom: 1px solid var(--border-subtle);
bdb18c9386 flex-shrink: 0;
bdb18c9387 }
bdb18c9388
bdb18c9389 .drawer-list {
bdb18c9390 flex: 1;
bdb18c9391 overflow-y: auto;
bdb18c9392 padding: 4px 0;
bdb18c9393 }
bdb18c9394
bdb18c9395 .drawer-section {
bdb18c9396 padding: 8px 12px 4px 12px;
bdb18c9397 font-size: 10px;
bdb18c9398 font-weight: 500;
bdb18c9399 color: var(--text-faint);
bdb18c9400 text-transform: uppercase;
bdb18c9401 letter-spacing: 0.5px;
bdb18c9402 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9403 }
bdb18c9404
bdb18c9405 .drawer-item {
bdb18c9406 display: flex;
bdb18c9407 align-items: center;
bdb18c9408 gap: 8px;
bdb18c9409 padding: 7px 12px 7px 16px;
bdb18c9410 font-size: 12px;
bdb18c9411 color: var(--text-secondary);
bdb18c9412 cursor: pointer;
bdb18c9413 transition: background-color 0.12s, color 0.12s;
bdb18c9414 white-space: nowrap;
bdb18c9415 overflow: hidden;
bdb18c9416 text-overflow: ellipsis;
bdb18c9417 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9418 position: relative;
bdb18c9419 border-left: 3px solid transparent;
bdb18c9420 }
bdb18c9421
bdb18c9422 .drawer-item:hover {
bdb18c9423 background: var(--bg-hover);
bdb18c9424 color: var(--text-primary);
bdb18c9425 }
bdb18c9426
bdb18c9427 .drawer-item.active {
bdb18c9428 background: var(--accent-subtle);
bdb18c9429 color: var(--accent);
bdb18c9430 border-left-color: var(--accent);
bdb18c9431 }
bdb18c9432
bdb18c9433 .drawer-item .item-number {
bdb18c9434 font-size: 10px;
bdb18c9435 color: var(--text-faint);
bdb18c9436 min-width: 18px;
bdb18c9437 flex-shrink: 0;
bdb18c9438 }
bdb18c9439
bdb18c9440 .drawer-item.active .item-number {
bdb18c9441 color: var(--accent);
bdb18c9442 }
bdb18c9443
bdb18c9444 .drawer-item .item-label {
bdb18c9445 overflow: hidden;
bdb18c9446 text-overflow: ellipsis;
bdb18c9447 flex: 1;
bdb18c9448 }
bdb18c9449
bdb18c9450 .drawer-item .note-count {
bdb18c9451 background: var(--accent-subtle);
bdb18c9452 color: var(--accent);
bdb18c9453 font-size: 10px;
bdb18c9454 font-weight: 500;
bdb18c9455 padding: 1px 5px;
bdb18c9456 border-radius: 2px;
bdb18c9457 border: 1px solid var(--note-border);
bdb18c9458 flex-shrink: 0;
bdb18c9459 }
bdb18c9460
bdb18c9461 .drawer-item .viewer-dots {
bdb18c9462 display: flex;
bdb18c9463 gap: 3px;
bdb18c9464 flex-shrink: 0;
bdb18c9465 margin-left: auto;
bdb18c9466 padding-left: 6px;
bdb18c9467 }
bdb18c9468
bdb18c9469 .drawer-item .viewer-dot {
bdb18c9470 width: 8px;
bdb18c9471 height: 8px;
bdb18c9472 border-radius: 50%;
bdb18c9473 border: 1px solid rgba(255,255,255,0.5);
bdb18c9474 box-shadow: 0 0 0 0.5px rgba(0,0,0,0.1);
bdb18c9475 }
bdb18c9476
bdb18c9477 /* Main area */
bdb18c9478 .main {
bdb18c9479 display: flex;
bdb18c9480 flex: 1;
bdb18c9481 overflow: hidden;
bdb18c9482 }
bdb18c9483
bdb18c9484 /* Diagram pane */
bdb18c9485 .diagram-pane {
bdb18c9486 flex: 1;
bdb18c9487 overflow: hidden;
bdb18c9488 position: relative;
bdb18c9489 background: var(--bg-page);
bdb18c9490 cursor: grab;
bdb18c9491 }
bdb18c9492
bdb18c9493 .diagram-pane.panning { cursor: grabbing; }
bdb18c9494
bdb18c9495 .diagram-pane .mermaid-container {
bdb18c9496 transform-origin: 0 0;
bdb18c9497 display: inline-block;
bdb18c9498 padding: 40px;
bdb18c9499 position: relative;
bdb18c9500 }
bdb18c9501
bdb18c9502 .diagram-pane .mermaid-container svg {
bdb18c9503 display: block;
bdb18c9504 }
bdb18c9505
cee484a506 /* Node focus: dim all diagram elements, then un-dim highlighted ones */
cee484a507 .diagram-pane.node-focus .mermaid-container svg .node-dimable {
cee484a508 opacity: 0.1;
cee484a509 transition: opacity 0.2s ease;
cee484a510 }
cee484a511
cee484a512 .diagram-pane.node-focus .mermaid-container svg .node-dimable.node-highlight {
cee484a513 opacity: 1 !important;
cee484a514 transition: opacity 0.2s ease;
cee484a515 }
cee484a516
cee484a517 .diagram-pane .mermaid-container svg .node-clickable {
cee484a518 cursor: pointer;
cee484a519 }
cee484a520
bdb18c9521 .bottom-toolbar {
bdb18c9522 position: absolute;
bdb18c9523 bottom: 12px;
bdb18c9524 right: 12px;
bdb18c9525 display: flex;
bdb18c9526 align-items: center;
bdb18c9527 gap: 4px;
bdb18c9528 z-index: 20;
bdb18c9529 background: var(--bg-card);
bdb18c9530 border: 1px solid var(--border);
bdb18c9531 border-radius: 6px;
bdb18c9532 padding: 3px;
bdb18c9533 box-shadow: 0 2px 8px rgba(0,0,0,0.06);
bdb18c9534 opacity: 0.7;
bdb18c9535 transition: opacity 0.15s;
bdb18c9536 }
bdb18c9537
bdb18c9538 .bottom-toolbar:hover {
bdb18c9539 opacity: 1;
bdb18c9540 }
bdb18c9541
bdb18c9542 .toolbar-divider {
bdb18c9543 width: 1px;
bdb18c9544 height: 20px;
bdb18c9545 background: var(--border);
bdb18c9546 margin: 0 2px;
bdb18c9547 }
bdb18c9548
bdb18c9549 .mode-btn {
bdb18c9550 width: 32px;
bdb18c9551 height: 32px;
bdb18c9552 border: 1px solid transparent;
bdb18c9553 background: transparent;
bdb18c9554 color: var(--text-muted);
bdb18c9555 border-radius: 4px;
bdb18c9556 cursor: pointer;
bdb18c9557 display: flex;
bdb18c9558 align-items: center;
bdb18c9559 justify-content: center;
bdb18c9560 transition: background-color 0.12s, color 0.12s, border-color 0.12s;
bdb18c9561 }
bdb18c9562
bdb18c9563 .mode-btn:hover {
bdb18c9564 background: var(--bg-hover);
bdb18c9565 color: var(--text-primary);
bdb18c9566 }
bdb18c9567
bdb18c9568 .mode-btn.active {
bdb18c9569 background: var(--accent-subtle);
bdb18c9570 color: var(--accent);
bdb18c9571 border-color: var(--accent);
bdb18c9572 }
bdb18c9573
bdb18c9574 .zoom-btn {
bdb18c9575 width: 32px;
bdb18c9576 height: 32px;
bdb18c9577 border: 1px solid transparent;
bdb18c9578 background: transparent;
bdb18c9579 color: var(--text-secondary);
bdb18c9580 border-radius: 4px;
bdb18c9581 cursor: pointer;
bdb18c9582 font-size: 16px;
bdb18c9583 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9584 display: flex;
bdb18c9585 align-items: center;
bdb18c9586 justify-content: center;
bdb18c9587 transition: background-color 0.15s;
bdb18c9588 }
bdb18c9589
bdb18c9590 .zoom-btn:hover { background: var(--bg-hover); }
bdb18c9591
bdb18c9592 .zoom-level {
bdb18c9593 height: 32px;
bdb18c9594 padding: 0 8px;
bdb18c9595 color: var(--text-muted);
bdb18c9596 font-size: 11px;
bdb18c9597 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9598 display: flex;
bdb18c9599 align-items: center;
bdb18c9600 }
bdb18c9601
bdb18c9602
bdb18c9603 /* Code pane (vertical left sidebar) */
bdb18c9604 .code-pane {
bdb18c9605 width: 32px;
bdb18c9606 min-width: 32px;
bdb18c9607 background: var(--bg-card);
bdb18c9608 border-right: 1px solid var(--border);
bdb18c9609 flex-shrink: 0;
bdb18c9610 overflow: hidden;
bdb18c9611 display: flex;
bdb18c9612 flex-direction: column;
bdb18c9613 position: relative;
bdb18c9614 }
bdb18c9615
bdb18c9616 .code-pane.open {
bdb18c9617 width: 520px;
bdb18c9618 min-width: 200px;
bdb18c9619 }
bdb18c9620
bdb18c9621 .code-pane-header {
bdb18c9622 display: flex;
bdb18c9623 align-items: center;
bdb18c9624 gap: 6px;
bdb18c9625 padding: 8px 8px;
bdb18c9626 font-size: 11px;
bdb18c9627 font-weight: 500;
bdb18c9628 color: var(--text-muted);
bdb18c9629 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9630 cursor: pointer;
bdb18c9631 user-select: none;
bdb18c9632 border-bottom: 1px solid var(--border-subtle);
bdb18c9633 text-transform: uppercase;
bdb18c9634 letter-spacing: 0.5px;
bdb18c9635 white-space: nowrap;
bdb18c9636 flex-shrink: 0;
bdb18c9637 }
bdb18c9638
bdb18c9639 .code-pane:not(.open) .code-pane-header {
bdb18c9640 writing-mode: vertical-lr;
bdb18c9641 text-orientation: mixed;
bdb18c9642 border-bottom: none;
bdb18c9643 padding: 12px 6px;
bdb18c9644 flex: 1;
bdb18c9645 justify-content: start;
bdb18c9646 align-items: center;
bdb18c9647 }
bdb18c9648
bdb18c9649 .code-pane:not(.open) .code-pane-toggle {
bdb18c9650 transform: rotate(90deg);
bdb18c9651 margin-bottom: 2px;
bdb18c9652 }
bdb18c9653
bdb18c9654 .code-pane-header:hover { background: var(--bg-hover); }
bdb18c9655
bdb18c9656 .code-pane-fmt-btn {
bdb18c9657 display: none;
bdb18c9658 padding: 2px 8px;
bdb18c9659 font-size: 10px;
bdb18c9660 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9661 background: var(--bg-inset);
bdb18c9662 border: 1px solid var(--border);
bdb18c9663 border-radius: 3px;
bdb18c9664 color: var(--text-muted);
bdb18c9665 cursor: pointer;
bdb18c9666 text-transform: none;
bdb18c9667 letter-spacing: 0;
bdb18c9668 transition: background-color 0.12s, color 0.12s;
bdb18c9669 }
bdb18c9670
bdb18c9671 .code-pane.open .code-pane-fmt-btn { display: block; }
bdb18c9672 .code-pane.open .code-pane-text-size { display: flex !important; gap: 2px; align-items: center; }
bdb18c9673 .code-pane-fmt-btn:hover { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
bdb18c9674
bdb18c9675 .code-pane-play-btn {
bdb18c9676 display: none;
bdb18c9677 padding: 2px 10px;
bdb18c9678 font-size: 10px;
bdb18c9679 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9680 background: var(--accent);
bdb18c9681 border: 1px solid var(--accent);
bdb18c9682 border-radius: 3px;
bdb18c9683 color: var(--accent-text);
bdb18c9684 cursor: pointer;
bdb18c9685 text-transform: none;
bdb18c9686 letter-spacing: 0;
bdb18c9687 transition: opacity 0.12s, background-color 0.12s;
bdb18c9688 opacity: 0.5;
bdb18c9689 }
bdb18c9690 .code-pane.open .code-pane-play-btn { display: block; }
bdb18c9691 .code-pane-play-btn.dirty { opacity: 1; }
bdb18c9692 .code-pane-play-btn:hover { opacity: 1; }
bdb18c9693
bdb18c9694 .code-pane-status {
bdb18c9695 display: none;
bdb18c9696 font-size: 10px;
bdb18c9697 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9698 color: var(--text-muted);
bdb18c9699 white-space: nowrap;
bdb18c9700 cursor: default;
bdb18c9701 }
bdb18c9702 .code-pane.open .code-pane-status { display: inline-block; }
bdb18c9703 .code-pane-status.error { color: #a05050; }
bdb18c9704 .code-pane-status.ok { color: var(--accent); }
bdb18c9705
bdb18c9706 .code-pane-diagnostics {
bdb18c9707 display: none;
bdb18c9708 padding: 6px 10px;
bdb18c9709 font-size: 11px;
bdb18c9710 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9711 color: #a05050;
bdb18c9712 background: #a050500a;
bdb18c9713 border-top: 1px solid #a0505030;
bdb18c9714 flex-shrink: 0;
bdb18c9715 overflow-x: auto;
bdb18c9716 white-space: pre-wrap;
bdb18c9717 word-break: break-word;
bdb18c9718 max-height: 120px;
bdb18c9719 overflow-y: auto;
bdb18c9720 line-height: 1.5;
bdb18c9721 }
bdb18c9722 .code-pane.open .code-pane-diagnostics.visible { display: block; }
bdb18c9723
bdb18c9724 .code-pane-toggle {
bdb18c9725 font-size: 10px;
bdb18c9726 transition: transform 0.15s;
bdb18c9727 display: inline-block;
bdb18c9728 }
bdb18c9729
bdb18c9730 .code-pane.open .code-pane-toggle {
bdb18c9731 transform: rotate(90deg);
bdb18c9732 }
bdb18c9733
bdb18c9734 .code-pane-editor {
bdb18c9735 display: none;
bdb18c9736 flex: 1;
bdb18c9737 overflow: hidden;
bdb18c9738 background: var(--bg-inset);
bdb18c9739 }
bdb18c9740
bdb18c9741 .code-pane.open .code-pane-editor {
bdb18c9742 display: flex;
bdb18c9743 }
bdb18c9744
bdb18c9745 /* Resize handle */
bdb18c9746 .code-pane-resize {
bdb18c9747 position: absolute;
bdb18c9748 top: 0;
bdb18c9749 right: -3px;
bdb18c9750 width: 6px;
bdb18c9751 height: 100%;
bdb18c9752 cursor: col-resize;
bdb18c9753 z-index: 10;
bdb18c9754 display: none;
bdb18c9755 }
bdb18c9756
bdb18c9757 .code-pane.open .code-pane-resize {
bdb18c9758 display: block;
bdb18c9759 }
bdb18c9760
bdb18c9761 .code-pane-resize:hover,
bdb18c9762 .code-pane-resize.dragging {
bdb18c9763 background: var(--accent);
bdb18c9764 opacity: 0.4;
bdb18c9765 }
bdb18c9766
bdb18c9767 /* Notes sidebar */
bdb18c9768 .notes-sidebar {
bdb18c9769 width: 300px;
bdb18c9770 min-width: 180px;
bdb18c9771 background: var(--bg-card);
bdb18c9772 border-left: 1px solid var(--border);
bdb18c9773 display: flex;
bdb18c9774 flex-direction: column;
bdb18c9775 flex-shrink: 0;
bdb18c9776 position: relative;
bdb18c9777 }
bdb18c9778
bdb18c9779 .notes-resize {
bdb18c9780 position: absolute;
bdb18c9781 top: 0;
bdb18c9782 left: -3px;
bdb18c9783 width: 6px;
bdb18c9784 height: 100%;
bdb18c9785 cursor: col-resize;
bdb18c9786 z-index: 10;
bdb18c9787 }
bdb18c9788
bdb18c9789 .notes-resize:hover,
bdb18c9790 .notes-resize.dragging {
bdb18c9791 background: var(--accent);
bdb18c9792 opacity: 0.4;
bdb18c9793 }
bdb18c9794
bdb18c9795 .notes-header {
bdb18c9796 padding: 8px 12px;
bdb18c9797 font-size: 11px;
bdb18c9798 font-weight: 500;
bdb18c9799 color: var(--text-muted);
bdb18c9800 border-bottom: 1px solid var(--border-subtle);
bdb18c9801 display: flex;
bdb18c9802 align-items: center;
bdb18c9803 justify-content: space-between;
bdb18c9804 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9805 text-transform: uppercase;
bdb18c9806 letter-spacing: 0.5px;
bdb18c9807 }
bdb18c9808
bdb18c9809 .notes-list {
bdb18c9810 flex: 1;
bdb18c9811 overflow-y: auto;
bdb18c9812 padding: 6px;
bdb18c9813 }
bdb18c9814
bdb18c9815 .notes-list:empty::after {
bdb18c9816 content: "No notes yet";
bdb18c9817 display: block;
bdb18c9818 text-align: center;
bdb18c9819 padding: 32px 16px;
bdb18c9820 color: var(--text-faint);
bdb18c9821 font-size: 12px;
bdb18c9822 font-style: italic;
bdb18c9823 }
bdb18c9824
bdb18c9825 .note-card {
bdb18c9826 background: transparent;
bdb18c9827 border: none;
bdb18c9828 border-bottom: 1px solid var(--border-subtle);
bdb18c9829 border-radius: 0;
bdb18c9830 padding: 8px 10px;
bdb18c9831 font-size: 13px;
bdb18c9832 position: relative;
bdb18c9833 transition: background-color 0.12s;
bdb18c9834 }
bdb18c9835
bdb18c9836 .note-card:last-child { border-bottom: none; }
bdb18c9837
bdb18c9838 .note-card:hover {
bdb18c9839 background: var(--bg-hover);
bdb18c9840 }
bdb18c9841
bdb18c9842 .note-card .note-meta {
bdb18c9843 display: flex;
bdb18c9844 align-items: center;
bdb18c9845 gap: 6px;
bdb18c9846 margin-bottom: 3px;
bdb18c9847 font-size: 10px;
bdb18c9848 color: var(--text-muted);
bdb18c9849 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9850 }
bdb18c9851
bdb18c9852 .note-card .note-author {
bdb18c9853 font-weight: 500;
bdb18c9854 color: var(--accent);
bdb18c9855 }
bdb18c9856
bdb18c9857 .note-card .note-text {
bdb18c9858 color: var(--text-primary);
bdb18c9859 line-height: 1.5;
bdb18c9860 font-size: 12px;
bdb18c9861 }
bdb18c9862
bdb18c9863 .note-card .note-actions {
bdb18c9864 position: absolute;
bdb18c9865 top: 6px;
bdb18c9866 right: 6px;
bdb18c9867 display: none;
bdb18c9868 gap: 3px;
bdb18c9869 }
bdb18c9870
bdb18c9871 .note-card:hover .note-actions { display: flex; }
bdb18c9872
bdb18c9873 .note-action-btn {
bdb18c9874 width: 20px;
bdb18c9875 height: 20px;
bdb18c9876 border: none;
bdb18c9877 background: var(--bg-inset);
bdb18c9878 color: var(--text-muted);
bdb18c9879 border-radius: 3px;
bdb18c9880 cursor: pointer;
bdb18c9881 font-size: 10px;
bdb18c9882 display: flex;
bdb18c9883 align-items: center;
bdb18c9884 justify-content: center;
bdb18c9885 transition: background-color 0.12s;
bdb18c9886 }
bdb18c9887
bdb18c9888 .note-action-btn:hover { background: var(--accent); color: var(--accent-text); }
bdb18c9889
bdb18c9890 .notes-input-area {
bdb18c9891 padding: 8px;
bdb18c9892 border-top: 1px solid var(--border-subtle);
bdb18c9893 display: flex;
bdb18c9894 gap: 6px;
bdb18c9895 align-items: flex-end;
bdb18c9896 }
bdb18c9897
bdb18c9898 .notes-input-area textarea {
bdb18c9899 flex: 1;
bdb18c9900 background: var(--bg-inset);
bdb18c9901 border: 1px solid var(--border-subtle);
bdb18c9902 border-radius: 4px;
bdb18c9903 color: var(--text-primary);
bdb18c9904 padding: 6px 10px;
bdb18c9905 font-size: 12px;
bdb18c9906 font-family: 'Libre Caslon Text', Georgia, serif;
bdb18c9907 resize: none;
bdb18c9908 outline: none;
bdb18c9909 min-height: 32px;
bdb18c9910 max-height: 80px;
bdb18c9911 line-height: 1.4;
bdb18c9912 transition: border-color 0.12s;
bdb18c9913 }
bdb18c9914
bdb18c9915 .notes-input-area textarea::placeholder { color: var(--text-faint); font-size: 11px; }
bdb18c9916 .notes-input-area textarea:focus { border-color: var(--accent); }
bdb18c9917
bdb18c9918 .notes-input-area .note-send-btn {
bdb18c9919 width: 32px;
bdb18c9920 height: 32px;
bdb18c9921 border: none;
bdb18c9922 background: var(--accent);
bdb18c9923 color: var(--accent-text);
bdb18c9924 border-radius: 4px;
bdb18c9925 cursor: pointer;
bdb18c9926 display: flex;
bdb18c9927 align-items: center;
bdb18c9928 justify-content: center;
bdb18c9929 flex-shrink: 0;
bdb18c9930 transition: opacity 0.12s;
bdb18c9931 opacity: 0.6;
bdb18c9932 }
bdb18c9933
bdb18c9934 .notes-input-area .note-send-btn:hover { opacity: 1; }
bdb18c9935
bdb18c9936 .notes-input-area .input-hint {
bdb18c9937 font-size: 11px;
bdb18c9938 color: var(--text-muted);
bdb18c9939 margin-top: 4px;
bdb18c9940 }
bdb18c9941
bdb18c9942 .notes-input-area .input-row {
bdb18c9943 display: flex;
bdb18c9944 gap: 8px;
bdb18c9945 margin-top: 8px;
bdb18c9946 align-items: center;
bdb18c9947 }
bdb18c9948
bdb18c9949 .notes-input-area .click-hint {
bdb18c9950 font-size: 11px;
bdb18c9951 color: var(--text-faint);
bdb18c9952 font-style: italic;
bdb18c9953 }
bdb18c9954
bdb18c9955 /* Remote cursors */
bdb18c9956 .remote-cursor {
bdb18c9957 position: fixed;
bdb18c9958 pointer-events: none;
bdb18c9959 z-index: 1000;
bdb18c9960 transition: left 0.12s linear, top 0.12s linear;
bdb18c9961 display: flex;
bdb18c9962 align-items: center;
bdb18c9963 gap: 4px;
bdb18c9964 }
bdb18c9965
bdb18c9966 .remote-cursor .cursor-dot {
bdb18c9967 width: 10px;
bdb18c9968 height: 10px;
bdb18c9969 border-radius: 50%;
bdb18c9970 border: 1.5px solid rgba(255,255,255,0.8);
bdb18c9971 box-shadow: 0 1px 3px rgba(0,0,0,0.3);
bdb18c9972 flex-shrink: 0;
bdb18c9973 }
bdb18c9974
bdb18c9975 .remote-cursor .cursor-label {
bdb18c9976 font-size: 9px;
bdb18c9977 font-weight: 500;
bdb18c9978 color: #fff;
bdb18c9979 padding: 1px 5px;
bdb18c9980 border-radius: 3px;
bdb18c9981 white-space: nowrap;
bdb18c9982 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c9983 opacity: 0;
bdb18c9984 transition: opacity 0.2s;
bdb18c9985 }
bdb18c9986
bdb18c9987 .remote-cursor:hover .cursor-label,
bdb18c9988 .remote-cursor.recent .cursor-label {
bdb18c9989 opacity: 1;
bdb18c9990 }
bdb18c9991
bdb18c9992
bdb18c9993 /* Empty state */
bdb18c9994 .empty-notes {
bdb18c9995 display: flex;
bdb18c9996 flex-direction: column;
bdb18c9997 align-items: center;
bdb18c9998 justify-content: center;
bdb18c9999 height: 100%;
bdb18c91000 color: var(--text-faint);
bdb18c91001 font-size: 13px;
bdb18c91002 text-align: center;
bdb18c91003 padding: 24px;
bdb18c91004 }
bdb18c91005
bdb18c91006 .empty-notes .icon { font-size: 32px; margin-bottom: 8px; opacity: 0.4; }
bdb18c91007
bdb18c91008 /* Toast */
bdb18c91009 @keyframes toast-enter {
bdb18c91010 from { opacity: 0; transform: translateX(-50%) translateY(8px); }
bdb18c91011 to { opacity: 1; transform: translateX(-50%) translateY(0); }
bdb18c91012 }
bdb18c91013
bdb18c91014 .toast {
bdb18c91015 position: fixed;
bdb18c91016 bottom: 20px;
bdb18c91017 left: 50%;
bdb18c91018 transform: translateX(-50%);
bdb18c91019 background: var(--bg-card);
bdb18c91020 color: var(--text-primary);
bdb18c91021 border: 1px solid var(--border);
bdb18c91022 padding: 8px 20px;
bdb18c91023 border-radius: 4px;
bdb18c91024 font-size: 13px;
bdb18c91025 z-index: 10000;
bdb18c91026 opacity: 0;
bdb18c91027 transition: opacity 0.3s;
bdb18c91028 pointer-events: none;
bdb18c91029 }
bdb18c91030
bdb18c91031 .toast.show { opacity: 1; animation: toast-enter 0.15s ease-out; }
bdb18c91032
bdb18c91033 /* Keyboard hint in drawer footer */
bdb18c91034 .drawer-footer {
bdb18c91035 padding: 8px 12px;
bdb18c91036 border-top: 1px solid var(--border-subtle);
bdb18c91037 font-size: 10px;
bdb18c91038 color: var(--text-faint);
bdb18c91039 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c91040 text-align: center;
bdb18c91041 flex-shrink: 0;
bdb18c91042 }
bdb18c91043
bdb18c91044 .drawer-footer kbd {
bdb18c91045 display: inline-block;
bdb18c91046 background: var(--bg-hover);
bdb18c91047 border: 1px solid var(--border);
bdb18c91048 border-radius: 3px;
bdb18c91049 padding: 0 4px;
bdb18c91050 font-size: 10px;
bdb18c91051 font-family: 'JetBrains Mono', Menlo, monospace;
bdb18c91052 }
bdb18c91053
bdb18c91054
922dd181055 /* Homepage */
a3cd4f51056 .homepage-nav {
922dd181057 display: flex;
a3cd4f51058 align-items: center;
a3cd4f51059 justify-content: space-between;
a3cd4f51060 height: 3.5rem;
a3cd4f51061 padding: 0 1.5rem;
a3cd4f51062 border-bottom: 1px solid var(--border);
a3cd4f51063 background: var(--bg-page);
922dd181064 }
922dd181065
a3cd4f51066 .homepage-nav .nav-left {
922dd181067 display: flex;
922dd181068 align-items: center;
a3cd4f51069 gap: 0.5rem;
922dd181070 }
922dd181071
a3cd4f51072 .homepage-nav .nav-left span {
a3cd4f51073 font-size: 1.125rem;
a3cd4f51074 font-weight: 500;
922dd181075 color: var(--text-primary);
a3cd4f51076 margin-left: 0.25rem;
a3cd4f51077 }
a3cd4f51078
a3cd4f51079 .homepage-nav .nav-right {
a3cd4f51080 display: flex;
a3cd4f51081 align-items: center;
a3cd4f51082 gap: 1rem;
a3cd4f51083 font-size: 0.875rem;
a3cd4f51084 }
a3cd4f51085
a3cd4f51086 .homepage-nav .nav-right a {
a3cd4f51087 color: var(--text-muted);
a3cd4f51088 text-decoration: none;
a3cd4f51089 }
a3cd4f51090
a3cd4f51091 .homepage-nav .nav-right a:hover {
a3cd4f51092 text-decoration: underline;
a3cd4f51093 }
a3cd4f51094
7f3fce01095 .profile-pill {
792cc2e1096 display: flex;
792cc2e1097 align-items: center;
7f3fce01098 gap: 6px;
7f3fce01099 padding: 4px 10px;
7f3fce01100 border: 1px solid var(--border);
7f3fce01101 border-radius: 9999px;
7f3fce01102 background: none;
7f3fce01103 color: var(--text-muted);
7f3fce01104 font-size: 0.8125rem;
792cc2e1105 font-family: inherit;
7f3fce01106 cursor: pointer;
7f3fce01107 }
7f3fce01108
7f3fce01109 .profile-pill:hover {
7f3fce01110 background: var(--bg-hover);
7f3fce01111 }
7f3fce01112
7f3fce01113 .profile-pill svg {
7f3fce01114 flex-shrink: 0;
792cc2e1115 }
792cc2e1116
792cc2e1117 .profile-menu {
792cc2e1118 position: relative;
792cc2e1119 }
792cc2e1120
792cc2e1121 .profile-dropdown {
792cc2e1122 display: none;
792cc2e1123 position: absolute;
792cc2e1124 right: 0;
792cc2e1125 top: calc(100% + 4px);
792cc2e1126 min-width: 160px;
792cc2e1127 background: var(--bg-card);
792cc2e1128 border: 1px solid var(--border);
792cc2e1129 box-shadow: 0 4px 12px rgba(0,0,0,0.1);
792cc2e1130 z-index: 200;
792cc2e1131 }
792cc2e1132
792cc2e1133 .profile-dropdown.open {
792cc2e1134 display: block;
792cc2e1135 }
792cc2e1136
792cc2e1137 .profile-dropdown a {
792cc2e1138 display: block;
792cc2e1139 padding: 6px 12px;
792cc2e1140 font-size: 0.875rem;
792cc2e1141 color: var(--text-primary);
792cc2e1142 text-decoration: none;
792cc2e1143 cursor: pointer;
792cc2e1144 }
792cc2e1145
792cc2e1146 .profile-dropdown a:hover {
792cc2e1147 background: var(--bg-hover);
792cc2e1148 text-decoration: none;
792cc2e1149 }
792cc2e1150
a3cd4f51151 .homepage {
a3cd4f51152 max-width: 48rem;
34cc7161153 width: 100%;
a3cd4f51154 margin: 0 auto;
021201c1155 padding: 2rem 1rem;
a3cd4f51156 display: flex;
a3cd4f51157 flex-direction: column;
922dd181158 }
922dd181159
922dd181160 .homepage-search {
922dd181161 margin-bottom: 1rem;
922dd181162 padding: 10px 12px;
922dd181163 background: var(--bg-card);
922dd181164 border: 1px solid var(--border-subtle);
922dd181165 }
922dd181166
922dd181167 .homepage-search input {
922dd181168 width: 100%;
922dd181169 font-size: 14px;
922dd181170 background: transparent;
922dd181171 color: var(--text-secondary);
922dd181172 border: none;
922dd181173 outline: none;
922dd181174 font-family: 'Libre Caslon Text', Georgia, serif;
922dd181175 }
922dd181176
922dd181177 .homepage-search input::placeholder {
922dd181178 color: var(--text-faint);
922dd181179 }
922dd181180
922dd181181 .homepage-group {
922dd181182 background: var(--bg-card);
922dd181183 border: 1px solid var(--border-subtle);
922dd181184 margin-bottom: 1rem;
922dd181185 }
922dd181186
922dd181187 .homepage-owner {
922dd181188 display: block;
922dd181189 padding: 8px 12px;
922dd181190 font-size: 14px;
922dd181191 font-weight: 500;
922dd181192 color: var(--text-muted);
922dd181193 border-bottom: 1px solid var(--border-subtle);
922dd181194 text-decoration: none;
922dd181195 transition: background-color 0.1s;
922dd181196 }
922dd181197
922dd181198 .homepage-owner:hover {
922dd181199 background: var(--bg-hover);
922dd181200 }
922dd181201
922dd181202 .homepage-repo {
922dd181203 display: flex;
922dd181204 align-items: center;
922dd181205 justify-content: space-between;
922dd181206 gap: 1rem;
922dd181207 padding: 10px 12px;
922dd181208 text-decoration: none;
922dd181209 transition: background-color 0.1s;
922dd181210 }
922dd181211
922dd181212 .homepage-repo:hover {
922dd181213 background: var(--bg-hover);
922dd181214 }
922dd181215
922dd181216 .homepage-repo + .homepage-repo {
922dd181217 border-top: 1px solid var(--border-subtle);
922dd181218 }
922dd181219
922dd181220 .homepage-repo-name {
922dd181221 color: var(--accent);
922dd181222 font-size: 14px;
922dd181223 }
922dd181224
922dd181225 .homepage-repo-desc {
922dd181226 color: var(--text-faint);
922dd181227 font-size: 12px;
922dd181228 margin-top: 2px;
922dd181229 overflow: hidden;
922dd181230 text-overflow: ellipsis;
922dd181231 white-space: nowrap;
922dd181232 }
922dd181233
922dd181234 .homepage-repo-time {
922dd181235 color: var(--text-faint);
922dd181236 font-size: 12px;
922dd181237 flex-shrink: 0;
922dd181238 }
922dd181239
922dd181240 .homepage-empty {
922dd181241 padding: 3rem 1rem;
922dd181242 text-align: center;
922dd181243 background: var(--bg-card);
922dd181244 border: 1px solid var(--border-subtle);
922dd181245 }
922dd181246
922dd181247 .homepage-empty .logo-wrap {
922dd181248 opacity: 0.5;
922dd181249 margin-bottom: 1rem;
922dd181250 }
922dd181251
922dd181252 .homepage-empty p {
922dd181253 font-size: 14px;
922dd181254 color: var(--text-faint);
922dd181255 }
922dd181256
922dd181257 .homepage-loading {
922dd181258 background: var(--bg-card);
922dd181259 border: 1px solid var(--border-subtle);
922dd181260 }
922dd181261
922dd181262 .homepage-loading .skel-row {
922dd181263 padding: 10px 12px;
922dd181264 border-top: 1px solid var(--divide);
922dd181265 }
922dd181266
922dd181267 .homepage-loading .skel-row:first-child {
922dd181268 border-top: none;
922dd181269 }
922dd181270
922dd181271 .skel {
922dd181272 height: 0.875rem;
922dd181273 border-radius: 3px;
922dd181274 background: linear-gradient(90deg, var(--bg-hover) 25%, var(--bg-inset) 50%, var(--bg-hover) 75%);
922dd181275 background-size: 200% 100%;
922dd181276 animation: shimmer 1.5s ease-in-out infinite;
922dd181277 }
922dd181278
922dd181279 @keyframes shimmer {
922dd181280 0% { background-position: 200% 0; }
922dd181281 100% { background-position: -200% 0; }
922dd181282 }
922dd181283
bdb18c91284</style>
bdb18c91285</head>
bdb18c91286<body>
bdb18c91287
bdb18c91288<!-- Top bar -->
bdb18c91289<div class="topbar">
e629dcb1290 <div class="topbar-left">
e629dcb1291 <a href="/" class="logo">
bf6031c1292 <svg width="28" height="28" viewBox="0 0 64 64" fill="none"><circle cx="32" cy="32" r="32" fill="var(--accent)"/><path d="M9 16 C9 11, 14 8, 21 8 L35 8 C42 8, 47 11, 47 16 L47 24 C47 29, 42 32, 35 32 L19 32 L13 37 L13 32 C10 31, 9 29, 9 24 Z" fill="white"/><path d="M55 35 C55 30, 50 27, 43 27 L29 27 C22 27, 17 30, 17 35 L17 43 C17 48, 22 51, 29 51 L45 51 L51 56 L51 51 C54 50, 55 48, 55 43 Z" fill="white" opacity="0.65"/></svg>
e629dcb1293 </a>
e629dcb1294 <span class="breadcrumb-sep">/</span>
e629dcb1295 <a id="breadcrumbOwner" class="breadcrumb-item" href="#">—</a>
e629dcb1296 <span class="breadcrumb-sep">/</span>
e629dcb1297 <span id="breadcrumbRepo" class="breadcrumb-current">—</span>
bdb18c91298 </div>
e629dcb1299 <div class="topbar-right">
e629dcb1300 <div class="presence" id="presenceBar"></div>
e629dcb1301 <div class="actions">
e629dcb1302 <button class="btn" onclick="copyRoomLink()" title="Copy shareable link">Copy Link</button>
e629dcb1303 <button class="btn" onclick="exportNotesLLM()" title="Export notes in LLM-readable format">Export</button>
e629dcb1304 </div>
bf6031c1305 <a href="/login" id="topbarSignIn" class="sign-in-link" style="color:var(--text-muted);font-size:0.875rem;text-decoration:none">Sign in</a>
e629dcb1306 <div class="profile-menu" id="topbarProfileMenu" style="display:none">
7f3fce01307 <button class="profile-pill" id="topbarProfileToggle">
7f3fce01308 <span id="topbarProfileName"></span>
7f3fce01309 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4L5 6.5L7.5 4" /></svg>
7f3fce01310 </button>
e629dcb1311 <div class="profile-dropdown" id="topbarProfileDropdown">
e629dcb1312 <a href="/dashboard">Dashboard</a>
e629dcb1313 <a href="#" id="topbarProfileSignOut">Sign out</a>
e629dcb1314 </div>
e629dcb1315 </div>
bdb18c91316 </div>
bdb18c91317</div>
bdb18c91318
bdb18c91319<!-- Main content -->
bdb18c91320<div class="main">
bdb18c91321 <!-- Left drawer -->
bdb18c91322 <div class="drawer" id="drawer">
bdb18c91323 <div class="drawer-header">Diagrams</div>
bdb18c91324 <div class="drawer-list" id="drawerList"></div>
bdb18c91325 <div class="drawer-footer"><kbd>&#8593;</kbd> <kbd>&#8595;</kbd> to navigate</div>
bdb18c91326 </div>
bdb18c91327
bdb18c91328 <!-- Code pane (left) -->
bdb18c91329 <div class="code-pane" id="codePane">
bdb18c91330 <div class="code-pane-header">
bdb18c91331 <span onclick="toggleCodePane()" style="display:flex;align-items:center;gap:6px;flex:1;cursor:pointer">
bdb18c91332 <span class="code-pane-toggle" id="codePaneToggle">&#9654;</span>
bdb18c91333 <span>Definition</span>
bdb18c91334 </span>
bdb18c91335 <span class="code-pane-text-size" style="display:none">
bdb18c91336 <button class="code-pane-fmt-btn" style="display:inline-block;padding:2px 5px" onclick="changeEditorFontSize(-1)" title="Decrease font size">A&minus;</button>
bdb18c91337 <button class="code-pane-fmt-btn" style="display:inline-block;padding:2px 5px" onclick="changeEditorFontSize(1)" title="Increase font size">A+</button>
bdb18c91338 </span>
bdb18c91339 <span class="code-pane-status" id="codePaneStatus"></span>
bdb18c91340 <button class="code-pane-fmt-btn" onclick="formatMermaid()" title="Format (Shift+Alt+F)">Format</button>
bdb18c91341 <button class="code-pane-play-btn" id="codePanePlay" onclick="commitDiagramCode()" title="Render diagram (Ctrl+Enter)">&#9654; Run</button>
bdb18c91342 </div>
bdb18c91343 <div class="code-pane-editor" id="codePaneEditor"></div>
bdb18c91344 <div class="code-pane-diagnostics" id="codePaneDiagnostics"></div>
bdb18c91345 <div class="code-pane-resize" id="codePaneResize"></div>
bdb18c91346 </div>
bdb18c91347
bdb18c91348 <!-- Diagram viewer -->
bdb18c91349 <div class="diagram-pane" id="diagramPane">
bdb18c91350 <div class="mermaid-container" id="mermaidContainer"></div>
bdb18c91351 <div class="bottom-toolbar">
bdb18c91352 <!-- Zoom controls -->
bdb18c91353 <button class="zoom-btn" onclick="zoomOut()" title="Zoom out">&minus;</button>
bdb18c91354 <span class="zoom-level" id="zoomLevel">100%</span>
bdb18c91355 <button class="zoom-btn" onclick="zoomIn()" title="Zoom in">+</button>
bdb18c91356 <button class="zoom-btn" onclick="zoomReset()" title="Fit to view">&#8634;</button>
bdb18c91357 </div>
bdb18c91358 </div>
bdb18c91359
bdb18c91360 <!-- Notes sidebar -->
bdb18c91361 <div class="notes-sidebar" id="notesSidebar">
bdb18c91362 <div class="notes-resize" id="notesResize"></div>
bdb18c91363 <div class="notes-header">
bdb18c91364 <span id="notesTitle">Notes</span>
bdb18c91365 <span id="notesCount"></span>
bdb18c91366 </div>
bdb18c91367 <div class="notes-list" id="notesList"></div>
bdb18c91368 <div class="notes-input-area">
bdb18c91369 <textarea id="noteInput" placeholder="Add a note..." rows="1"></textarea>
bdb18c91370 <button class="note-send-btn" onclick="submitNote()" title="Send note (Enter)">
bdb18c91371 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
bdb18c91372 </button>
bdb18c91373 </div>
bdb18c91374 </div>
bdb18c91375</div>
bdb18c91376
bdb18c91377<div class="toast" id="toast"></div>
bdb18c91378
bdb18c91379<script>
1e755c01380// ── Diagram definitions (loaded from server) ──
1e755c01381let DIAGRAM_SECTIONS = [];
1e755c01382let DIAGRAMS = [];
1e755c01383
2a9592c1384async function loadDiagramsFromServer(owner, repo) {
1e755c01385 try {
2a9592c1386 const headers = {};
2a9592c1387 const token = getCookie("grove_hub_token");
2a9592c1388 if (token) headers["Authorization"] = `Bearer ${token}`;
2a9592c1389 const res = await fetch(`/api/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/diagrams`, { headers });
1e755c01390 if (!res.ok) throw new Error(`HTTP ${res.status}`);
1e755c01391 const data = await res.json();
1e755c01392 DIAGRAM_SECTIONS = data.sections || [];
1e755c01393 DIAGRAMS = data.diagrams || [];
1e755c01394 } catch (e) {
1e755c01395 console.error("[diagrams] Failed to load from server:", e);
1e755c01396 DIAGRAM_SECTIONS = [{ label: "Error" }];
1e755c01397 DIAGRAMS = [{
1e755c01398 id: "error",
1e755c01399 title: "Failed to load diagrams",
1e755c01400 section: 0,
1e755c01401 code: "graph TD\n A[Error loading diagrams from server]"
1e755c01402 }];
bdb18c91403 }
1e755c01404}
1e755c01405
1e755c01406// Diagram seed data lives in diagrams-default.json on the server.
1e755c01407// DIAGRAMS and DIAGRAM_SECTIONS are populated by loadDiagramsFromServer() above.
bdb18c91408
2a9592c1409// ── Auth helpers ──
2a9592c1410function getCookie(name) {
2a9592c1411 const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
2a9592c1412 return match ? decodeURIComponent(match[1]) : null;
2a9592c1413}
2a9592c1414
bdb18c91415// ── State ──
bdb18c91416let socket = null;
bdb18c91417let currentTab = 0;
bdb18c91418let myName = "";
bdb18c91419let roomId = "";
2a9592c1420let currentOwner = "";
2a9592c1421let currentRepo = "";
bdb18c91422let allNotes = {}; // { diagramId: [note, ...] }
bdb18c91423let allUsers = {};
bdb18c91424let remoteCursors = {}; // { socketId: DOM element }
bdb18c91425
bdb18c91426// Pan/zoom state
bdb18c91427let panZoom = { x: 0, y: 0, scale: 1 };
bdb18c91428let isPanning = false;
bdb18c91429let panStart = { x: 0, y: 0 };
bdb18c91430let panStartOffset = { x: 0, y: 0 };
cee484a1431let didDrag = false; // true if mouse moved during a pan gesture (suppress click)
cee484a1432let selectedNodeId = null; // currently focused node for dim effect
bdb18c91433
bdb18c91434// ── Init ──
bdb18c91435mermaid.initialize({
bdb18c91436 startOnLoad: false,
bdb18c91437 theme: "base",
bdb18c91438 themeVariables: {
bdb18c91439 primaryColor: "#e8f2ee",
bdb18c91440 primaryTextColor: "#2c2824",
bdb18c91441 primaryBorderColor: "#4d8a78",
bdb18c91442 lineColor: "#4d8a78",
bdb18c91443 secondaryColor: "#f2efe9",
bdb18c91444 tertiaryColor: "#eae6df",
bdb18c91445 noteBkgColor: "#e8f2ee",
bdb18c91446 noteTextColor: "#2d6b56",
bdb18c91447 noteBorderColor: "#b0d4c5",
bdb18c91448 textColor: "#2c2824",
bdb18c91449 mainBkg: "#f2efe9",
bdb18c91450 nodeBorder: "#d9d3ca",
bdb18c91451 clusterBkg: "#faf8f5",
bdb18c91452 clusterBorder: "#d9d3ca",
bdb18c91453 titleColor: "#2c2824",
bdb18c91454 edgeLabelBackground: "#faf8f5",
bdb18c91455 actorBkg: "#f2efe9",
bdb18c91456 actorBorder: "#4d8a78",
bdb18c91457 actorTextColor: "#2c2824",
bdb18c91458 labelColor: "#2c2824",
bdb18c91459 loopTextColor: "#4a4540",
bdb18c91460 activationBorderColor: "#4d8a78",
bdb18c91461 activationBkgColor: "#e8f2ee",
bdb18c91462 sequenceNumberColor: "#ffffff",
bdb18c91463 background: "#faf8f5",
bdb18c91464 fontFamily: "'Libre Caslon Text', Georgia, serif",
bdb18c91465 }
bdb18c91466});
bdb18c91467
bdb18c91468
bdb18c91469// ── Drawer ──
bdb18c91470
bdb18c91471function buildDrawer() {
bdb18c91472 const list = document.getElementById("drawerList");
bdb18c91473 list.innerHTML = "";
bdb18c91474
bdb18c91475 let lastSection = -1;
bdb18c91476
bdb18c91477 DIAGRAMS.forEach((d, i) => {
bdb18c91478 // Section header
bdb18c91479 if (d.section !== lastSection) {
bdb18c91480 lastSection = d.section;
bdb18c91481 const sec = document.createElement("div");
bdb18c91482 sec.className = "drawer-section";
bdb18c91483 sec.textContent = DIAGRAM_SECTIONS[d.section].label;
bdb18c91484 list.appendChild(sec);
bdb18c91485 }
bdb18c91486
bdb18c91487 const item = document.createElement("div");
bdb18c91488 item.className = "drawer-item" + (i === currentTab ? " active" : "");
bdb18c91489 item.dataset.index = i;
bdb18c91490 item.onclick = () => switchTab(i);
bdb18c91491
bdb18c91492 let html = `<span class="item-label">${escapeHtml(d.title)}</span>`;
bdb18c91493
bdb18c91494 const count = (allNotes[d.id] || []).length;
bdb18c91495 if (count > 0) html += `<span class="note-count">${count}</span>`;
bdb18c91496
bdb18c91497 // Show dots for users viewing this tab
bdb18c91498 const viewers = Object.values(allUsers).filter(u => u.activeTab === d.id && u.id !== socket?.id);
bdb18c91499 if (viewers.length > 0) {
bdb18c91500 html += '<span class="viewer-dots">';
bdb18c91501 viewers.forEach(v => {
bdb18c91502 html += `<span class="viewer-dot" style="background:${v.color}" title="${escapeHtml(v.name)}"></span>`;
bdb18c91503 });
bdb18c91504 html += '</span>';
bdb18c91505 }
bdb18c91506
bdb18c91507 item.innerHTML = html;
bdb18c91508 list.appendChild(item);
bdb18c91509 });
bdb18c91510}
bdb18c91511
bdb18c91512async function renderDiagram(index) {
bdb18c91513 const container = document.getElementById("mermaidContainer");
bdb18c91514 container.innerHTML = "";
bdb18c91515 const diagram = DIAGRAMS[index];
bdb18c91516
bdb18c91517 const div = document.createElement("div");
bdb18c91518 div.id = `mermaid-${index}`;
bdb18c91519 container.appendChild(div);
bdb18c91520
bdb18c91521 try {
bdb18c91522 const { svg } = await mermaid.render(`mermaid-svg-${index}-${Date.now()}`, diagram.code);
bdb18c91523 div.innerHTML = svg;
bdb18c91524
bdb18c91525 // Resize SVG so its natural size fills the pane at 100% zoom
bdb18c91526 const pane = document.getElementById("diagramPane");
bdb18c91527 const svgEl = div.querySelector("svg");
bdb18c91528 if (svgEl) {
bdb18c91529 const pw = pane.clientWidth - 80; // account for container padding
bdb18c91530 const ph = pane.clientHeight - 80;
bdb18c91531 const vb = svgEl.viewBox.baseVal;
bdb18c91532 const svgW = vb.width || svgEl.width.baseVal.value || parseFloat(svgEl.getAttribute("width"));
bdb18c91533 const svgH = vb.height || svgEl.height.baseVal.value || parseFloat(svgEl.getAttribute("height"));
bdb18c91534 if (svgW && svgH) {
bdb18c91535 const fitScale = Math.min(pw / svgW, ph / svgH);
bdb18c91536 const newW = svgW * fitScale;
bdb18c91537 const newH = svgH * fitScale;
bdb18c91538 svgEl.setAttribute("width", newW);
bdb18c91539 svgEl.setAttribute("height", newH);
bdb18c91540 svgEl.style.width = newW + "px";
bdb18c91541 svgEl.style.height = newH + "px";
bdb18c91542 }
bdb18c91543 }
cee484a1544 // Wire up node-focus click handlers
38b80fd1545 wireNodeFocus(div);
bdb18c91546 } catch (e) {
bdb18c91547 div.innerHTML = `<pre style="color:#a05050;padding:20px;font-family:'JetBrains Mono',Menlo,monospace;font-size:13px;">Error rendering diagram:\n${e.message}</pre>`;
bdb18c91548 }
bdb18c91549
bdb18c91550}
bdb18c91551
cee484a1552// ── Node Focus (dim unconnected elements on click) ──
cee484a1553
cee484a1554/**
38b80fd1555 * Build adjacency and node map directly from the rendered SVG.
38b80fd1556 * Uses data-start/data-end on edges (from our patched Mermaid) and
38b80fd1557 * the g.id on node groups — everything uses the same Mermaid internal IDs.
cee484a1558 */
38b80fd1559function buildGraphFromSvg(svgEl) {
cee484a1560 const adjacency = {};
38b80fd1561 const nodeMap = new Map();
cee484a1562
cee484a1563 const ensure = (id) => { if (!adjacency[id]) adjacency[id] = new Set(); };
cee484a1564 const addNode = (id, el) => {
cee484a1565 if (!nodeMap.has(id)) nodeMap.set(id, []);
cee484a1566 nodeMap.get(id).push(el);
cee484a1567 el.classList.add("node-dimable", "node-clickable");
cee484a1568 };
cee484a1569
38b80fd1570 // Collect nodes — extract the short ID that edges use in data-start/data-end.
38b80fd1571 // Flowchart: g.id "flowchart-Customer-0" → edge uses "Customer"
38b80fd1572 // ER: g.id "entity-policy-3" → edge uses "entity-policy-3"
cee484a1573 svgEl.querySelectorAll("g.node").forEach(g => {
38b80fd1574 if (!g.id) return;
38b80fd1575 const parts = g.id.split("-");
38b80fd1576 const short = parts.length >= 3 ? parts.slice(1, -1).join("-") : g.id;
38b80fd1577 addNode(short, g);
cee484a1578 });
38b80fd1579 svgEl.querySelectorAll("g[id^='entity-'], g.classGroup, g.statediagram-state").forEach(g => {
38b80fd1580 if (g.id) addNode(g.id, g);
cee484a1581 });
cee484a1582
38b80fd1583 // Build adjacency from data-start / data-end on edges
38b80fd1584 svgEl.querySelectorAll("[data-start][data-end]").forEach(el => {
38b80fd1585 const s = el.getAttribute("data-start");
38b80fd1586 const e = el.getAttribute("data-end");
38b80fd1587 if (s && e) {
38b80fd1588 ensure(s); ensure(e);
38b80fd1589 adjacency[s].add(e);
38b80fd1590 adjacency[e].add(s);
38b80fd1591 }
38b80fd1592 el.classList.add("node-dimable");
cee484a1593 });
cee484a1594
38b80fd1595 // Tag edge containers and clusters as dimable
cee484a1596 svgEl.querySelectorAll(".edgePath, .edgeLabel, .cluster").forEach(el => {
cee484a1597 el.classList.add("node-dimable");
cee484a1598 });
cee484a1599
38b80fd1600 // Tag loose path/line elements NOT inside a node, existing dimable, or marker defs
38b80fd1601 svgEl.querySelectorAll("path, line").forEach(el => {
38b80fd1602 if (el.closest("marker, defs")) return; // skip arrowhead marker definitions
38b80fd1603 if (!el.closest(".node-dimable") && !el.closest(".node-clickable")) {
cee484a1604 el.classList.add("node-dimable");
38b80fd1605 }
38b80fd1606 });
cee484a1607
38b80fd1608 // Match edge labels to edge paths using data-id → path ID.
38b80fd1609 // Edge paths have id="L_Start_End_0" and data-start/data-end.
38b80fd1610 // Edge labels contain g.label[data-id] where data-id matches path id.
38b80fd1611 // Copy data-start/data-end onto the edgeLabel group so highlighting works.
38b80fd1612 const pathDataById = new Map();
38b80fd1613 svgEl.querySelectorAll("[data-start][data-end]").forEach(el => {
38b80fd1614 if (el.id) pathDataById.set(el.id, { start: el.getAttribute("data-start"), end: el.getAttribute("data-end") });
38b80fd1615 });
38b80fd1616 svgEl.querySelectorAll("g.edgeLabel").forEach(label => {
38b80fd1617 const inner = label.querySelector("[data-id]");
38b80fd1618 const dataId = inner?.getAttribute("data-id");
38b80fd1619 if (dataId && pathDataById.has(dataId)) {
38b80fd1620 const { start, end } = pathDataById.get(dataId);
38b80fd1621 label.setAttribute("data-start", start);
38b80fd1622 label.setAttribute("data-end", end);
38b80fd1623 }
38b80fd1624 });
cee484a1625
38b80fd1626 return { adjacency, nodeMap };
cee484a1627}
cee484a1628
cee484a1629function clearNodeFocus() {
cee484a1630 selectedNodeId = null;
cee484a1631 const pane = document.getElementById("diagramPane");
cee484a1632 pane.classList.remove("node-focus");
cee484a1633 const svg = pane.querySelector("svg");
cee484a1634 if (svg) {
cee484a1635 svg.querySelectorAll(".node-highlight").forEach(el => el.classList.remove("node-highlight"));
cee484a1636 }
cee484a1637}
cee484a1638
cee484a1639function applyNodeFocus(svgEl, clickedNodeId, adjacency, nodeMap) {
cee484a1640 const pane = document.getElementById("diagramPane");
cee484a1641
cee484a1642 // Connected set = clicked + immediate neighbours
cee484a1643 const connected = new Set([clickedNodeId]);
cee484a1644 if (adjacency[clickedNodeId]) adjacency[clickedNodeId].forEach(id => connected.add(id));
cee484a1645
cee484a1646 // Clear old highlights
cee484a1647 svgEl.querySelectorAll(".node-highlight").forEach(el => el.classList.remove("node-highlight"));
cee484a1648
38b80fd1649 // Helper: highlight an element and all its node-dimable descendants
38b80fd1650 // (prevents CSS opacity compounding from dimming children)
38b80fd1651 const highlight = (el) => {
38b80fd1652 el.classList.add("node-highlight");
38b80fd1653 el.querySelectorAll(".node-dimable").forEach(child => child.classList.add("node-highlight"));
38b80fd1654 };
38b80fd1655
cee484a1656 // Highlight connected nodes
cee484a1657 for (const [id, elements] of nodeMap) {
38b80fd1658 if (connected.has(id)) elements.forEach(highlight);
cee484a1659 }
cee484a1660
38b80fd1661 // Highlight all elements with data-start/data-end where both endpoints are connected
38b80fd1662 svgEl.querySelectorAll("[data-start][data-end]").forEach(el => {
38b80fd1663 const s = el.getAttribute("data-start");
38b80fd1664 const e = el.getAttribute("data-end");
38b80fd1665 if (connected.has(s) && connected.has(e)) highlight(el);
cee484a1666 });
cee484a1667
cee484a1668 pane.classList.add("node-focus");
cee484a1669 selectedNodeId = clickedNodeId;
cee484a1670}
cee484a1671
cee484a1672/**
cee484a1673 * Attach click handlers to all nodes in the rendered SVG.
cee484a1674 */
38b80fd1675function wireNodeFocus(container) {
cee484a1676 const svgEl = container.querySelector("svg");
cee484a1677 if (!svgEl) return;
cee484a1678
38b80fd1679 const { adjacency, nodeMap } = buildGraphFromSvg(svgEl);
38b80fd1680 if (Object.keys(adjacency).length === 0) return;
cee484a1681
38b80fd1682 for (const [nodeId, elements] of nodeMap) {
cee484a1683 elements.forEach(el => {
cee484a1684 el.addEventListener("click", (e) => {
cee484a1685 e.stopPropagation();
cee484a1686 if (didDrag) return;
38b80fd1687 if (selectedNodeId === nodeId) {
cee484a1688 clearNodeFocus();
cee484a1689 } else {
38b80fd1690 applyNodeFocus(svgEl, nodeId, adjacency, nodeMap);
cee484a1691 }
cee484a1692 });
cee484a1693 });
cee484a1694 }
cee484a1695}
cee484a1696
bdb18c91697function renderNotesList(diagramId) {
bdb18c91698 const list = document.getElementById("notesList");
bdb18c91699 const notes = allNotes[diagramId] || [];
bdb18c91700 const countEl = document.getElementById("notesCount");
bdb18c91701 countEl.textContent = notes.length > 0 ? `(${notes.length})` : "";
bdb18c91702
bdb18c91703 if (notes.length === 0) {
bdb18c91704 list.innerHTML = `<div class="empty-notes">
bdb18c91705 No notes yet for this diagram.<br>Click on the diagram or type below to add one.
bdb18c91706 </div>`;
bdb18c91707 return;
bdb18c91708 }
bdb18c91709
bdb18c91710 list.innerHTML = "";
be59e711711 notes.forEach((note) => {
bdb18c91712 const card = document.createElement("div");
bdb18c91713 card.className = "note-card";
bdb18c91714 card.id = `note-${note.id}`;
be59e711715
be59e711716 const meta = document.createElement("div");
be59e711717 meta.className = "note-meta";
be59e711718 const authorSpan = document.createElement("span");
be59e711719 authorSpan.className = "note-author";
be59e711720 authorSpan.textContent = note.author;
be59e711721 const timeSpan = document.createElement("span");
be59e711722 timeSpan.textContent = formatTime(note.timestamp);
be59e711723 meta.appendChild(authorSpan);
be59e711724 meta.appendChild(timeSpan);
be59e711725
be59e711726 const textEl = document.createElement("div");
be59e711727 textEl.className = "note-text";
be59e711728 textEl.textContent = note.text;
be59e711729
be59e711730 const actions = document.createElement("div");
be59e711731 actions.className = "note-actions";
be59e711732
be59e711733 const editBtn = document.createElement("button");
be59e711734 editBtn.className = "note-action-btn";
be59e711735 editBtn.title = "Edit";
be59e711736 editBtn.innerHTML = "&#9998;";
be59e711737 editBtn.addEventListener("click", () => editNote(note.id, note.diagramId, card));
be59e711738
be59e711739 const deleteBtn = document.createElement("button");
be59e711740 deleteBtn.className = "note-action-btn";
be59e711741 deleteBtn.title = "Delete";
be59e711742 deleteBtn.innerHTML = "&#10005;";
be59e711743 deleteBtn.addEventListener("click", () => deleteNote(note.id, note.diagramId));
be59e711744
be59e711745 actions.appendChild(editBtn);
be59e711746 actions.appendChild(deleteBtn);
be59e711747
be59e711748 card.appendChild(meta);
be59e711749 card.appendChild(textEl);
be59e711750 card.appendChild(actions);
bdb18c91751 list.appendChild(card);
bdb18c91752 });
bdb18c91753}
bdb18c91754
bdb18c91755function highlightNoteCard(noteId) {
bdb18c91756 const card = document.getElementById(`note-${noteId}`);
bdb18c91757 if (!card) return;
bdb18c91758 card.scrollIntoView({ behavior: "smooth", block: "center" });
bdb18c91759 card.style.outline = "2px solid var(--note-border)";
bdb18c91760 setTimeout(() => card.style.outline = "", 2000);
bdb18c91761}
bdb18c91762
bdb18c91763async function switchTab(index) {
cee484a1764 clearNodeFocus();
bdb18c91765 currentTab = index;
bdb18c91766 buildDrawer();
bdb18c91767 await renderDiagram(index);
bdb18c91768 renderNotesList(DIAGRAMS[index].id);
bdb18c91769
bdb18c91770 // Rebuild editor with this diagram's Yjs doc
bdb18c91771 rebuildEditorForDiagram(DIAGRAMS[index].id, DIAGRAMS[index].code);
bdb18c91772
bdb18c91773 setEditorDirty(false);
bdb18c91774 const status = document.getElementById("codePaneStatus");
bdb18c91775 if (status) { status.textContent = ""; status.className = "code-pane-status"; }
bdb18c91776 // Fit diagram to view on tab switch
bdb18c91777 requestAnimationFrame(() => zoomReset());
bdb18c91778
bdb18c91779 // Scroll active drawer item into view
bdb18c91780 const activeItem = document.querySelector(".drawer-item.active");
bdb18c91781 if (activeItem) activeItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
bdb18c91782
bdb18c91783 // Notify server
bdb18c91784 if (socket) {
bdb18c91785 socket.emit("cursor-move", { x: 0, y: 0, activeTab: DIAGRAMS[index].id });
bdb18c91786 }
bdb18c91787}
bdb18c91788
bdb18c91789// ── Room / Socket ──
2a9592c1790async function joinRoom(owner, repo, token, user) {
2a9592c1791 currentOwner = owner;
2a9592c1792 currentRepo = repo;
2a9592c1793 myName = user.display_name || user.username || "Anonymous";
2a9592c1794 roomId = `${owner}/${repo}`;
2a9592c1795
1e755c01796 if (DIAGRAMS.length === 0) {
2a9592c1797 await loadDiagramsFromServer(owner, repo);
1e755c01798 buildDrawer();
1e755c01799 }
bdb18c91800
e629dcb1801 setBreadcrumbs(owner, repo);
bdb18c91802
2a9592c1803 // Connect to the /collab namespace with auth token
2a9592c1804 socket = io("/collab", { auth: { token } });
bdb18c91805
bdb18c91806 socket.on("connect", () => {
2a9592c1807 socket.emit("join-room", { owner, repo });
2a9592c1808 });
2a9592c1809
2a9592c1810 socket.on("connect_error", (err) => {
2a9592c1811 if (err.message === "unauthorized") {
515cc681812 console.warn("[collab] Socket auth failed — running in read-only mode");
515cc681813 socket.disconnect();
2a9592c1814 }
bdb18c91815 });
bdb18c91816
bdb18c91817 socket.on("room-state", (state) => {
bdb18c91818 allNotes = state.notes || {};
bdb18c91819 allUsers = state.users || {};
bdb18c91820 buildDrawer();
bdb18c91821 switchTab(0);
bdb18c91822 updatePresence();
bdb18c91823 });
bdb18c91824
bdb18c91825 socket.on("users-updated", (users) => {
bdb18c91826 allUsers = users;
bdb18c91827 updatePresence();
bdb18c91828 buildDrawer();
bdb18c91829 });
bdb18c91830
bdb18c91831 socket.on("cursor-updated", ({ userId, x, y, activeTab, name, color }) => {
bdb18c91832 // Update user's active tab in allUsers so drawer dots reflect it
bdb18c91833 if (allUsers[userId]) {
bdb18c91834 const prev = allUsers[userId].activeTab;
bdb18c91835 allUsers[userId].activeTab = activeTab;
bdb18c91836 if (prev !== activeTab) buildDrawer();
bdb18c91837 }
bdb18c91838 updateRemoteCursor(userId, x, y, activeTab, name, color);
bdb18c91839 });
bdb18c91840
bdb18c91841 socket.on("cursor-removed", ({ userId }) => {
bdb18c91842 removeRemoteCursor(userId);
bdb18c91843 });
bdb18c91844
bdb18c91845 // ── Yjs sync over socket.io ──
bdb18c91846 socket.on("yjs-sync", async ({ diagramId, update }) => {
bdb18c91847 const Y = window._Y;
bdb18c91848 if (!Y || !ydocs[diagramId]) return;
bdb18c91849 const ydoc = ydocs[diagramId];
bdb18c91850 const buf = Uint8Array.from(atob(update), c => c.charCodeAt(0));
bdb18c91851 Y.applyUpdate(ydoc, buf, "remote");
bdb18c91852 // If server had no content, initialize with default code
bdb18c91853 const ytext = ydoc.getText("code");
bdb18c91854 if (ytext.length === 0 && ydoc._defaultCode) {
bdb18c91855 ytext.insert(0, ydoc._defaultCode);
bdb18c91856 delete ydoc._defaultCode;
bdb18c91857 }
bdb18c91858 // Re-render diagram from synced content
bdb18c91859 const idx = DIAGRAMS.findIndex(d => d.id === diagramId);
bdb18c91860 if (idx !== -1 && currentTab === idx) {
bdb18c91861 const code = ytext.toString();
bdb18c91862 DIAGRAMS[idx].code = code;
bdb18c91863 await renderDiagram(idx);
bdb18c91864 requestAnimationFrame(() => zoomReset());
bdb18c91865 }
bdb18c91866 });
bdb18c91867
bdb18c91868 socket.on("yjs-update", ({ diagramId, update }) => {
bdb18c91869 const Y = window._Y;
bdb18c91870 if (!Y || !ydocs[diagramId]) return;
bdb18c91871 const ydoc = ydocs[diagramId];
bdb18c91872 const buf = Uint8Array.from(atob(update), c => c.charCodeAt(0));
bdb18c91873 Y.applyUpdate(ydoc, buf, "remote");
bdb18c91874 });
bdb18c91875
bdb18c91876 // Remote user hit Run — re-render the diagram on our side
bdb18c91877 socket.on("diagram-code", async ({ diagramId, code }) => {
bdb18c91878 const idx = DIAGRAMS.findIndex(d => d.id === diagramId);
bdb18c91879 if (idx === -1) return;
bdb18c91880 DIAGRAMS[idx].code = code;
bdb18c91881 if (currentTab === idx) {
bdb18c91882 await renderDiagram(idx);
bdb18c91883 requestAnimationFrame(() => zoomReset());
bdb18c91884 setEditorDirty(false);
bdb18c91885 const status = document.getElementById("codePaneStatus");
bdb18c91886 status.textContent = "\u2713 synced";
bdb18c91887 status.className = "code-pane-status ok";
bdb18c91888 }
bdb18c91889 });
bdb18c91890
bdb18c91891 socket.on("note-added", (note) => {
bdb18c91892 if (!allNotes[note.diagramId]) allNotes[note.diagramId] = [];
bdb18c91893 allNotes[note.diagramId].push(note);
bdb18c91894 if (DIAGRAMS[currentTab].id === note.diagramId) {
bdb18c91895 renderNotesList(note.diagramId);
bdb18c91896 }
bdb18c91897 buildDrawer();
bdb18c91898 });
bdb18c91899
bdb18c91900 socket.on("note-edited", ({ noteId, diagramId, text, editedAt }) => {
bdb18c91901 const notes = allNotes[diagramId] || [];
bdb18c91902 const note = notes.find(n => n.id === noteId);
bdb18c91903 if (note) {
bdb18c91904 note.text = text;
bdb18c91905 note.editedAt = editedAt;
bdb18c91906 }
bdb18c91907 if (DIAGRAMS[currentTab].id === diagramId) {
bdb18c91908 renderNotesList(diagramId);
bdb18c91909 }
bdb18c91910 });
bdb18c91911
bdb18c91912 socket.on("note-deleted", ({ noteId, diagramId }) => {
bdb18c91913 if (allNotes[diagramId]) {
bdb18c91914 allNotes[diagramId] = allNotes[diagramId].filter(n => n.id !== noteId);
bdb18c91915 }
bdb18c91916 if (DIAGRAMS[currentTab].id === diagramId) {
bdb18c91917 renderNotesList(diagramId);
bdb18c91918 }
bdb18c91919 buildDrawer();
bdb18c91920 });
bdb18c91921}
bdb18c91922
bdb18c91923// ── Keyboard navigation ──
bdb18c91924document.addEventListener("keydown", (e) => {
bdb18c91925 // Ctrl+Enter or Cmd+Enter = commit and render diagram
bdb18c91926 if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
bdb18c91927 e.preventDefault();
bdb18c91928 commitDiagramCode();
bdb18c91929 return;
bdb18c91930 }
bdb18c91931
bdb18c91932 // Shift+Alt+F = format (works even inside the editor)
bdb18c91933 if (e.key === "F" && e.shiftKey && e.altKey) {
bdb18c91934 e.preventDefault();
bdb18c91935 formatMermaid();
bdb18c91936 return;
bdb18c91937 }
bdb18c91938
bdb18c91939 // Don't intercept when typing in inputs
bdb18c91940 if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.closest(".cm-editor")) return;
bdb18c91941
bdb18c91942 if (e.key === "ArrowDown" || e.key === "j") {
bdb18c91943 e.preventDefault();
bdb18c91944 if (currentTab < DIAGRAMS.length - 1) switchTab(currentTab + 1);
bdb18c91945 } else if (e.key === "ArrowUp" || e.key === "k") {
bdb18c91946 e.preventDefault();
bdb18c91947 if (currentTab > 0) switchTab(currentTab - 1);
bdb18c91948 }
bdb18c91949});
bdb18c91950
bdb18c91951// ── Cursor tracking ──
bdb18c91952document.getElementById("diagramPane").addEventListener("mousemove", (e) => {
bdb18c91953 if (!socket) return;
bdb18c91954 const pane = document.getElementById("diagramPane");
bdb18c91955 const rect = pane.getBoundingClientRect();
bdb18c91956 // Send content-space coords so they're zoom-independent
bdb18c91957 socket.emit("cursor-move", {
bdb18c91958 x: (e.clientX - rect.left - panZoom.x) / panZoom.scale,
bdb18c91959 y: (e.clientY - rect.top - panZoom.y) / panZoom.scale,
bdb18c91960 activeTab: DIAGRAMS[currentTab].id,
bdb18c91961 });
bdb18c91962});
bdb18c91963
bdb18c91964function updateRemoteCursor(userId, x, y, activeTab, name, color) {
bdb18c91965 if (activeTab !== DIAGRAMS[currentTab].id) {
bdb18c91966 removeRemoteCursor(userId);
bdb18c91967 return;
bdb18c91968 }
bdb18c91969
bdb18c91970 let el = remoteCursors[userId];
bdb18c91971 if (!el) {
bdb18c91972 el = document.createElement("div");
bdb18c91973 el.className = "remote-cursor";
bdb18c91974 el.innerHTML = `
bdb18c91975 <span class="cursor-dot" style="background:${color}"></span>
bdb18c91976 <span class="cursor-label" style="background:${color}">${escapeHtml(name)}</span>
bdb18c91977 `;
bdb18c91978 document.body.appendChild(el);
bdb18c91979 remoteCursors[userId] = el;
bdb18c91980 }
bdb18c91981
bdb18c91982 // Briefly show the name label on movement
bdb18c91983 el.classList.add("recent");
bdb18c91984 clearTimeout(el._labelTimer);
bdb18c91985 el._labelTimer = setTimeout(() => el.classList.remove("recent"), 2000);
bdb18c91986
bf1f9951987 // Store content-space coords so we can reposition on zoom/pan
bf1f9951988 el._contentX = x;
bf1f9951989 el._contentY = y;
bf1f9951990
bf1f9951991 positionCursorElement(el);
bf1f9951992}
bf1f9951993
bf1f9951994function positionCursorElement(el) {
bdb18c91995 const pane = document.getElementById("diagramPane");
bdb18c91996 const rect = pane.getBoundingClientRect();
bf1f9951997 const screenX = rect.left + panZoom.x + el._contentX * panZoom.scale;
bf1f9951998 const screenY = rect.top + panZoom.y + el._contentY * panZoom.scale;
bdb18c91999
bdb18c92000 if (screenX < rect.left || screenX > rect.right || screenY < rect.top || screenY > rect.bottom) {
bdb18c92001 el.style.display = "none";
bdb18c92002 } else {
bdb18c92003 el.style.display = "flex";
bdb18c92004 el.style.left = screenX - 5 + "px";
bdb18c92005 el.style.top = screenY - 5 + "px";
bdb18c92006 }
bdb18c92007}
bdb18c92008
bf1f9952009function repositionRemoteCursors() {
bf1f9952010 for (const el of Object.values(remoteCursors)) {
bf1f9952011 if (el._contentX != null) positionCursorElement(el);
bf1f9952012 }
bf1f9952013}
bf1f9952014
bdb18c92015function removeRemoteCursor(userId) {
bdb18c92016 if (remoteCursors[userId]) {
bdb18c92017 remoteCursors[userId].remove();
bdb18c92018 delete remoteCursors[userId];
bdb18c92019 }
bdb18c92020}
bdb18c92021
bdb18c92022// ── Presence bar ──
bdb18c92023function updatePresence() {
bdb18c92024 const bar = document.getElementById("presenceBar");
bdb18c92025 bar.innerHTML = "";
bdb18c92026 Object.values(allUsers).forEach(u => {
bdb18c92027 const dot = document.createElement("div");
bdb18c92028 dot.className = "presence-dot";
bdb18c92029 dot.style.background = u.color;
bdb18c92030 dot.textContent = (u.name || "?")[0].toUpperCase();
bdb18c92031 dot.innerHTML += `<span class="tooltip">${escapeHtml(u.name)}</span>`;
bdb18c92032 bar.appendChild(dot);
bdb18c92033 });
bdb18c92034}
bdb18c92035
bdb18c92036// ── Notes ──
bdb18c92037// ── Code pane (CodeMirror 6) ──
bdb18c92038let codeRenderTimeout = null;
bdb18c92039let cmEditor = null;
bdb18c92040let cmReady = false;
bdb18c92041let editorFontSize = 12;
bdb18c92042
bdb18c92043function changeEditorFontSize(delta) {
bdb18c92044 editorFontSize = Math.max(8, Math.min(24, editorFontSize + delta));
bdb18c92045 if (cmEditor) {
bdb18c92046 cmEditor.dom.style.fontSize = editorFontSize + "px";
bdb18c92047 cmEditor.requestMeasure();
bdb18c92048 }
bdb18c92049}
bdb18c92050
bdb18c92051function toggleCodePane() {
bdb18c92052 const pane = document.getElementById("codePane");
bdb18c92053 pane.classList.toggle("open");
bdb18c92054 // Clear any inline width from manual resize so CSS takes over
bdb18c92055 pane.style.width = "";
bdb18c92056 if (pane.classList.contains("open")) {
bdb18c92057 setTimeout(() => zoomReset(), 220);
bdb18c92058 }
bdb18c92059}
bdb18c92060
bdb18c92061// ── Yjs document management ──
bdb18c92062const ydocs = {}; // { diagramId: Y.Doc }
bdb18c92063let currentYtext = null;
bdb18c92064let currentUndoManager = null;
bdb18c92065
bdb18c92066function uint8ToBase64(uint8) {
bdb18c92067 let binary = "";
bdb18c92068 for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]);
bdb18c92069 return btoa(binary);
bdb18c92070}
bdb18c92071
bdb18c92072function getOrCreateYDoc(diagramId) {
bdb18c92073 const Y = window._Y;
bdb18c92074 if (!Y) return null;
bdb18c92075 if (!ydocs[diagramId]) {
bdb18c92076 const ydoc = new Y.Doc();
bdb18c92077 ydocs[diagramId] = ydoc;
bdb18c92078 // When Yjs text changes locally, send update to server
bdb18c92079 ydoc.on("update", (update, origin) => {
bdb18c92080 if (origin === "remote") return;
bdb18c92081 if (socket) {
bdb18c92082 socket.emit("yjs-update", { diagramId, update: uint8ToBase64(update) });
bdb18c92083 }
bdb18c92084 });
bdb18c92085 }
bdb18c92086 return ydocs[diagramId];
bdb18c92087}
bdb18c92088
bdb18c92089function syncYDoc(diagramId, defaultCode) {
bdb18c92090 if (!socket) return;
bdb18c92091 // Store default code so we can initialize if server has no state
bdb18c92092 if (defaultCode) ydocs[diagramId]._defaultCode = defaultCode;
bdb18c92093 socket.emit("yjs-sync", { diagramId });
bdb18c92094}
bdb18c92095
bdb18c92096let _suppressEditorDirty = false;
bdb18c92097
bdb18c92098function updateCodePane(code) {
bdb18c92099 if (cmEditor) {
bdb18c92100 const cur = cmEditor.state.doc.toString();
bdb18c92101 if (cur !== code) {
bdb18c92102 _suppressEditorDirty = true;
bdb18c92103 cmEditor.dispatch({
bdb18c92104 changes: { from: 0, to: cur.length, insert: code }
bdb18c92105 });
bdb18c92106 _suppressEditorDirty = false;
bdb18c92107 }
bdb18c92108 }
bdb18c92109}
bdb18c92110
bdb18c92111function rebuildEditorForDiagram(diagramId, defaultCode) {
bdb18c92112 if (!window._cmCreateEditor) return;
bdb18c92113 const container = document.getElementById("codePaneEditor");
bdb18c92114
bdb18c92115 // Destroy previous editor
bdb18c92116 if (cmEditor) {
bdb18c92117 cmEditor.destroy();
bdb18c92118 cmEditor = null;
bdb18c92119 }
bdb18c92120
bdb18c92121 const Y = window._Y;
bdb18c92122 if (Y && socket) {
bdb18c92123 // Collaborative mode: use Yjs
bdb18c92124 const ydoc = getOrCreateYDoc(diagramId);
bdb18c92125 const ytext = ydoc.getText("code");
bdb18c92126 currentYtext = ytext;
bdb18c92127 currentUndoManager = new Y.UndoManager(ytext);
bdb18c92128
bdb18c92129 cmEditor = window._cmCreateEditor(container, "", (code) => {
bdb18c92130 if (_suppressEditorDirty) return;
bdb18c92131 setEditorDirty(true);
bdb18c92132 clearTimeout(codeRenderTimeout);
bdb18c92133 codeRenderTimeout = setTimeout(() => validateMermaidCode(code), 500);
bdb18c92134 }, ytext, currentUndoManager);
bdb18c92135
bdb18c92136 // Request latest state from server; pass default code for first-time init
bdb18c92137 syncYDoc(diagramId, defaultCode);
bdb18c92138 } else {
bdb18c92139 // Standalone mode: no Yjs
bdb18c92140 currentYtext = null;
bdb18c92141 currentUndoManager = null;
bdb18c92142 cmEditor = window._cmCreateEditor(container, defaultCode || "", (code) => {
bdb18c92143 if (_suppressEditorDirty) return;
bdb18c92144 setEditorDirty(true);
bdb18c92145 clearTimeout(codeRenderTimeout);
bdb18c92146 codeRenderTimeout = setTimeout(() => validateMermaidCode(code), 500);
bdb18c92147 });
bdb18c92148 }
bdb18c92149 cmReady = true;
bdb18c92150}
bdb18c92151
bdb18c92152// ── Mermaid auto-formatter ──
bdb18c92153function formatMermaid() {
bdb18c92154 if (!cmEditor) return;
bdb18c92155 const src = cmEditor.state.doc.toString();
bdb18c92156 const formatted = formatMermaidCode(src);
bdb18c92157 if (formatted !== src) {
bdb18c92158 cmEditor.dispatch({
bdb18c92159 changes: { from: 0, to: src.length, insert: formatted }
bdb18c92160 });
bdb18c92161 }
bdb18c92162}
bdb18c92163
bdb18c92164function formatMermaidCode(src) {
bdb18c92165 const lines = src.split("\n");
bdb18c92166 const out = [];
bdb18c92167 const INDENT = " "; // 4 spaces
bdb18c92168 let depth = 0;
bdb18c92169
bdb18c92170 // Keywords that open a block (increase indent after this line)
bdb18c92171 const openers = /^(subgraph|state|loop|alt|par|critical|opt)\b/;
bdb18c92172 // Keywords that close a block (decrease indent before this line)
bdb18c92173 const closers = /^end\b/;
bdb18c92174 // "else" is at same level as the opener, then content indented again
bdb18c92175 const midBlock = /^(else|break)\b/;
bdb18c92176 // Brace openers for ER/class: "entity_name {" or "class Foo {"
bdb18c92177 const braceOpen = /\{\s*$/;
bdb18c92178 const braceClose = /^\s*\}/;
bdb18c92179 // Diagram type declarations stay at column 0
bdb18c92180 const diagramType = /^(graph|flowchart|erDiagram|classDiagram|stateDiagram-v2|stateDiagram|sequenceDiagram|gantt|pie|gitGraph|mindmap|timeline|journey|quadrantChart|sankey|xychart)\b/;
bdb18c92181
bdb18c92182 for (let i = 0; i < lines.length; i++) {
bdb18c92183 const trimmed = lines[i].trim();
bdb18c92184
bdb18c92185 // Blank lines pass through
bdb18c92186 if (trimmed === "") { out.push(""); continue; }
bdb18c92187
bdb18c92188 // Comments stay at current indent level
bdb18c92189 if (trimmed.startsWith("%%")) {
bdb18c92190 out.push(INDENT.repeat(depth) + trimmed);
bdb18c92191 continue;
bdb18c92192 }
bdb18c92193
bdb18c92194 // Diagram type at column 0, reset depth
bdb18c92195 if (diagramType.test(trimmed)) {
bdb18c92196 depth = 0;
bdb18c92197 out.push(trimmed);
bdb18c92198 continue;
bdb18c92199 }
bdb18c92200
bdb18c92201 // Brace close: dedent before
bdb18c92202 if (braceClose.test(trimmed)) {
bdb18c92203 depth = Math.max(0, depth - 1);
bdb18c92204 out.push(INDENT.repeat(depth) + trimmed);
bdb18c92205 continue;
bdb18c92206 }
bdb18c92207
bdb18c92208 // "end" keyword: dedent before
bdb18c92209 if (closers.test(trimmed)) {
bdb18c92210 depth = Math.max(0, depth - 1);
bdb18c92211 out.push(INDENT.repeat(depth) + trimmed);
bdb18c92212 continue;
bdb18c92213 }
bdb18c92214
bdb18c92215 // Mid-block keywords (else): dedent for this line, re-indent after
bdb18c92216 if (midBlock.test(trimmed)) {
bdb18c92217 out.push(INDENT.repeat(Math.max(0, depth - 1)) + trimmed);
bdb18c92218 continue;
bdb18c92219 }
bdb18c92220
bdb18c92221 // Normal line at current depth
bdb18c92222 out.push(INDENT.repeat(depth) + trimmed);
bdb18c92223
bdb18c92224 // After outputting, check if this line opens a block
bdb18c92225 if (openers.test(trimmed) || braceOpen.test(trimmed)) {
bdb18c92226 depth++;
bdb18c92227 }
bdb18c92228 }
bdb18c92229
bdb18c92230 return out.join("\n");
bdb18c92231}
bdb18c92232
bdb18c92233let editorDirty = false;
bdb18c92234
bdb18c92235function setEditorDirty(dirty) {
bdb18c92236 editorDirty = dirty;
bdb18c92237 const btn = document.getElementById("codePanePlay");
bdb18c92238 if (btn) btn.classList.toggle("dirty", dirty);
bdb18c92239}
bdb18c92240
bdb18c92241async function validateMermaidCode(code) {
bdb18c92242 const status = document.getElementById("codePaneStatus");
bdb18c92243 const diag = document.getElementById("codePaneDiagnostics");
bdb18c92244 try {
bdb18c92245 await mermaid.parse(code);
bdb18c92246 status.textContent = "\u2713 valid";
bdb18c92247 status.className = "code-pane-status ok";
bdb18c92248 diag.className = "code-pane-diagnostics";
bdb18c92249 diag.textContent = "";
bdb18c92250 return true;
bdb18c92251 } catch (e) {
bdb18c92252 const raw = e.message || String(e);
bdb18c92253 status.textContent = "\u2717 error";
bdb18c92254 status.className = "code-pane-status error";
bdb18c92255
bdb18c92256 // Extract line/col from mermaid error (formats vary)
bdb18c92257 let line = null, col = null;
bdb18c92258 const lineMatch = raw.match(/line\s*(\d+)/i) || raw.match(/at line (\d+)/i);
bdb18c92259 const colMatch = raw.match(/col(?:umn)?\s*(\d+)/i);
bdb18c92260 if (lineMatch) line = parseInt(lineMatch[1]);
bdb18c92261 if (colMatch) col = parseInt(colMatch[1]);
bdb18c92262
bdb18c92263 // Also check e.hash (jison parser data)
bdb18c92264 if (!line && e.hash) {
bdb18c92265 if (e.hash.line != null) line = e.hash.line + 1;
bdb18c92266 if (e.hash.loc) {
bdb18c92267 line = line || (e.hash.loc.first_line);
bdb18c92268 col = col || (e.hash.loc.first_column + 1);
bdb18c92269 }
bdb18c92270 }
bdb18c92271
bdb18c92272 // Build diagnostics message
bdb18c92273 let msg = "";
bdb18c92274 if (line) {
bdb18c92275 msg += "Line " + line;
bdb18c92276 if (col) msg += ":" + col;
bdb18c92277 msg += " \u2014 ";
bdb18c92278 }
bdb18c92279 // Clean up the error message
bdb18c92280 let cleaned = raw.replace(/^Error:\s*/i, "").replace(/Syntax error in.*?:\s*/i, "");
bdb18c92281 // Take the first meaningful line
bdb18c92282 const lines = cleaned.split("\n").filter(l => l.trim());
bdb18c92283 msg += lines[0] || cleaned;
bdb18c92284
bdb18c92285 // If we can, show the offending source line
bdb18c92286 if (line && code) {
bdb18c92287 const srcLines = code.split("\n");
bdb18c92288 const srcLine = srcLines[line - 1];
bdb18c92289 if (srcLine != null) {
bdb18c92290 msg += "\n\n " + line + " \u2502 " + srcLine;
bdb18c92291 if (col) {
bdb18c92292 msg += "\n " + " ".repeat(String(line).length) + " ".repeat(col) + "\u2191";
bdb18c92293 }
bdb18c92294 }
bdb18c92295 }
bdb18c92296
bdb18c92297 diag.textContent = msg;
bdb18c92298 diag.className = "code-pane-diagnostics visible";
bdb18c92299
bdb18c92300 // Jump editor cursor to error line
bdb18c92301 if (line && cmEditor) {
bdb18c92302 try {
bdb18c92303 const pos = cmEditor.state.doc.line(line);
bdb18c92304 cmEditor.dispatch({
bdb18c92305 selection: { anchor: pos.from + (col ? col - 1 : 0) },
bdb18c92306 scrollIntoView: true
bdb18c92307 });
bdb18c92308 } catch (_) {}
bdb18c92309 }
bdb18c92310
bdb18c92311 return false;
bdb18c92312 }
bdb18c92313}
bdb18c92314
bdb18c92315async function commitDiagramCode() {
bdb18c92316 if (!cmEditor) return;
bdb18c92317 const code = cmEditor.state.doc.toString();
bdb18c92318 const valid = await validateMermaidCode(code);
bdb18c92319 if (!valid) return;
bdb18c92320 DIAGRAMS[currentTab].code = code;
bdb18c92321 await renderDiagram(currentTab);
bdb18c92322 requestAnimationFrame(() => zoomReset());
bdb18c92323 setEditorDirty(false);
bdb18c92324 // Code is already synced via Yjs — also broadcast render trigger to others
bdb18c92325 if (socket) {
bdb18c92326 socket.emit("diagram-code", { diagramId: DIAGRAMS[currentTab].id, code });
bdb18c92327 }
bdb18c92328}
bdb18c92329
bdb18c92330function initCodeMirror() {
bdb18c92331 if (cmEditor || !window._cmCreateEditor) return;
bdb18c92332 rebuildEditorForDiagram(DIAGRAMS[currentTab]?.id, DIAGRAMS[currentTab]?.code || "");
bdb18c92333}
bdb18c92334
bdb18c92335// Init CM when the ESM module finishes loading
bdb18c92336if (window._cmCreateEditor) {
bdb18c92337 initCodeMirror();
bdb18c92338} else {
bdb18c92339 window.addEventListener("cm-ready", () => initCodeMirror());
bdb18c92340}
bdb18c92341
bdb18c92342// Resize handle — code pane
bdb18c92343(function() {
bdb18c92344 const handle = document.getElementById("codePaneResize");
bdb18c92345 const pane = document.getElementById("codePane");
bdb18c92346 let startX, startWidth;
bdb18c92347
bdb18c92348 handle.addEventListener("mousedown", (e) => {
bdb18c92349 e.preventDefault();
bdb18c92350 startX = e.clientX;
bdb18c92351 startWidth = pane.offsetWidth;
bdb18c92352 handle.classList.add("dragging");
bdb18c92353 document.addEventListener("mousemove", onDrag);
bdb18c92354 document.addEventListener("mouseup", onStop);
bdb18c92355 });
bdb18c92356
bdb18c92357 handle.addEventListener("dblclick", () => {
bdb18c92358 pane.style.width = "";
bdb18c92359 setTimeout(() => zoomReset(), 50);
bdb18c92360 });
bdb18c92361
bdb18c92362 function onDrag(e) {
bdb18c92363 const newWidth = Math.max(200, startWidth + (e.clientX - startX));
bdb18c92364 pane.style.width = newWidth + "px";
bdb18c92365 }
bdb18c92366
bdb18c92367 function onStop() {
bdb18c92368 handle.classList.remove("dragging");
bdb18c92369 document.removeEventListener("mousemove", onDrag);
bdb18c92370 document.removeEventListener("mouseup", onStop);
bdb18c92371 setTimeout(() => zoomReset(), 50);
bdb18c92372 }
bdb18c92373})();
bdb18c92374
bdb18c92375// Resize handle — notes sidebar
bdb18c92376(function() {
bdb18c92377 const handle = document.getElementById("notesResize");
bdb18c92378 const pane = document.getElementById("notesSidebar");
bdb18c92379 let startX, startWidth;
bdb18c92380
bdb18c92381 handle.addEventListener("mousedown", (e) => {
bdb18c92382 e.preventDefault();
bdb18c92383 startX = e.clientX;
bdb18c92384 startWidth = pane.offsetWidth;
bdb18c92385 handle.classList.add("dragging");
bdb18c92386 document.addEventListener("mousemove", onDrag);
bdb18c92387 document.addEventListener("mouseup", onStop);
bdb18c92388 });
bdb18c92389
bdb18c92390 handle.addEventListener("dblclick", () => {
bdb18c92391 pane.style.width = "";
bdb18c92392 setTimeout(() => zoomReset(), 50);
bdb18c92393 });
bdb18c92394
bdb18c92395 function onDrag(e) {
bdb18c92396 const newWidth = Math.max(180, startWidth - (e.clientX - startX));
bdb18c92397 pane.style.width = newWidth + "px";
bdb18c92398 }
bdb18c92399
bdb18c92400 function onStop() {
bdb18c92401 handle.classList.remove("dragging");
bdb18c92402 document.removeEventListener("mousemove", onDrag);
bdb18c92403 document.removeEventListener("mouseup", onStop);
bdb18c92404 setTimeout(() => zoomReset(), 50);
bdb18c92405 }
bdb18c92406})();
bdb18c92407
bdb18c92408function submitNote() {
bdb18c92409 const input = document.getElementById("noteInput");
bdb18c92410 const text = input.value.trim();
bdb18c92411 if (!text || !socket) return;
bdb18c92412
bdb18c92413 const diagram = DIAGRAMS[currentTab];
bdb18c92414
bdb18c92415 socket.emit("add-note", {
bdb18c92416 text,
bdb18c92417 diagramId: diagram.id,
bdb18c92418 diagramTitle: diagram.title,
bdb18c92419 });
bdb18c92420
bdb18c92421 input.value = "";
bdb18c92422}
bdb18c92423
bdb18c92424const noteInputEl = document.getElementById("noteInput");
bdb18c92425noteInputEl.addEventListener("keydown", (e) => {
bdb18c92426 if (e.key === "Enter" && !e.shiftKey) {
bdb18c92427 e.preventDefault();
bdb18c92428 submitNote();
bdb18c92429 }
bdb18c92430});
bdb18c92431noteInputEl.addEventListener("input", () => {
bdb18c92432 noteInputEl.style.height = "auto";
bdb18c92433 noteInputEl.style.height = Math.min(noteInputEl.scrollHeight, 80) + "px";
bdb18c92434});
bdb18c92435
be59e712436function editNote(noteId, diagramId, card) {
bdb18c92437 const notes = allNotes[diagramId] || [];
bdb18c92438 const note = notes.find(n => n.id === noteId);
be59e712439 if (!note || !card) return;
be59e712440
be59e712441 const textEl = card.querySelector(".note-text");
be59e712442 if (!textEl || textEl.dataset.editing) return;
be59e712443 textEl.dataset.editing = "true";
be59e712444
be59e712445 const original = note.text;
be59e712446 const input = document.createElement("textarea");
be59e712447 input.value = original;
be59e712448 input.rows = 2;
be59e712449 input.style.cssText = "width:100%;background:var(--bg-inset);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);padding:4px 8px;font-size:12px;font-family:'Libre Caslon Text',Georgia,serif;resize:none;outline:none;line-height:1.4;";
be59e712450 input.style.height = "auto";
be59e712451
be59e712452 textEl.textContent = "";
be59e712453 textEl.appendChild(input);
be59e712454 input.focus();
be59e712455 input.style.height = Math.min(input.scrollHeight, 120) + "px";
be59e712456
be59e712457 function commit() {
be59e712458 const newText = input.value.trim();
be59e712459 delete textEl.dataset.editing;
be59e712460 if (newText && newText !== original) {
be59e712461 socket.emit("edit-note", { noteId, diagramId, text: newText });
be59e712462 } else {
be59e712463 textEl.textContent = original;
be59e712464 }
bdb18c92465 }
be59e712466
be59e712467 input.addEventListener("keydown", (e) => {
be59e712468 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commit(); }
be59e712469 if (e.key === "Escape") { delete textEl.dataset.editing; textEl.textContent = original; }
be59e712470 });
be59e712471 input.addEventListener("blur", commit);
bdb18c92472}
bdb18c92473
bdb18c92474function deleteNote(noteId, diagramId) {
bdb18c92475 if (!confirm("Delete this note?")) return;
bdb18c92476 socket.emit("delete-note", { noteId, diagramId });
bdb18c92477}
bdb18c92478
bdb18c92479// ── Actions ──
bdb18c92480function copyRoomLink() {
2a9592c2481 const url = `${window.location.origin}/${encodeURIComponent(currentOwner)}/${encodeURIComponent(currentRepo)}`;
bdb18c92482 navigator.clipboard.writeText(url).then(() => showToast("Link copied!"));
bdb18c92483}
bdb18c92484
bdb18c92485async function exportNotesLLM() {
bdb18c92486 try {
2a9592c2487 const headers = {};
2a9592c2488 const token = getCookie("grove_hub_token");
2a9592c2489 if (token) headers["Authorization"] = `Bearer ${token}`;
2a9592c2490 const resp = await fetch(`/api/repos/${encodeURIComponent(currentOwner)}/${encodeURIComponent(currentRepo)}/notes/llm`, { headers });
bdb18c92491 const blob = await resp.blob();
bdb18c92492 const cd = resp.headers.get("Content-Disposition") || "";
bdb18c92493 const match = cd.match(/filename="(.+?)"/);
2a9592c2494 const filename = match ? match[1] : `collab-notes-${currentOwner}-${currentRepo}.md`;
bdb18c92495
bdb18c92496 const url = URL.createObjectURL(blob);
bdb18c92497 const a = document.createElement("a");
bdb18c92498 a.href = url;
bdb18c92499 a.download = filename;
bdb18c92500 document.body.appendChild(a);
bdb18c92501 a.click();
bdb18c92502 a.remove();
bdb18c92503 URL.revokeObjectURL(url);
bdb18c92504 showToast("Notes exported!");
bdb18c92505 } catch (e) {
bdb18c92506 showToast("Export failed");
bdb18c92507 }
bdb18c92508}
bdb18c92509
bdb18c92510function showToast(msg) {
bdb18c92511 const t = document.getElementById("toast");
bdb18c92512 t.textContent = msg;
bdb18c92513 t.classList.add("show");
bdb18c92514 setTimeout(() => t.classList.remove("show"), 2500);
bdb18c92515}
bdb18c92516
bdb18c92517// ── Helpers ──
e629dcb2518function setBreadcrumbs(owner, repo) {
e629dcb2519 const ownerEl = document.getElementById("breadcrumbOwner");
e629dcb2520 const repoEl = document.getElementById("breadcrumbRepo");
e629dcb2521 if (ownerEl) {
e629dcb2522 ownerEl.textContent = owner;
e629dcb2523 ownerEl.href = `/${encodeURIComponent(owner)}`;
e629dcb2524 }
e629dcb2525 if (repoEl) repoEl.textContent = repo;
e629dcb2526}
e629dcb2527
e629dcb2528function setupTopbarProfile(user) {
e629dcb2529 const menu = document.getElementById("topbarProfileMenu");
e629dcb2530 const toggle = document.getElementById("topbarProfileToggle");
e629dcb2531 const dropdown = document.getElementById("topbarProfileDropdown");
e629dcb2532 const nameEl = document.getElementById("topbarProfileName");
bf6031c2533 const signInLink = document.getElementById("topbarSignIn");
e629dcb2534 if (!menu || !toggle || !user) return;
7f3fce02535 if (nameEl) nameEl.textContent = user.username;
bf6031c2536 if (signInLink) signInLink.style.display = "none";
e629dcb2537 menu.style.display = "";
e629dcb2538 toggle.addEventListener("click", () => dropdown.classList.toggle("open"));
e629dcb2539 document.addEventListener("mousedown", (e) => {
e629dcb2540 if (!menu.contains(e.target)) dropdown.classList.remove("open");
e629dcb2541 });
e629dcb2542 document.getElementById("topbarProfileSignOut").addEventListener("click", (e) => {
e629dcb2543 e.preventDefault();
e629dcb2544 document.cookie = "grove_hub_token=; path=/; max-age=0";
e629dcb2545 document.cookie = "grove_hub_user=; path=/; max-age=0";
e629dcb2546 const hostname = window.location.hostname;
e629dcb2547 const parts = hostname.split(".");
e629dcb2548 const domain = parts.length > 2 ? "." + parts.slice(-2).join(".") : "." + hostname;
e629dcb2549 document.cookie = "grove_hub_token=; path=/; domain=" + domain + "; max-age=0";
e629dcb2550 document.cookie = "grove_hub_user=; path=/; domain=" + domain + "; max-age=0";
e629dcb2551 localStorage.removeItem("grove_hub_token");
e629dcb2552 localStorage.removeItem("grove_hub_user");
e629dcb2553 window.location.href = "/";
e629dcb2554 });
e629dcb2555}
e629dcb2556
bdb18c92557function escapeHtml(s) {
bdb18c92558 const d = document.createElement("div");
bdb18c92559 d.textContent = s;
bdb18c92560 return d.innerHTML;
bdb18c92561}
bdb18c92562
bdb18c92563function formatTime(iso) {
bdb18c92564 try {
bdb18c92565 const d = new Date(iso);
bdb18c92566 return d.toLocaleString("en-CA", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
bdb18c92567 } catch { return iso; }
bdb18c92568}
bdb18c92569
bdb18c92570// ── Pan / Zoom ──
bdb18c92571function applyTransform() {
bdb18c92572 const container = document.getElementById("mermaidContainer");
bdb18c92573 container.style.transform = `translate(${panZoom.x}px, ${panZoom.y}px) scale(${panZoom.scale})`;
bdb18c92574 document.getElementById("zoomLevel").textContent = Math.round(panZoom.scale * 100) + "%";
bf1f9952575 repositionRemoteCursors();
bdb18c92576}
bdb18c92577
bdb18c92578function zoomIn() {
bdb18c92579 panZoom.scale = Math.min(panZoom.scale * 1.25, 10);
bdb18c92580 applyTransform();
bdb18c92581}
bdb18c92582
bdb18c92583function zoomOut() {
bdb18c92584 panZoom.scale = Math.max(panZoom.scale / 1.25, 0.1);
bdb18c92585 applyTransform();
bdb18c92586}
bdb18c92587
bdb18c92588function zoomReset() {
bdb18c92589 const pane = document.getElementById("diagramPane");
bdb18c92590 const container = document.getElementById("mermaidContainer");
bdb18c92591 // Reset to measure natural size
bdb18c92592 container.style.transform = "none";
bdb18c92593 const cw = container.scrollWidth;
bdb18c92594 const ch = container.scrollHeight;
bdb18c92595 const pw = pane.clientWidth;
bdb18c92596 const ph = pane.clientHeight;
bdb18c92597 // Start at 100% (natural size), centered in the pane
bdb18c92598 panZoom.scale = 1;
bdb18c92599 panZoom.x = (pw - cw) / 2;
bdb18c92600 panZoom.y = (ph - ch) / 2;
bdb18c92601 applyTransform();
bdb18c92602}
bdb18c92603
bdb18c92604// Wheel zoom (pinch-to-zoom on trackpad)
bdb18c92605document.getElementById("diagramPane").addEventListener("wheel", (e) => {
bdb18c92606 e.preventDefault();
bdb18c92607 const pane = document.getElementById("diagramPane");
bdb18c92608 const rect = pane.getBoundingClientRect();
bdb18c92609
bdb18c92610 // Mouse position relative to pane
bdb18c92611 const mx = e.clientX - rect.left;
bdb18c92612 const my = e.clientY - rect.top;
bdb18c92613
bdb18c92614 // Point in content space before zoom
bdb18c92615 const contentX = (mx - panZoom.x) / panZoom.scale;
bdb18c92616 const contentY = (my - panZoom.y) / panZoom.scale;
bdb18c92617
bdb18c92618 // Determine zoom delta
bdb18c92619 const delta = e.deltaY > 0 ? 0.9 : 1.1;
bdb18c92620 panZoom.scale = Math.min(Math.max(panZoom.scale * delta, 0.1), 10);
bdb18c92621
bdb18c92622 // Adjust pan so the point under the cursor stays fixed
bdb18c92623 panZoom.x = mx - contentX * panZoom.scale;
bdb18c92624 panZoom.y = my - contentY * panZoom.scale;
bdb18c92625
bdb18c92626 applyTransform();
bdb18c92627}, { passive: false });
bdb18c92628
bdb18c92629// Mouse drag to pan
bdb18c92630document.getElementById("diagramPane").addEventListener("mousedown", (e) => {
cee484a2631 const onInteractive = e.target.closest(".bottom-toolbar, .node-clickable");
cee484a2632 // Middle-click always pans; left-click pans only outside interactive elements
bdb18c92633 if (e.button === 1 || (e.button === 0 && !onInteractive)) {
bdb18c92634 isPanning = true;
cee484a2635 didDrag = false;
bdb18c92636 panStart = { x: e.clientX, y: e.clientY };
bdb18c92637 panStartOffset = { x: panZoom.x, y: panZoom.y };
bdb18c92638 document.getElementById("diagramPane").classList.add("panning");
bdb18c92639 e.preventDefault();
bdb18c92640 }
bdb18c92641});
bdb18c92642
bdb18c92643window.addEventListener("mousemove", (e) => {
bdb18c92644 if (!isPanning) return;
cee484a2645 const dx = e.clientX - panStart.x;
cee484a2646 const dy = e.clientY - panStart.y;
cee484a2647 if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didDrag = true;
cee484a2648 panZoom.x = panStartOffset.x + dx;
cee484a2649 panZoom.y = panStartOffset.y + dy;
bdb18c92650 applyTransform();
bdb18c92651});
bdb18c92652
bdb18c92653window.addEventListener("mouseup", (e) => {
bdb18c92654 if (isPanning) {
bdb18c92655 isPanning = false;
bdb18c92656 document.getElementById("diagramPane").classList.remove("panning");
bdb18c92657 }
bdb18c92658});
bdb18c92659
cee484a2660// Click on diagram background clears node focus
cee484a2661// (node clicks call stopPropagation, so only background clicks reach here)
cee484a2662document.getElementById("diagramPane").addEventListener("click", (e) => {
cee484a2663 if (didDrag || !selectedNodeId) return;
cee484a2664 const onToolbar = e.target.closest(".bottom-toolbar");
cee484a2665 if (!onToolbar) clearNodeFocus();
cee484a2666});
cee484a2667
922dd182668// ── Homepage ──
922dd182669function showHomepage(token, user) {
922dd182670 // Hide the collab UI, show the homepage
922dd182671 document.body.innerHTML = "";
922dd182672 document.body.style.overflow = "auto";
922dd182673 document.body.style.height = "auto";
922dd182674
a3cd4f52675 // Nav bar (matches Grove)
a3cd4f52676 const nav = document.createElement("nav");
a3cd4f52677 nav.className = "homepage-nav";
a3cd4f52678 nav.innerHTML = `
a3cd4f52679 <a href="/" class="nav-left" style="text-decoration:none">
bf6031c2680 <svg width="28" height="28" viewBox="0 0 64 64" fill="none"><circle cx="32" cy="32" r="32" fill="var(--accent)"/><path d="M9 16 C9 11, 14 8, 21 8 L35 8 C42 8, 47 11, 47 16 L47 24 C47 29, 42 32, 35 32 L19 32 L13 37 L13 32 C10 31, 9 29, 9 24 Z" fill="white"/><path d="M55 35 C55 30, 50 27, 43 27 L29 27 C22 27, 17 30, 17 35 L17 43 C17 48, 22 51, 29 51 L45 51 L51 56 L51 51 C54 50, 55 48, 55 43 Z" fill="white" opacity="0.65"/></svg>
a3cd4f52681 </a>
a3cd4f52682 <div class="nav-right">
792cc2e2683 ${user ? `
792cc2e2684 <div class="profile-menu">
7f3fce02685 <button class="profile-pill" id="profileToggle">
7f3fce02686 ${escapeHtml(user.username)}
7f3fce02687 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4L5 6.5L7.5 4" /></svg>
792cc2e2688 </button>
792cc2e2689 <div class="profile-dropdown" id="profileDropdown">
792cc2e2690 <a href="/dashboard">Dashboard</a>
792cc2e2691 <a href="#" id="profileSignOut">Sign out</a>
792cc2e2692 </div>
792cc2e2693 </div>
792cc2e2694 ` : `<a href="/login">Sign in</a>`}
a3cd4f52695 </div>
a3cd4f52696 `;
a3cd4f52697 document.body.appendChild(nav);
a3cd4f52698
792cc2e2699 // Profile dropdown toggle
792cc2e2700 const profileToggle = document.getElementById("profileToggle");
792cc2e2701 const profileDropdown = document.getElementById("profileDropdown");
792cc2e2702 if (profileToggle && profileDropdown) {
792cc2e2703 profileToggle.addEventListener("click", () => {
792cc2e2704 profileDropdown.classList.toggle("open");
792cc2e2705 });
792cc2e2706 document.addEventListener("mousedown", (e) => {
792cc2e2707 if (!profileToggle.contains(e.target) && !profileDropdown.contains(e.target)) {
792cc2e2708 profileDropdown.classList.remove("open");
792cc2e2709 }
792cc2e2710 });
792cc2e2711 document.getElementById("profileSignOut").addEventListener("click", (e) => {
792cc2e2712 e.preventDefault();
792cc2e2713 document.cookie = "grove_hub_token=; path=/; max-age=0";
792cc2e2714 document.cookie = "grove_hub_user=; path=/; max-age=0";
792cc2e2715 const hostname = window.location.hostname;
792cc2e2716 const parts = hostname.split(".");
792cc2e2717 const domain = parts.length > 2 ? "." + parts.slice(-2).join(".") : "." + hostname;
792cc2e2718 document.cookie = "grove_hub_token=; path=/; domain=" + domain + "; max-age=0";
792cc2e2719 document.cookie = "grove_hub_user=; path=/; domain=" + domain + "; max-age=0";
792cc2e2720 localStorage.removeItem("grove_hub_token");
792cc2e2721 localStorage.removeItem("grove_hub_user");
792cc2e2722 window.location.href = "/";
792cc2e2723 });
792cc2e2724 }
792cc2e2725
922dd182726 const root = document.createElement("div");
922dd182727 root.className = "homepage";
922dd182728
922dd182729 root.innerHTML = `
922dd182730 <div class="homepage-search">
922dd182731 <input id="homepageSearch" type="text" placeholder="Search repositories" />
922dd182732 </div>
922dd182733 <div id="homepageContent">
922dd182734 <div class="homepage-loading">
922dd182735 ${[0,1,2,3].map(i => `<div class="skel-row"><div class="skel" style="width:${[240,200,260,220][i]}px;margin-bottom:4px"></div><div class="skel" style="width:${[180,160,200,140][i]}px;height:0.7rem"></div></div>`).join("")}
922dd182736 </div>
922dd182737 </div>
922dd182738 `;
922dd182739 document.body.appendChild(root);
922dd182740
922dd182741 let allRepos = [];
922dd182742
922dd182743 function timeAgo(ts) {
922dd182744 const secs = Math.floor(Date.now() / 1000) - ts;
922dd182745 if (secs < 60) return "just now";
922dd182746 if (secs < 3600) return Math.floor(secs / 60) + "m ago";
922dd182747 if (secs < 86400) return Math.floor(secs / 3600) + "h ago";
922dd182748 if (secs < 2592000) return Math.floor(secs / 86400) + "d ago";
922dd182749 return Math.floor(secs / 2592000) + "mo ago";
922dd182750 }
922dd182751
922dd182752 function renderRepos(repos) {
922dd182753 const container = document.getElementById("homepageContent");
922dd182754 if (repos.length === 0) {
922dd182755 container.innerHTML = `
922dd182756 <div class="homepage-empty">
922dd182757 <div class="logo-wrap">
bf6031c2758 <svg width="48" height="48" viewBox="0 0 64 64" fill="none"><circle cx="32" cy="32" r="32" fill="var(--accent)"/><path d="M9 16 C9 11, 14 8, 21 8 L35 8 C42 8, 47 11, 47 16 L47 24 C47 29, 42 32, 35 32 L19 32 L13 37 L13 32 C10 31, 9 29, 9 24 Z" fill="white"/><path d="M55 35 C55 30, 50 27, 43 27 L29 27 C22 27, 17 30, 17 35 L17 43 C17 48, 22 51, 29 51 L45 51 L51 56 L51 51 C54 50, 55 48, 55 43 Z" fill="white" opacity="0.65"/></svg>
922dd182759 </div>
922dd182760 <p>No repositories found.</p>
922dd182761 </div>
922dd182762 `;
922dd182763 return;
922dd182764 }
922dd182765
922dd182766 // Group by owner
922dd182767 const groups = [];
922dd182768 const map = new Map();
922dd182769 for (const r of repos) {
922dd182770 const owner = r.owner_name;
922dd182771 if (!map.has(owner)) { map.set(owner, []); groups.push({ owner, repos: [] }); }
922dd182772 groups.find(g => g.owner === owner).repos.push(r);
922dd182773 }
922dd182774
922dd182775 container.innerHTML = groups.map(g => `
922dd182776 <div class="homepage-group">
922dd182777 <span class="homepage-owner">${escapeHtml(g.owner)}</span>
922dd182778 ${g.repos.map(r => {
922dd182779 const commitTs = r.last_commit_ts ?? null;
922dd182780 const updatedTs = r.updated_at ? Math.floor(new Date(r.updated_at).getTime() / 1000) : null;
922dd182781 const ts = commitTs ?? updatedTs;
922dd182782 const label = commitTs ? "Pushed" : updatedTs ? "Updated" : null;
922dd182783 return `
922dd182784 <a href="/${encodeURIComponent(r.owner_name)}/${encodeURIComponent(r.name)}" class="homepage-repo">
922dd182785 <div style="min-width:0">
922dd182786 <div class="homepage-repo-name">${escapeHtml(r.name)}</div>
922dd182787 <div class="homepage-repo-desc">${escapeHtml(r.description || "No description")}</div>
922dd182788 </div>
922dd182789 <span class="homepage-repo-time">${ts ? `${label} ${timeAgo(ts)}` : ""}</span>
922dd182790 </a>
922dd182791 `;
922dd182792 }).join("")}
922dd182793 </div>
922dd182794 `).join("");
922dd182795 }
922dd182796
922dd182797 // Fetch repos
515cc682798 const repoHeaders = token ? { Authorization: `Bearer ${token}` } : {};
515cc682799 fetch("/api/repos", { headers: repoHeaders })
922dd182800 .then(res => res.json())
922dd182801 .then(data => {
922dd182802 allRepos = (data.repos || []).sort((a, b) => {
922dd182803 const aTs = a.last_commit_ts ?? (a.updated_at ? Math.floor(new Date(a.updated_at).getTime() / 1000) : 0);
922dd182804 const bTs = b.last_commit_ts ?? (b.updated_at ? Math.floor(new Date(b.updated_at).getTime() / 1000) : 0);
922dd182805 return bTs - aTs;
922dd182806 });
922dd182807 renderRepos(allRepos);
922dd182808 })
922dd182809 .catch(() => renderRepos([]));
922dd182810
922dd182811 // Search
922dd182812 document.getElementById("homepageSearch").addEventListener("input", (e) => {
922dd182813 const q = e.target.value.trim().toLowerCase();
922dd182814 if (!q) { renderRepos(allRepos); return; }
922dd182815 renderRepos(allRepos.filter(r => {
922dd182816 return `${r.owner_name} ${r.name} ${r.description || ""}`.toLowerCase().includes(q);
922dd182817 }));
922dd182818 });
922dd182819}
922dd182820
515cc682821// ── Init: load page, connect socket only if authenticated ──
1e755c02822(async function() {
2a9592c2823 const token = getCookie("grove_hub_token");
2a9592c2824 const userJson = getCookie("grove_hub_user");
515cc682825 let user = null;
515cc682826 if (token && userJson) {
515cc682827 try { user = JSON.parse(userJson); } catch { /* treat as unauthenticated */ }
2a9592c2828 }
2a9592c2829
2a9592c2830 // Extract owner/repo from URL path
2a9592c2831 const pathParts = window.location.pathname.split("/").filter(Boolean);
2a9592c2832 if (pathParts.length < 2) {
922dd182833 showHomepage(token, user);
2a9592c2834 return;
2a9592c2835 }
2a9592c2836 const [owner, repo] = pathParts;
2a9592c2837
e629dcb2838 // Set up topbar profile icon
e629dcb2839 setupTopbarProfile(user);
e629dcb2840
515cc682841 if (token && user) {
515cc682842 await joinRoom(owner, repo, token, user);
515cc682843 } else {
515cc682844 // Unauthenticated: load diagrams read-only (no real-time collab)
515cc682845 currentOwner = owner;
515cc682846 currentRepo = repo;
515cc682847 myName = "Anonymous";
515cc682848 roomId = `${owner}/${repo}`;
e629dcb2849 setBreadcrumbs(owner, repo);
515cc682850 await loadDiagramsFromServer(owner, repo);
515cc682851 buildDrawer();
515cc682852 switchTab(0);
515cc682853 }
bdb18c92854})();
bdb18c92855
bdb18c92856// Live reload: reconnect a lightweight socket just for hot-reload signals
bdb18c92857(function() {
bdb18c92858 const reloadSocket = io();
bdb18c92859 reloadSocket.on("hot-reload", ({ file }) => {
bdb18c92860 console.log(`[hot-reload] ${file} changed, reloading...`);
bdb18c92861 window.location.reload();
bdb18c92862 });
bdb18c92863})();
bdb18c92864</script>
bdb18c92865</body>
bdb18c92866</html>