Files
CTXD/app/src/ctxd/ui.html
T

1674 lines
54 KiB
HTML
Raw Normal View History

2026-06-23 23:54:37 +00:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Context Dossier</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
/* ── Reset & base ─────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a1a;
--paper: #1e1e1e;
--ink: #d4d4d4;
--ink-dim: #888;
--ink-bright: #e0e0e0;
--accent: #e5c07b;
--accent2: #7ec8e3;
--accent3: #98c379;
--accent4: #c678dd;
--border: #2a2a2a;
--border-light: #333;
--header-bg: #141414;
--input-bg: #222;
--hover: #252525;
--danger: #e06c75;
--font: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'IBM Plex Mono', monospace;
--line: 1.6;
--col-gap: 2rem;
--sidebar-w: 240px;
--touch-min: 44px;
}
html {
height: 100%;
font-size: 15px;
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--ink);
line-height: var(--line);
height: 100vh;
height: 100dvh;
max-height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Masthead ──────────────────────────────────────────────────── */
.masthead {
background: var(--header-bg);
border-bottom: 3px double var(--border-light);
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
user-select: none;
flex-shrink: 0;
min-height: var(--touch-min);
}
.masthead h1 {
font-size: 1.1rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.masthead h1 span { color: var(--ink-dim); font-weight: 400; }
.masthead .edition {
font-size: 0.65rem;
color: var(--ink-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
white-space: nowrap;
}
.masthead .edition .dot {
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent3);
margin-right: 0.3rem;
vertical-align: middle;
animation: pulse 2s infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
/* Hamburger — visible only on mobile */
.hamburger {
display: none;
background: none;
border: none;
color: var(--ink);
font-size: 1.3rem;
cursor: pointer;
padding: 0.4rem;
line-height: 1;
min-width: var(--touch-min);
min-height: var(--touch-min);
align-items: center;
justify-content: center;
}
.hamburger:hover { color: var(--accent); }
/* ── Layout ────────────────────────────────────────────────────── */
.layout {
display: flex;
flex: 1;
min-height: 0;
position: relative;
}
.sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
background: var(--paper);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
transition: transform 0.25s ease;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0;
}
/* ── Sidebar ───────────────────────────────────────────────────── */
.sidebar-header {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--border);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-dim);
display: flex;
justify-content: space-between;
align-items: center;
min-height: var(--touch-min);
}
.sidebar-header .btn-new {
font-family: var(--font);
font-size: 0.65rem;
background: none;
border: 1px solid var(--border-light);
color: var(--accent);
padding: 0.3rem 0.6rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
min-height: var(--touch-min);
display: flex;
align-items: center;
}
.sidebar-header .btn-new:hover { background: var(--hover); }
.project-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0.25rem 0;
}
.project-item {
padding: 0.6rem 0.75rem;
cursor: pointer;
border-left: 3px solid transparent;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
min-height: var(--touch-min);
-webkit-tap-highlight-color: transparent;
}
.project-item:hover { background: var(--hover); }
.project-item:active { background: var(--hover); }
.project-item.active {
background: var(--hover);
border-left-color: var(--accent);
color: var(--accent);
}
.project-item .chevron {
color: var(--ink-dim);
width: 0.9rem;
flex-shrink: 0;
}
.project-item .version {
font-size: 0.65rem;
color: var(--ink-dim);
flex-shrink: 0;
margin-left: 0.5rem;
}
.project-item .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.project-files {
margin: 0.1rem 0 0.35rem 0;
border-left: 1px solid var(--border);
margin-left: 1.25rem;
}
.project-file-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem 0.35rem 0.65rem;
cursor: pointer;
font-size: 0.72rem;
color: var(--ink-dim);
min-height: 2rem;
border-left: 2px solid transparent;
-webkit-tap-highlight-color: transparent;
}
.project-file-item:hover { background: var(--hover); color: var(--ink); }
.project-file-item.active {
color: var(--accent);
border-left-color: var(--accent);
background: rgba(229, 192, 123, 0.08);
}
.project-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-file-version {
font-size: 0.58rem;
color: var(--ink-dim);
flex-shrink: 0;
}
.project-files-loading {
padding: 0.35rem 0.75rem 0.35rem 0.65rem;
color: var(--ink-dim);
font-size: 0.68rem;
font-style: italic;
}
.sidebar-footer {
padding: 0.4rem 0.75rem;
border-top: 1px solid var(--border);
font-size: 0.65rem;
color: var(--ink-dim);
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.sidebar-footer button {
font-family: var(--font);
font-size: 0.65rem;
background: none;
border: none;
color: var(--ink-dim);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.3rem 0.4rem;
min-height: var(--touch-min);
display: flex;
align-items: center;
}
.sidebar-footer button:hover { color: var(--accent); }
.sidebar-footer button.active-tab { color: var(--accent); }
/* ── Search bar ────────────────────────────────────────────────── */
.search-bar {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
display: flex;
gap: 0.4rem;
align-items: center;
flex-shrink: 0;
}
.search-bar input {
flex: 1;
min-width: 0;
font-family: var(--font);
font-size: 0.8rem;
background: var(--input-bg);
border: 1px solid var(--border-light);
color: var(--ink);
padding: 0.4rem 0.6rem;
outline: none;
min-height: var(--touch-min);
-webkit-appearance: none;
border-radius: 0;
}
.search-bar input:focus { border-color: var(--accent); }
.search-bar input::placeholder { color: var(--ink-dim); font-style: italic; }
.search-bar .search-hint {
font-size: 0.65rem;
color: var(--ink-dim);
white-space: nowrap;
flex-shrink: 0;
}
/* ── Content area ──────────────────────────────────────────────── */
.content {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
padding: 1rem;
display: block;
}
/* ── File tabs ─────────────────────────────────────────────────── */
.file-tabs {
display: flex;
gap: 0;
overflow-x: auto;
border-bottom: 1px solid var(--border);
margin-bottom: 0.5rem;
flex-shrink: 0;
-webkit-overflow-scrolling: touch;
}
.file-tab {
font-family: var(--font);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--ink-dim);
padding: 0.5rem 0.75rem;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.3rem;
min-height: var(--touch-min);
-webkit-tap-highlight-color: transparent;
}
.file-tab:hover { color: var(--ink); background: var(--hover); }
.file-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.file-tab-version {
font-size: 0.55rem;
color: var(--ink-dim);
background: var(--input-bg);
padding: 0.1rem 0.25rem;
border-radius: 2px;
}
.file-tab.active .file-tab-version { color: var(--accent); }
.file-tab-add {
color: var(--accent2);
font-size: 1rem;
padding: 0.5rem 0.6rem;
}
/* ── Article body — single column ──────────────────────────────── */
.newspaper {
display: block;
overflow: visible;
padding-right: 0.25rem;
padding-bottom: env(safe-area-inset-bottom, 0);
column-count: 1;
column-gap: 0;
column-rule: none;
}
.newspaper h1 { column-span: none; }
.newspaper h2 { column-span: none; }
.newspaper h3 { break-inside: avoid; }
.newspaper pre { break-inside: avoid; }
.newspaper table { break-inside: avoid; }
.newspaper img { max-width: 100%; height: auto; }
/* ── Markdown content ──────────────────────────────────────────── */
.content h1 {
font-size: 1.3rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-bright);
margin: 0 0 0.6rem 0;
padding-bottom: 0.4rem;
border-bottom: 2px solid var(--border-light);
word-break: break-word;
}
.content h2 {
font-size: 1.05rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
margin: 1.2rem 0 0.4rem 0;
word-break: break-word;
}
.content h3 {
font-size: 0.95rem;
font-weight: 600;
color: var(--accent2);
margin: 0.8rem 0 0.3rem 0;
word-break: break-word;
}
.content p { margin: 0 0 0.6rem 0; word-break: break-word; }
.content a { color: var(--accent2); text-decoration: underline; text-underline-offset: 2px; word-break: break-word; }
.content a:hover { color: var(--accent); }
.content strong { color: var(--ink-bright); }
.content em { color: var(--ink-dim); }
.content blockquote {
border-left: 3px solid var(--accent);
padding: 0.4rem 0.75rem;
margin: 0.6rem 0;
background: var(--input-bg);
font-style: italic;
color: var(--ink-dim);
}
.content ul, .content ol { margin: 0 0 0.6rem 1.2rem; }
.content li { margin: 0.2rem 0; word-break: break-word; }
.content hr {
border: none;
border-top: 1px solid var(--border-light);
margin: 1.2rem 0;
}
.content code {
font-family: var(--font);
font-size: 0.85em;
background: var(--input-bg);
padding: 0.1em 0.3em;
border: 1px solid var(--border);
border-radius: 2px;
word-break: break-word;
}
.content pre {
background: var(--input-bg);
border: 1px solid var(--border);
padding: 0.6rem 0.75rem;
margin: 0.6rem 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
font-size: 0.75rem;
line-height: 1.4;
}
.content pre code {
background: none;
border: none;
padding: 0;
font-size: inherit;
word-break: normal;
white-space: pre;
}
.content table {
width: 100%;
border-collapse: collapse;
margin: 0.6rem 0;
font-size: 0.8rem;
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.content th, .content td {
border: 1px solid var(--border);
padding: 0.35rem 0.5rem;
text-align: left;
white-space: nowrap;
}
.content th {
background: var(--input-bg);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.7rem;
}
.content tr:nth-child(even) { background: rgba(255,255,255,0.02); }
/* ── Metadata header (gold, vault-style) ──────────────────────── */
.ctxd-header {
color: var(--accent);
font-family: var(--font);
font-size: 0.85rem;
line-height: 1.7;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: pre-wrap;
margin-bottom: 0.5rem;
}
.ctxd-header + hr { margin-top: 0; }
/* ── Editor ─────────────────────────────────────────────────────── */
.editor-pane { display: none; }
.editor-pane.active {
display: block;
min-height: 0;
overflow: visible;
}
.editor-toolbar {
display: flex;
gap: 0.4rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
margin-bottom: 0.6rem;
align-items: center;
flex-wrap: wrap;
flex-shrink: 0;
}
.editor-toolbar .meta {
font-size: 0.65rem;
color: var(--ink-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.editor-toolbar .meta strong { color: var(--ink-bright); }
.editor-toolbar .spacer { flex: 1; min-width: 0.5rem; }
.editor-toolbar button {
font-family: var(--font);
font-size: 0.7rem;
background: none;
border: 1px solid var(--border-light);
color: var(--ink);
padding: 0.3rem 0.6rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
min-height: var(--touch-min);
display: flex;
align-items: center;
-webkit-tap-highlight-color: transparent;
}
.editor-toolbar button:hover { background: var(--hover); border-color: var(--accent); color: var(--accent); }
.editor-toolbar button.primary {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
font-weight: 600;
}
.editor-toolbar button.primary:hover { background: #d4ae5c; }
.editor-toolbar button.danger { color: var(--danger); border-color: var(--danger); }
.editor-toolbar button.danger:hover { background: var(--danger); color: var(--bg); }
.editor-split {
display: flex;
flex: 1;
gap: 0.75rem;
min-height: 0;
}
.editor-split textarea {
flex: 1;
font-family: var(--font);
font-size: 0.8rem;
line-height: 1.6;
background: var(--input-bg);
border: 1px solid var(--border-light);
color: var(--ink);
padding: 0.6rem;
resize: none;
outline: none;
min-height: 200px;
-webkit-appearance: none;
border-radius: 0;
}
.editor-split textarea:focus { border-color: var(--accent); }
.editor-preview {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0 0.6rem;
border-left: 1px solid var(--border);
}
/* ── Search results ────────────────────────────────────────────── */
.search-results { display: none; }
.search-results.active { display: block; }
.search-result-item {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
min-height: var(--touch-min);
-webkit-tap-highlight-color: transparent;
}
.search-result-item:hover { background: var(--hover); }
.search-result-item:active { background: var(--hover); }
.search-result-item .src {
font-size: 0.65rem;
color: var(--ink-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.search-result-item .path { color: var(--accent2); font-size: 0.8rem; word-break: break-word; }
.search-result-item .snippet {
font-size: 0.75rem;
color: var(--ink-dim);
margin-top: 0.2rem;
line-height: 1.4;
word-break: break-word;
}
.search-result-item .snippet em { color: var(--accent); font-style: normal; background: rgba(229,192,123,0.15); padding: 0 2px; }
/* ── Audit log ─────────────────────────────────────────────────── */
.audit-table { width: 100%; font-size: 0.75rem; display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.audit-table th {
position: sticky;
top: 0;
background: var(--paper);
white-space: nowrap;
}
.audit-table td { white-space: nowrap; }
/* ── New project dialog ────────────────────────────────────────── */
.dialog-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 100;
align-items: center;
justify-content: center;
padding: 1rem;
}
.dialog-overlay.active { display: flex; }
.dialog {
background: var(--paper);
border: 1px solid var(--border-light);
padding: 1.25rem;
width: 100%;
max-width: 420px;
}
.dialog h2 {
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: 0.75rem;
}
.dialog label {
display: block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-dim);
margin-bottom: 0.2rem;
}
.dialog input {
width: 100%;
font-family: var(--font);
font-size: 0.85rem;
background: var(--input-bg);
border: 1px solid var(--border-light);
color: var(--ink);
padding: 0.4rem 0.6rem;
margin-bottom: 0.6rem;
outline: none;
min-height: var(--touch-min);
-webkit-appearance: none;
border-radius: 0;
}
.dialog input:focus { border-color: var(--accent); }
.dialog .actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.dialog .actions button {
font-family: var(--font);
font-size: 0.7rem;
padding: 0.4rem 0.75rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid var(--border-light);
background: none;
color: var(--ink);
min-height: var(--touch-min);
display: flex;
align-items: center;
}
.dialog .actions button:hover { background: var(--hover); }
.dialog .actions button.primary {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
/* ── Import / file upload ──────────────────────────────────────── */
.import-section { margin-bottom: 0.6rem; }
.file-drop {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border: 1px dashed var(--border-light);
background: var(--input-bg);
min-height: var(--touch-min);
}
.file-btn {
font-family: var(--font);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid var(--border-light);
background: none;
color: var(--ink);
padding: 0.3rem 0.6rem;
cursor: pointer;
min-height: var(--touch-min);
display: flex;
align-items: center;
flex-shrink: 0;
}
.file-btn:hover { background: var(--hover); border-color: var(--accent); color: var(--accent); }
.file-name {
font-size: 0.75rem;
color: var(--ink-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-hint {
font-size: 0.7rem;
color: var(--ink-dim);
margin-bottom: 0.6rem;
line-height: 1.5;
}
/* ── Toast ──────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 1rem;
right: 1rem;
left: 1rem;
background: var(--paper);
border: 1px solid var(--border-light);
padding: 0.6rem 0.75rem;
font-size: 0.75rem;
color: var(--ink);
z-index: 200;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
max-width: 400px;
margin: 0 auto;
}
.toast.show { opacity: 1; }
.toast.error { border-color: var(--danger); color: var(--danger); }
.toast.success { border-color: var(--accent3); color: var(--accent3); }
/* ── Loading ───────────────────────────────────────────────────── */
.loading {
text-align: center;
padding: 2rem 1rem;
color: var(--ink-dim);
font-style: italic;
}
/* ── Scrollbar ─────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border-light); }
::-webkit-scrollbar-thumb:hover { background: var(--ink-dim); }
/* ── Empty state ───────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 2rem 1rem;
color: var(--ink-dim);
font-style: italic;
}
/* ── Sidebar overlay (mobile) ──────────────────────────────────── */
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 50;
}
/* ═══════════════════════════════════════════════════════════════════
RESPONSIVE — Mobile first, layered up
═══════════════════════════════════════════════════════════════════ */
/* ── Small phones (< 480px) ────────────────────────────────────── */
@media (max-width: 479px) {
html { font-size: 14px; }
.masthead { padding: 0.5rem 0.6rem; }
.masthead h1 { font-size: 0.95rem; letter-spacing: 0.1em; }
.masthead h1 span { display: none; }
.masthead .edition { font-size: 0.6rem; }
.content { padding: 0.6rem; }
.search-bar { padding: 0.4rem 0.5rem; }
.search-bar input { font-size: 0.75rem; }
.editor-split { flex-direction: column; }
.editor-split textarea { min-height: 150px; }
.editor-preview { border-left: none; border-top: 1px solid var(--border); padding: 0.6rem 0 0 0; }
.editor-toolbar button { font-size: 0.65rem; padding: 0.25rem 0.5rem; }
.newspaper { column-count: 1; }
.sidebar { width: 100%; max-width: 280px; }
.dialog { padding: 1rem; }
}
/* ── Phones & small tablets (480px 767px) ───────────────────── */
@media (max-width: 767px) {
.hamburger { display: flex; }
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 60;
transform: translateX(-100%);
width: 80vw;
max-width: 300px;
}
.sidebar.open { transform: translateX(0); }
.sidebar-overlay.active { display: block; }
.newspaper { column-count: 1; }
.editor-split { flex-direction: column; }
.editor-split textarea { min-height: 180px; }
.editor-preview { border-left: none; border-top: 1px solid var(--border); padding: 0.6rem 0 0 0; }
.masthead h1 span { display: none; }
}
/* ── Tablets portrait (768px 1023px) ─────────────────────────── */
@media (min-width: 768px) and (max-width: 1023px) {
html { font-size: 14.5px; }
.sidebar { width: 200px; }
.content { padding: 1rem 1.25rem; }
.newspaper { column-count: 1; column-gap: 0; column-rule: none; }
.editor-split textarea { min-height: 250px; }
.masthead h1 { font-size: 1.05rem; }
}
/* ── Tablets landscape (1024px 1279px) ────────────────────────── */
@media (min-width: 1024px) and (max-width: 1279px) {
.sidebar { width: 220px; }
.content { padding: 1.25rem 1.5rem; }
}
/* ── Large desktop (> 1280px) ──────────────────────────────────── */
@media (min-width: 1280px) {
.content { padding: 1.5rem 2rem; }
}
/* ── Touch-friendly: hide hover on touch devices ────────────────── */
@media (hover: none) {
.project-item:hover { background: none; }
.project-item:active { background: var(--hover); }
.search-result-item:hover { background: none; }
.search-result-item:active { background: var(--hover); }
.editor-toolbar button:hover { background: none; border-color: var(--border-light); color: var(--ink); }
.editor-toolbar button:active { background: var(--hover); }
.sidebar-footer button:hover { color: var(--ink-dim); }
.sidebar-footer button:active { color: var(--accent); }
.dialog .actions button:hover { background: none; }
.dialog .actions button:active { background: var(--hover); }
}
/* ── Safe area insets (iPhone X+) ──────────────────────────────── */
@supports (padding: env(safe-area-inset-bottom)) {
.sidebar { padding-bottom: env(safe-area-inset-bottom); }
.toast { bottom: calc(1rem + env(safe-area-inset-bottom)); }
}
</style>
</head>
<body>
<!-- ── Masthead ── -->
<header class="masthead">
<button class="hamburger" id="hamburger" aria-label="Toggle sidebar"></button>
<h1>📋 Context Dossier <span>· project context</span></h1>
<div class="edition">
<span class="dot"></span>
<span id="status-text">connecting…</span>
</div>
</header>
<!-- ── Layout ── -->
<div class="layout">
<!-- Sidebar overlay (mobile) -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Sidebar -->
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<span>projects</span>
<button class="btn-new" onclick="showNewProjectDialog()">+ new</button>
</div>
<div class="project-list" id="project-list"></div>
<div class="sidebar-footer">
<button class="active-tab" data-tab="context" onclick="switchTab('context')">context</button>
<button data-tab="audit" onclick="switchTab('audit')">audit</button>
<button data-tab="snapshots" onclick="switchTab('snapshots')">snapshots</button>
</div>
</nav>
<!-- Main -->
<div class="main">
<div class="search-bar">
<input type="text" id="search-input" placeholder="search context…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" onkeydown="if(event.key==='Enter')doSearch()">
<span class="search-hint"></span>
</div>
<div class="content" id="content-area">
<div class="loading">select a project from the sidebar</div>
</div>
</div>
</div>
<!-- ── New project dialog ── -->
<div class="dialog-overlay" id="new-project-dialog">
<div class="dialog">
<h2>new project</h2>
<label>project id</label>
<input type="text" id="new-pid" placeholder="e.g. my-project" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" onkeydown="if(event.key==='Enter')createProject()">
<label>display name</label>
<input type="text" id="new-name" placeholder="My Project" onkeydown="if(event.key==='Enter')createProject()">
<label>description</label>
<input type="text" id="new-desc" placeholder="What is this project?" onkeydown="if(event.key==='Enter')createProject()">
<div class="import-section">
<label>import context file (optional)</label>
<div class="file-drop" id="file-drop">
<input type="file" id="import-file" accept=".md,.txt,.cursorrules,.windsurfrules" style="display:none">
<button class="file-btn" onclick="document.getElementById('import-file').click()">choose file</button>
<span class="file-name" id="import-file-name">no file selected</span>
</div>
</div>
<div class="actions">
<button onclick="hideNewProjectDialog()">cancel</button>
<button class="primary" onclick="createProject()">create</button>
</div>
</div>
</div>
<!-- ── Import dialog (existing project) ── -->
<div class="dialog-overlay" id="import-dialog">
<div class="dialog">
<h2>import context</h2>
<p class="dialog-hint">Upload an AGENTS.md, CLAUDE.md, .cursorrules, or any markdown/txt file. Existing headers and YAML frontmatter will be stripped automatically.</p>
<div class="file-drop" id="file-drop-import">
<input type="file" id="import-file-existing" accept=".md,.txt,.cursorrules,.windsurfrules" style="display:none">
<button class="file-btn" onclick="document.getElementById('import-file-existing').click()">choose file</button>
<span class="file-name" id="import-file-name-existing">no file selected</span>
</div>
<div class="actions">
<button onclick="hideImportDialog()">cancel</button>
<button class="primary" onclick="importToExisting()">import</button>
</div>
</div>
</div>
<!-- ── Auth dialog ── -->
<div class="dialog-overlay" id="auth-dialog">
<div class="dialog">
<h2>authentication required</h2>
<p class="dialog-hint">Enter the API key to access Context Dossier.</p>
<input type="password" id="auth-key-input" placeholder="API key" autocomplete="off" onkeydown="if(event.key==='Enter')submitAuthKey()">
<div class="actions">
<button class="primary" onclick="submitAuthKey()">connect</button>
</div>
</div>
</div>
<!-- ── Toast ── -->
<div class="toast" id="toast"></div>
<script>
// ── State ────────────────────────────────────────────────────────
const state = {
projects: [],
currentProject: null,
currentFile: null,
currentFileContent: '',
currentTab: 'context',
currentVersion: 0,
fileVersion: 0,
files: [],
filesByProject: {},
expandedProjects: new Set(),
searchResults: null,
};
// ── API helpers ──────────────────────────────────────────────────
const API = window.location.origin;
// Auth — API key stored in localStorage
function getApiKey() {
return localStorage.getItem('ctxd_api_key') || '';
}
function setApiKey(key) {
localStorage.setItem('ctxd_api_key', key);
}
function clearApiKey() {
localStorage.removeItem('ctxd_api_key');
}
async function api(method, path, body) {
const opts = { method, headers: {} };
const key = getApiKey();
if (key) opts.headers['Authorization'] = `Bearer ${key}`;
if (body) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(API + path, opts);
if (res.status === 401) {
showAuthDialog();
throw new Error('unauthorized');
}
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text.slice(0, 200)}`);
}
return res.json();
}
// ── Sidebar toggle (mobile) ─────────────────────────────────────
function openSidebar() {
document.getElementById('sidebar').classList.add('open');
document.getElementById('sidebar-overlay').classList.add('active');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebar-overlay').classList.remove('active');
}
document.getElementById('hamburger').addEventListener('click', openSidebar);
document.getElementById('sidebar-overlay').addEventListener('click', closeSidebar);
// ── Toast ────────────────────────────────────────────────────────
let toastTimer;
// ── Auth dialog ──────────────────────────────────────────────────
function showAuthDialog() {
document.getElementById('auth-dialog').classList.add('active');
setTimeout(() => document.getElementById('auth-key-input').focus(), 100);
}
function hideAuthDialog() {
document.getElementById('auth-dialog').classList.remove('active');
}
async function submitAuthKey() {
const key = document.getElementById('auth-key-input').value.trim();
if (!key) return;
setApiKey(key);
// Test the key by trying to list projects
try {
await api('GET', '/projects');
hideAuthDialog();
showToast('authenticated', 'success');
await loadProjects();
} catch (e) {
clearApiKey();
showToast('invalid API key', 'error');
}
}
function showToast(msg, type) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show' + (type ? ' ' + type : '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 3000);
}
// ── Status ──────────────────────────────────────────────────────
async function checkStatus() {
try {
const data = await api('GET', '/status');
document.getElementById('status-text').textContent = 'connected · ' + data.db.split('/').pop();
} catch (e) {
document.getElementById('status-text').textContent = 'disconnected';
}
}
// ── Projects ─────────────────────────────────────────────────────
async function loadProjects() {
try {
state.projects = await api('GET', '/projects');
renderProjectList();
} catch (e) {
if (e.message !== 'unauthorized') {
showToast('Failed to load projects: ' + e.message, 'error');
}
}
}
function renderProjectList() {
const el = document.getElementById('project-list');
el.innerHTML = state.projects.map(p => {
const pid = p.project_id;
const expanded = state.expandedProjects.has(pid);
const files = state.filesByProject[pid] || [];
const isActiveProject = state.currentProject === pid;
const fileListHtml = expanded ? `
<div class="project-files">
${files.length ? files.map(f => `
<div class="project-file-item${isActiveProject && state.currentFile === f.file_path ? ' active' : ''}"
onclick="selectSidebarFile('${pid}', '${f.file_path}')">
<span class="project-file-name">${f.file_path.replace(/\.MD$/i, '')}</span>
<span class="project-file-version">v${f.version}</span>
</div>
`).join('') : '<div class="project-files-loading">loading files…</div>'}
</div>
` : '';
return `
<div class="project-block">
<div class="project-item${isActiveProject ? ' active' : ''}"
onclick="toggleProject('${pid}')">
<span class="chevron">${expanded ? '▾' : '▸'}</span>
<span class="name">${p.display_name || pid}</span>
<span class="version">v${p.shared_version}</span>
</div>
${fileListHtml}
</div>
`;
}).join('');
}
async function ensureProjectFiles(pid, force = false) {
if (!force && state.filesByProject[pid]) return state.filesByProject[pid];
let files = await api('GET', `/projects/${pid}/files`);
if (files.length === 0) {
await api('POST', `/projects/${pid}/migrate-files`);
files = await api('GET', `/projects/${pid}/files`);
}
state.filesByProject[pid] = files;
if (state.currentProject === pid) state.files = files;
return files;
}
// ── Select project ──────────────────────────────────────────────
async function toggleProject(pid) {
const wasExpanded = state.expandedProjects.has(pid);
if (wasExpanded) {
state.expandedProjects.delete(pid);
renderProjectList();
return;
}
await openProject(pid, { closeAfterSelect: false });
}
async function selectProject(pid) {
await openProject(pid, { closeAfterSelect: true });
}
async function openProject(pid, { closeAfterSelect = false } = {}) {
state.currentProject = pid;
state.searchResults = null;
state.currentFile = null;
state.fileVersion = 0;
state.files = [];
state.expandedProjects.add(pid);
document.getElementById('search-input').value = '';
switchTab('context', { render: false });
renderProjectList();
const area = document.getElementById('content-area');
area.innerHTML = '<div class="loading">loading…</div>';
try {
const files = await ensureProjectFiles(pid);
renderProjectList();
if (files.length > 0) {
await selectProjectFile(pid, files[0].file_path, { closeAfterSelect });
} else {
area.innerHTML = '<div class="empty-state">no files — create one to get started</div>';
}
} catch (e) {
if (e.message !== 'unauthorized') {
area.innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
}
}
}
// ── File management ──────────────────────────────────────────────
async function loadProjectFiles(pid) {
await openProject(pid, { closeAfterSelect: false });
}
async function selectSidebarFile(pid, filePath) {
await selectProjectFile(pid, filePath, { closeAfterSelect: true });
}
async function selectFile(filePath) {
await selectProjectFile(state.currentProject, filePath, { closeAfterSelect: false });
}
async function selectProjectFile(pid, filePath, { closeAfterSelect = false } = {}) {
state.currentProject = pid;
state.currentFile = filePath;
state.fileVersion = 0;
state.searchResults = null;
state.expandedProjects.add(pid);
document.getElementById('search-input').value = '';
switchTab('context', { render: false });
try {
const files = await ensureProjectFiles(pid);
state.files = files;
renderProjectList();
renderFileTabs();
await loadFile(pid, filePath);
if (closeAfterSelect) closeSidebar();
} catch (e) {
if (e.message !== 'unauthorized') {
document.getElementById('content-area').innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
}
}
}
function renderFileTabs() {
const tabsEl = document.getElementById('file-tabs');
if (!tabsEl || !state.files.length) return;
tabsEl.innerHTML = state.files.map(f => `
<button class="file-tab${state.currentFile === f.file_path ? ' active' : ''}"
onclick="selectFile('${f.file_path}')">
${f.file_path.replace('.MD', '').replace('.md', '')}
<span class="file-tab-version">v${f.version}</span>
</button>
`).join('') + `
<button class="file-tab file-tab-add" onclick="showNewFileDialog()" title="add file">+</button>
`;
}
async function loadFile(pid, filePath) {
const area = document.getElementById('content-area');
area.innerHTML = '<div class="loading">loading…</div>';
try {
const data = await api('GET', `/projects/${pid}/files/${encodeURIComponent(filePath)}`);
state.fileVersion = data.version;
state.currentFileContent = data.content;
renderContext(data.content, data.version, filePath);
} catch (e) {
if (e.message !== 'unauthorized') {
area.innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
}
}
}
// ── Markdown custom renderer ─────────────────────────────────────
// The metadata header (TYPE: PROJECT CONTEXT ...) should render in gold.
// Strip it before markdown rendering, render the rest, then re-insert
// it styled as a <div class=ctxd-header>.
const ctxdHeaderRegex = /^(TYPE: PROJECT CONTEXT[\s\S]*?(?:\n--\n___|\n---))/;
function extractHeader(content) {
const m = content.match(ctxdHeaderRegex);
return m ? { header: m[1], rest: content.slice(m[0].length) } : null;
}
// ── Render context ──────────────────────────────────────────────
function renderContext(content, version, filePath) {
const area = document.getElementById('content-area');
const displayName = filePath ? filePath.replace(/\.MD$/i, '') : 'context';
// Extract the metadata header so markdown doesn't mangle it
const extracted = extractHeader(content);
let headerHtml = '';
let body = content;
if (extracted) {
headerHtml = `<div class="ctxd-header">${escapeHtml(extracted.header)}</div>`;
body = extracted.rest;
}
const html = marked.parse(body, { breaks: true, gfm: true });
area.innerHTML = `
<div class="file-tabs" id="file-tabs"></div>
<div class="editor-pane active">
<div class="editor-toolbar">
<span class="meta">${state.currentProject} / ${displayName} · <strong>v${version}</strong></span>
<span class="spacer"></span>
<button onclick="toggleEdit()">edit</button>
<button onclick="showImportDialog()">import</button>
<button onclick="syncProject()">sync</button>
</div>
<div class="newspaper" id="context-view">${headerHtml}${html}</div>
</div>
<div class="editor-pane" id="editor-pane">
<div class="editor-toolbar">
<span class="meta">editing · ${displayName} · <strong>v${version}</strong></span>
<span class="spacer"></span>
<button onclick="toggleEdit()">cancel</button>
<button class="primary" onclick="saveFileContent()">save</button>
</div>
<div class="editor-split">
<textarea id="editor-textarea">${escapeHtml(content)}</textarea>
<div class="editor-preview" id="editor-preview"></div>
</div>
</div>
`;
// Render file tabs
renderFileTabs();
// Highlight code blocks
document.querySelectorAll('#context-view pre code').forEach(block => {
hljs.highlightElement(block);
});
// Live preview
const ta = document.getElementById('editor-textarea');
if (ta) {
ta.addEventListener('input', updatePreview);
}
}
function updatePreview() {
const ta = document.getElementById('editor-textarea');
const preview = document.getElementById('editor-preview');
if (ta && preview) {
preview.innerHTML = marked.parse(ta.value, { breaks: true, gfm: true });
}
}
let editing = false;
function toggleEdit() {
editing = !editing;
document.querySelectorAll('.editor-pane').forEach(el => el.classList.toggle('active'));
if (editing) updatePreview();
}
async function saveFileContent() {
const ta = document.getElementById('editor-textarea');
if (!ta) return;
const content = ta.value;
try {
const result = await api('PUT', `/projects/${state.currentProject}/files/${encodeURIComponent(state.currentFile)}`, {
content,
updated_by: 'admin',
base_version: state.fileVersion,
});
if (result.ok) {
showToast(`saved · v${result.new_version}`, 'success');
state.fileVersion = result.new_version;
editing = false;
// Reload the file to show the updated content with fresh header
await loadFile(state.currentProject, state.currentFile);
// Update file version in tabs/sidebar cache
const fileEntry = state.files.find(f => f.file_path === state.currentFile);
if (fileEntry) fileEntry.version = result.new_version;
if (state.filesByProject[state.currentProject]) {
const cachedEntry = state.filesByProject[state.currentProject].find(f => f.file_path === state.currentFile);
if (cachedEntry) cachedEntry.version = result.new_version;
}
renderFileTabs();
renderProjectList();
} else if (result.error === 'conflict') {
showToast(`conflict: base v${state.fileVersion} but current is v${result.current_version} — re-read and merge`, 'error');
} else {
showToast('save failed: ' + (result.error || 'unknown'), 'error');
}
} catch (e) {
showToast('save error: ' + e.message, 'error');
}
}
// ── New file dialog ─────────────────────────────────────────────
function showNewFileDialog() {
const name = prompt('File name (e.g. ARCHITECTURE.md):');
if (!name) return;
// Normalize
let fname = name.toUpperCase();
if (!fname.endsWith('.MD')) fname += '.MD';
createNewFile(fname);
}
async function createNewFile(filePath) {
try {
const result = await api('POST', `/projects/${state.currentProject}/files`, {
file_path: filePath,
content: '',
updated_by: 'admin',
});
if (result.ok) {
showToast(`created ${filePath}`, 'success');
// Reload file list and select the new file
const files = await ensureProjectFiles(state.currentProject, true);
state.files = files;
renderProjectList();
selectFile(filePath);
} else {
showToast('create failed: ' + (result.error || 'unknown'), 'error');
}
} catch (e) {
showToast('create failed: ' + e.message, 'error');
}
}
async function syncProject() {
try {
const result = await api('POST', `/projects/${state.currentProject}/sync`);
if (result.ok) {
showToast(`synced to ${result.path}`, 'success');
} else {
showToast('sync: ' + (result.error || 'failed'), 'error');
}
} catch (e) {
showToast('sync error: ' + e.message, 'error');
}
}
// ── Search ──────────────────────────────────────────────────────
async function doSearch() {
const q = document.getElementById('search-input').value.trim();
if (!q) return;
const area = document.getElementById('content-area');
area.innerHTML = '<div class="loading">searching…</div>';
try {
const results = await api('GET', `/search?q=${encodeURIComponent(q)}`);
state.searchResults = results;
renderSearchResults(results, q);
} catch (e) {
area.innerHTML = `<div class="empty-state">search error: ${e.message}</div>`;
}
}
function renderSearchResults(results, query) {
const area = document.getElementById('content-area');
if (results.length === 0) {
area.innerHTML = `<div class="empty-state">no results for "${query}"</div>`;
return;
}
area.innerHTML = `
<div style="margin-bottom:0.75rem;font-size:0.7rem;color:var(--ink-dim);text-transform:uppercase;letter-spacing:0.08em;">
${results.length} result${results.length !== 1 ? 's' : ''} for "${query}"
</div>
${results.map(r => {
const snippet = (r.content || '').slice(0, 300);
return `
<div class="search-result-item" onclick="selectProjectFile('${r.project_id}', '${r.file_path || 'CONTEXT.MD'}')">
<div class="src">${r.source_type || '?'}</div>
<div class="path">${r.project_id}/${r.file_path || ''}</div>
<div class="snippet">${escapeHtml(snippet)}</div>
</div>
`;
}).join('')}
`;
}
// ── Tab switching ────────────────────────────────────────────────
function switchTab(tab, options = {}) {
const shouldRender = options.render !== false;
state.currentTab = tab;
document.querySelectorAll('.sidebar-footer button').forEach(b => {
b.classList.toggle('active-tab', b.dataset.tab === tab);
});
if (!shouldRender) return;
const area = document.getElementById('content-area');
if (tab === 'context') {
if (state.currentProject && state.currentFile) {
loadFile(state.currentProject, state.currentFile);
} else if (state.currentProject) {
loadProjectFiles(state.currentProject);
} else {
area.innerHTML = '<div class="loading">select a project from the sidebar</div>';
}
} else if (tab === 'audit') {
loadAudit();
} else if (tab === 'snapshots') {
loadSnapshots();
}
}
async function loadAudit() {
const area = document.getElementById('content-area');
area.innerHTML = '<div class="loading">loading audit log…</div>';
try {
const entries = await api('GET', '/audit?limit=50');
area.innerHTML = `
<h1>audit log</h1>
<table class="audit-table">
<thead><tr>
<th>time</th><th>user</th><th>agent</th><th>op</th><th>project</th><th>summary</th>
</tr></thead>
<tbody>
${entries.map(e => `
<tr>
<td style="white-space:nowrap;font-size:0.7rem">${(e.created_at || '').slice(0,19)}</td>
<td>${e.user_id || ''}</td>
<td style="font-size:0.7rem">${e.agent_id || ''}</td>
<td><span style="color:var(--accent)">${e.operation || ''}</span></td>
<td style="font-size:0.7rem">${e.project_id || ''}</td>
<td>${escapeHtml((e.summary || '').slice(0, 80))}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (e) {
area.innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
}
}
async function loadSnapshots() {
const area = document.getElementById('content-area');
if (!state.currentProject) {
area.innerHTML = '<div class="loading">select a project to view snapshots</div>';
return;
}
area.innerHTML = '<div class="loading">loading snapshots…</div>';
try {
const snaps = await api('GET', `/projects/${state.currentProject}/snapshots`);
area.innerHTML = `
<h1>snapshots · ${state.currentProject}</h1>
${snaps.length === 0 ? '<div class="empty-state">no snapshots yet</div>' : `
<table class="audit-table">
<thead><tr><th>time</th><th>from</th><th>to</th><th>size</th><th>hash</th></tr></thead>
<tbody>
${snaps.map(s => `
<tr>
<td style="white-space:nowrap;font-size:0.7rem">${(s.created_at || '').slice(0,19)}</td>
<td>v${s.version_from}</td>
<td>v${s.version_to}</td>
<td>${s.size_bytes || 0}b</td>
<td style="font-size:0.65rem;color:var(--ink-dim)">${(s.content_hash || '').slice(0,12)}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
} catch (e) {
area.innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
}
}
// ── New project dialog ──────────────────────────────────────────
function showNewProjectDialog() {
document.getElementById('new-project-dialog').classList.add('active');
document.getElementById('new-pid').value = '';
document.getElementById('new-name').value = '';
document.getElementById('new-desc').value = '';
document.getElementById('import-file-name').textContent = 'no file selected';
document.getElementById('import-file').value = '';
setTimeout(() => document.getElementById('new-pid').focus(), 100);
}
function hideNewProjectDialog() {
document.getElementById('new-project-dialog').classList.remove('active');
}
// ── File upload helpers ─────────────────────────────────────────
let pendingImportFile = null;
document.getElementById('import-file').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
document.getElementById('import-file-name').textContent = file.name;
pendingImportFile = file;
}
});
document.getElementById('import-file-existing').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
document.getElementById('import-file-name-existing').textContent = file.name;
}
});
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
async function createProject() {
const pid = document.getElementById('new-pid').value.trim();
const name = document.getElementById('new-name').value.trim() || pid;
const desc = document.getElementById('new-desc').value.trim();
if (!pid) { showToast('project id is required', 'error'); return; }
try {
await api('POST', '/projects', { project_id: pid, display_name: name, description: desc });
// If a file was selected, import it as the project context
if (pendingImportFile) {
const content = await readFileAsText(pendingImportFile);
await api('POST', `/projects/${pid}/import`, {
content,
updated_by: 'admin',
base_version: 0,
});
showToast(`created ${pid} · imported ${pendingImportFile.name}`, 'success');
pendingImportFile = null;
} else {
showToast(`created ${pid}`, 'success');
}
hideNewProjectDialog();
await loadProjects();
selectProject(pid);
} catch (e) {
showToast('create failed: ' + e.message, 'error');
}
}
// ── Import dialog (existing project) ────────────────────────────
function showImportDialog() {
document.getElementById('import-dialog').classList.add('active');
document.getElementById('import-file-name-existing').textContent = 'no file selected';
document.getElementById('import-file-existing').value = '';
}
function hideImportDialog() {
document.getElementById('import-dialog').classList.remove('active');
}
async function importToExisting() {
const fileInput = document.getElementById('import-file-existing');
const file = fileInput.files[0];
if (!file) { showToast('select a file first', 'error'); return; }
if (!state.currentProject) { showToast('no project selected', 'error'); return; }
try {
const content = await readFileAsText(file);
const result = await api('POST', `/projects/${state.currentProject}/import`, {
content,
updated_by: 'admin',
base_version: state.currentVersion,
});
if (result.ok) {
showToast(`imported ${file.name} · v${result.new_version}`, 'success');
hideImportDialog();
await ensureProjectFiles(state.currentProject, true);
await selectFile(state.currentFile || 'CONTEXT.MD');
} else if (result.error === 'conflict') {
showToast('version conflict — reload and try again', 'error');
} else {
showToast('import failed: ' + (result.error || 'unknown'), 'error');
}
} catch (e) {
showToast('import failed: ' + e.message, 'error');
}
}
// ── Utilities ───────────────────────────────────────────────────
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ── Init ─────────────────────────────────────────────────────────
checkStatus();
loadProjects();
</script>
</body>
</html>