@polderlabs/openkan 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -159,6 +159,66 @@ function escapeHtml(text) {
159
159
  function normalizeWhitespace(text) {
160
160
  return text.replace(/\s+/g, " ").trim();
161
161
  }
162
+ // ─── Inline markdown → HTML ─────────────────────────────────────────────────────
163
+ //
164
+ // Transforms a single line of plain text into HTML that handles the most common
165
+ // inline constructs: `code`, **bold**, *italic*, __bold__, _italic_, and
166
+ // [text](url). MDX-style `{expr}` is escaped so it shows up literally without
167
+ // crashing the render — we don't have a real JSX runtime. The final result is
168
+ // fed back through escapeHtml so any literal HTML still survives sanitisation
169
+ // as escaped entities.
170
+ //
171
+ // Order matters:
172
+ // 1. Inline code is matched first so its contents are never re-interpreted.
173
+ // 2. Bold is matched before italic because `**` would otherwise partially
174
+ // match `*` runs.
175
+ // 3. Links and images are matched last so the link text gets one pass of
176
+ // bold/italic without infinite recursion.
177
+ // 4. Anything that looks like an MDX expression `{...}` is neutralised to
178
+ // `{...}` so the braces stay visible but never confuse a
179
+ // downstream JSX-aware consumer.
180
+ function renderInlineMarkdown(line) {
181
+ if (!line)
182
+ return "";
183
+ // 1. Carve out inline code (and remember the placeholders so we can put the
184
+ // code back after the other patterns have run).
185
+ const codeSlots = [];
186
+ const withPlaceholders = line.replace(/`([^`\n]+)`/g, (_match, code) => {
187
+ const idx = codeSlots.length;
188
+ codeSlots.push(`<code>${escapeHtml(code)}</code>`);
189
+ return `CODE${idx}`;
190
+ });
191
+ // 2. Escape the remaining text so any literal HTML shows up as text.
192
+ let escaped = escapeHtml(withPlaceholders);
193
+ // 3. Neutralise MDX-style `{expr}` so the braces survive visually but don't
194
+ // get interpreted downstream. Match an opening brace followed by any
195
+ // non-empty, non-brace, non-newline body and a closing brace.
196
+ escaped = escaped.replace(/&#123;([^{}\n]+)&#125;/g, (_match, inner) => `&#123;${inner}&#125;`);
197
+ // 4. Inline images: ![alt](url) — must come before links so the leading `!`
198
+ // isn't accidentally consumed.
199
+ escaped = escaped.replace(/!\[([^\]\n]*)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, (_match, alt, url) => {
200
+ if (!/^https?:|^mailto:|^data:image\//i.test(url))
201
+ return _match;
202
+ return `<img src="${url}" alt="${escapeHtml(alt)}" />`;
203
+ });
204
+ // 5. Links: [text](url) — URL must be a safe scheme.
205
+ escaped = escaped.replace(/\[([^\]\n]+)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, (_match, text, url) => {
206
+ if (!/^https?:|^mailto:|^data:image\//i.test(url))
207
+ return _match;
208
+ return `<a href="${url}">${text}</a>`;
209
+ });
210
+ // 6. Bold (must come before italic): **...** or __...__
211
+ escaped = escaped.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
212
+ escaped = escaped.replace(/__([^_\n]+)__/g, "<strong>$1</strong>");
213
+ // 7. Italic: *...* or _..._ — guard against the middle-of-word underscores
214
+ // in things like `foo_bar_baz` by requiring the surrounding characters to
215
+ // not be word characters.
216
+ escaped = escaped.replace(/(^|[^*\w])\*([^*\n]+)\*(?=[^*\w]|$)/g, "$1<em>$2</em>");
217
+ escaped = escaped.replace(/(^|[^\w_])_([^_\n]+)_(?=[^\w_]|$)/g, "$1<em>$2</em>");
218
+ // 8. Restore code placeholders.
219
+ escaped = escaped.replace(/CODE(\d+)/g, (_match, idx) => codeSlots[Number(idx)] ?? "");
220
+ return escaped;
221
+ }
162
222
  function blockPreview(blockLines) {
163
223
  return normalizeWhitespace(trimLines(blockLines.join(" "))).slice(0, 80);
164
224
  }
@@ -220,7 +280,7 @@ function blockToHtml(block, siblingIndex, opts) {
220
280
  case "heading": {
221
281
  const m = trimmed.match(/^(#{1,6})\s(.*)/);
222
282
  const level = m ? m[1].length : 2;
223
- inner = `<h${level}>${escapeHtml(m ? m[2] : trimmed.replace(/^#+\s/, ""))}</h${level}>`;
283
+ inner = `<h${level}>${renderInlineMarkdown(m ? m[2] : trimmed.replace(/^#+\s/, ""))}</h${level}>`;
224
284
  break;
225
285
  }
226
286
  case "code": {
@@ -233,28 +293,28 @@ function blockToHtml(block, siblingIndex, opts) {
233
293
  case "list": {
234
294
  const items = block.lines.map((l) => {
235
295
  const text = l.replace(/^\s*[-*+]\s/, "").replace(/^\s*\d+\.\s/, "");
236
- return `<li>${escapeHtml(text.trim())}</li>`;
296
+ return `<li>${renderInlineMarkdown(text.trim())}</li>`;
237
297
  }).join("");
238
298
  inner = trimmed.startsWith("1.") ? `<ol>${items}</ol>` : `<ul>${items}</ul>`;
239
299
  break;
240
300
  }
241
301
  case "quote": {
242
302
  const text = block.lines.map((l) => l.replace(/^>\s?/, "")).join(" ");
243
- inner = `<blockquote>${escapeHtml(text.trim())}</blockquote>`;
303
+ inner = `<blockquote>${renderInlineMarkdown(text.trim())}</blockquote>`;
244
304
  break;
245
305
  }
246
306
  case "table": {
247
307
  // Simple pipe table: first row = header, second row = separator, rest = body
248
308
  const rows = block.lines.map((l) => l.trim()).filter((l) => l.startsWith("|"));
249
309
  if (rows.length < 2) {
250
- inner = `<p>${escapeHtml(trimmed)}</p>`;
310
+ inner = `<p>${renderInlineMarkdown(trimmed)}</p>`;
251
311
  }
252
312
  else {
253
313
  const cells = rows[0].split("|").map((c) => c.trim()).filter((c) => c !== "");
254
- const header = `<thead><tr>${cells.map((c) => `<th>${escapeHtml(c)}</th>`).join("")}</tr></thead>`;
314
+ const header = `<thead><tr>${cells.map((c) => `<th>${renderInlineMarkdown(c)}</th>`).join("")}</tr></thead>`;
255
315
  const bodyRows = rows.slice(2).map((row) => {
256
316
  const cells = row.split("|").map((c) => c.trim()).filter((c) => c !== "");
257
- return `<tr>${cells.map((c) => `<td>${escapeHtml(c)}</td>`).join("")}</tr>`;
317
+ return `<tr>${cells.map((c) => `<td>${renderInlineMarkdown(c)}</td>`).join("")}</tr>`;
258
318
  }).join("");
259
319
  inner = `<table>${header}<tbody>${bodyRows}</tbody></table>`;
260
320
  }
@@ -262,7 +322,7 @@ function blockToHtml(block, siblingIndex, opts) {
262
322
  }
263
323
  case "paragraph":
264
324
  default: {
265
- inner = `<p>${escapeHtml(normalizeWhitespace(trimmed))}</p>`;
325
+ inner = `<p>${renderInlineMarkdown(normalizeWhitespace(trimmed))}</p>`;
266
326
  break;
267
327
  }
268
328
  }
@@ -286,7 +346,7 @@ export function stripMdxFrontmatter(mdx) {
286
346
  // ─── Main render function ──────────────────────────────────────────────────────
287
347
  const SANITIZE_ALLOWED_TAGS = new Set([
288
348
  "h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li",
289
- "code", "pre", "blockquote", "a", "strong", "em", "hr", "br",
349
+ "code", "pre", "blockquote", "a", "strong", "em", "hr", "br", "img",
290
350
  "table", "thead", "tbody", "tr", "th", "td",
291
351
  "section", "iframe", "button", "input", "select", "option", "label",
292
352
  ]);
@@ -335,10 +395,18 @@ export async function renderMdx(source, opts) {
335
395
  });
336
396
  let html = htmlParts.join("\n");
337
397
  // Sanitize final HTML
398
+ //
399
+ // `allowedSchemes` + `allowedSchemesByTag` + `allowedSchemesAppliedToAttributes`
400
+ // already strip `javascript:` URLs. The renderInlineMarkdown helper also
401
+ // refuses to emit an `<a href>` for unsafe schemes — so we don't need an
402
+ // additional `urlFilter` callback (which isn't part of sanitize-html's public
403
+ // type surface anyway). The scheme list below is the single source of truth.
338
404
  const clean = sanitizeHtml(html, {
339
405
  allowedTags: Array.from(SANITIZE_ALLOWED_TAGS),
340
406
  allowedAttributes: SANITIZE_ALLOWED_ATTRS,
341
407
  allowedSchemes: SANITIZE_ALLOWED_SCHEMES,
408
+ allowedSchemesByTag: { a: ["http", "https", "mailto"], img: ["http", "https", "data"] },
409
+ allowedSchemesAppliedToAttributes: ["href", "src"],
342
410
  });
343
411
  return {
344
412
  html: clean,
package/dist/web/app.js CHANGED
@@ -719,7 +719,7 @@
719
719
  }
720
720
 
721
721
  function effectiveState(t) {
722
- return t.state ?? t.status ?? "idle";
722
+ return window.OpenKanStatus?.displayState(t) ?? "pending";
723
723
  }
724
724
 
725
725
  function makeTagChip(tag, opts = {}) {
@@ -198,7 +198,7 @@
198
198
  const list = el("div", "contributor-tasks");
199
199
  for (const t of tasks) {
200
200
  const item = el("div", "contributor-task", { "data-id": t.id });
201
- const dot = el("span", `task-status-dot state-${t.state ?? t.status ?? "idle"}`);
201
+ const dot = el("span", `task-status-dot state-${window.OpenKanStatus?.displayState(t) ?? "pending"}`);
202
202
  item.append(dot);
203
203
  item.append(el("span", "task-title-text", { text: t.title || "(untitled)" }));
204
204
  item.append(el("span", "task-column-text", { text: t.column || "" }));
@@ -1,4 +1,7 @@
1
- // OpenKan Docs — preview-first MDX workspace with an always-available source editor.
1
+ // OpenKan Docs — preview-first MDX workspace with an in-place source editor.
2
+ // The rendered preview is the default surface; an "Edit" button swaps it for a
3
+ // toolbar + textarea so the user can shape MDX without leaving the page, then
4
+ // calls back into the existing /api/docs/* API to persist.
2
5
  (() => {
3
6
  "use strict";
4
7
  const { api } = window.OpenKanAPI;
@@ -56,19 +59,36 @@
56
59
  else state.expandedDirs.delete(directory.dataset.docDirectory);
57
60
  }));
58
61
  }
62
+ function renderPreviewBody() {
63
+ if (state.html) return state.html;
64
+ if (state.editing) return `<section class="docs-preview-empty"><h3>Start a durable note.</h3><p>Markdown previews here as you write. **bold**, *italic*, ` + "`code`" + `, and [links](https://example.com) all render.</p></section>`;
65
+ return `<section class="docs-preview-empty"><h3>Pick a document, or start a new one.</h3><p>The rendered MDX lives here. Click <strong>Edit</strong> above to open the source panel — the preview keeps pace as you type.</p><button data-doc-action="new">New document</button></section>`;
66
+ }
67
+ function editorView() {
68
+ return `<section class="docs-editor-shell" aria-label="Source editor">${sourceToolbar()}<textarea id="docs-editor-input" spellcheck="true" placeholder="# Start writing…">${esc(state.content)}</textarea><footer class="docs-editor-foot"><span>${state.content.length.toLocaleString()} characters</span><span class="docs-editor-hint">MDX / Markdown · preview updates as you type</span></footer></section>`;
69
+ }
59
70
  function render() {
60
71
  if (!state?.root) return;
61
72
  const files = flatten(state.entries).filter((entry) => DOC_FILE_PATTERN.test(entry.path));
62
73
  const status = state.dirty ? "Unsaved draft" : state.path ? `Saved ${relativeTime(state.mtime)}` : "New draft";
74
+ const editLabel = state.editing ? "Done editing" : "Edit document";
75
+ const stageBody = state.editing
76
+ ? editorView()
77
+ : `<article class="docs-rendered docs-mdx-preview" tabindex="0" data-doc-preview>${renderPreviewBody()}</article>`;
63
78
  state.root.innerHTML = `<section class="docs-shell">
64
- <header class="docs-commandbar"><div><span class="workspace-eyebrow">Knowledge workspace</span><h2>Docs that stay readable while you write.</h2><p>MDX renders as the document itself. Open source only when you need to shape it.</p></div><div class="docs-command-actions"><button data-doc-action="new">New document</button><button class="docs-agent-button" data-doc-action="generate">Generate with agent</button></div></header>
65
- <div class="docs-workspace docs-workspace--${state.sourceOpen ? "source-open" : "preview-only"}">
66
- <aside class="docs-sidebar"><div class="docs-sidebar-head"><div><span>docs/</span><small>${files.length} documents · folders preserved</small></div><button data-doc-action="new" aria-label="New document">+</button></div><div class="docs-sidebar-search"><input type="search" value="${esc(state.docFilter || "")}" placeholder="Filter documents" aria-label="Filter documents" data-doc-filter></div><div class="docs-file-list docs-folder-tree" data-doc-list></div></aside>
67
- <main class="docs-stage"><header class="docs-filebar"><div class="docs-file-ident"><span class="docs-file-icon">⌁</span><span><strong>${esc(fileName(state.path))}</strong><small>${esc(state.path || "Choose a path before saving")} · ${status}</small></span></div><div class="docs-file-actions"><button data-doc-action="source" aria-pressed="${state.sourceOpen}">${state.sourceOpen ? "Hide source" : "Edit source"}</button><button data-doc-action="save" ${state.path ? "" : "disabled"}>Save</button><button data-doc-action="more" aria-label="More document actions">•••</button></div></header>
68
- <div class="docs-preview-toolbar"><span><i></i> Preview</span><button data-doc-action="focus-preview">Reading view</button><button data-doc-action="help">MDX help</button></div>
69
- <article class="docs-rendered docs-mdx-preview" tabindex="0" data-doc-preview>${state.html || `<section class="docs-preview-empty"><h3>Start a durable note.</h3><p>Create a document or choose one from the library. MDX previews here exactly as it will be read.</p><button data-doc-action="new">New document</button></section>`}</article>
79
+ <div class="docs-workspace docs-workspace--${state.editing ? "editing" : "reading"}">
80
+ <aside class="docs-sidebar"><div class="docs-sidebar-head"><div><span>docs/</span><small>${files.length} documents · folders preserved</small></div><button data-doc-action="new" aria-label="New document">+</button></div><div class="docs-sidebar-search"><input type="search" value="${esc(state.docFilter || "")}" placeholder="Filter documents" aria-label="Filter documents" data-doc-filter></div><div class="docs-file-list docs-folder-tree" data-doc-list></div><div class="docs-sidebar-foot"><button data-doc-action="generate">Generate with agent</button></div></aside>
81
+ <main class="docs-stage">
82
+ <header class="docs-filebar"><div class="docs-file-ident"><span class="docs-file-icon">⌁</span><span><strong>${esc(fileName(state.path))}</strong><small>${esc(state.path || "Choose a path before saving")} · ${status}</small></span></div>
83
+ <div class="docs-file-actions">
84
+ ${state.editing
85
+ ? `<button class="docs-btn-secondary" data-doc-action="cancel-edit">Discard</button><button class="docs-btn-primary" data-doc-action="save" ${state.path ? "" : "disabled"}>Save changes</button>`
86
+ : `<button class="docs-btn-secondary" data-doc-action="help" title="MDX syntax help">MDX help</button><button class="docs-btn-primary" data-doc-action="source" aria-pressed="${state.editing}">${editLabel}</button>`}
87
+ <button data-doc-action="more" aria-label="More document actions" class="docs-btn-icon">•••</button>
88
+ </div>
89
+ </header>
90
+ ${stageBody}
70
91
  </main>
71
- <aside class="docs-source-panel" aria-label="Markdown source"><header><div><span>Source</span><small>MDX / Markdown</small></div><button data-doc-action="source" aria-label="Close source">×</button></header>${sourceToolbar()}<textarea id="docs-editor-input" spellcheck="true" placeholder="# Start writing…">${esc(state.content)}</textarea><footer><span>${state.content.length.toLocaleString()} characters</span><button data-doc-action="render">Refresh preview</button></footer></aside>
72
92
  </div><div id="docs-context-menu" class="docs-context-menu" hidden></div></section>`;
73
93
  wire();
74
94
  }
@@ -94,7 +114,7 @@
94
114
  if (!path || !state) return;
95
115
  const doc = await api("GET", `/api/docs/${encodeURI(path)}?raw=0`);
96
116
  openParentDirectories(path);
97
- Object.assign(state, { path, content: doc.raw || "", html: doc.html || doc.rendered || "", mtime: doc.mtime || "", dirty: false, sourceOpen: false });
117
+ Object.assign(state, { path, content: doc.raw || "", html: doc.html || doc.rendered || "", mtime: doc.mtime || "", dirty: false, editing: false });
98
118
  render();
99
119
  }
100
120
  async function refresh({ loadInitial = false } = {}) {
@@ -123,11 +143,12 @@
123
143
  await renderPreview();
124
144
  const doc = await api("PUT", `/api/docs/${encodeURI(state.path)}`, { content: state.content });
125
145
  Object.assign(state, { html: doc.html || doc.rendered || state.html, mtime: doc.mtime || new Date().toISOString(), dirty: false });
146
+ state.editing = false;
126
147
  await refresh();
127
148
  }
128
149
  function insertAtSelection(before, after = "", placeholder = "text") {
129
150
  const textarea = state.root.querySelector("#docs-editor-input");
130
- if (!textarea) { state.sourceOpen = true; render(); requestAnimationFrame(() => insertAtSelection(before, after, placeholder)); return; }
151
+ if (!textarea) { state.editing = true; render(); requestAnimationFrame(() => insertAtSelection(before, after, placeholder)); return; }
131
152
  const start = textarea.selectionStart, end = textarea.selectionEnd;
132
153
  const chosen = textarea.value.slice(start, end) || placeholder;
133
154
  const output = `${textarea.value.slice(0, start)}${before}${chosen}${after}${textarea.value.slice(end)}`;
@@ -144,25 +165,33 @@
144
165
  if (kind === "link") { const url = prompt("Link URL", "https://"); if (url) insertAtSelection("[", `](${url})`, "link text"); }
145
166
  }
146
167
  async function action(name) {
147
- if (name === "new") { const path = askPath("notes/new-document.mdx"); if (!path) return; Object.assign(state, { path, content: "# New document\n\n", html: "<h1>New document</h1>", mtime: "", dirty: true, sourceOpen: true }); return render(); }
148
- if (name === "source") { state.sourceOpen = !state.sourceOpen; return render(); }
168
+ if (name === "new") { const path = askPath("notes/new-document.mdx"); if (!path) return; Object.assign(state, { path, content: "# New document\n\n", html: "<h1>New document</h1>", mtime: "", dirty: true, editing: true }); return render(); }
169
+ if (name === "source") { state.editing = !state.editing; return render(); }
170
+ if (name === "cancel-edit") {
171
+ // Reload the canonical content from the server so we drop unsaved edits.
172
+ if (state.path) {
173
+ const doc = await api("GET", `/api/docs/${encodeURI(state.path)}?raw=0`);
174
+ Object.assign(state, { content: doc.raw || "", html: doc.html || doc.rendered || "", mtime: doc.mtime || "", dirty: false, editing: false });
175
+ } else {
176
+ state.editing = false;
177
+ }
178
+ return render();
179
+ }
149
180
  if (name === "save") return save();
150
- if (name === "render") { await renderPreview(); return render(); }
151
- if (name === "focus-preview") { state.sourceOpen = false; render(); state.root.querySelector("[data-doc-preview]")?.focus(); return; }
152
181
  if (name === "more") return openMoreMenu();
153
182
  if (name === "help") return showHelp();
154
183
  if (name === "generate") return generate();
155
184
  }
156
- function showHelp() { alert("OpenKan MDX\n\n# Heading\n**bold** · *italic* · `code`\n- bullet\n1. numbered\n[link](https://example.com)\n\nUse the source panel for exact MDX. The Preview stays rendered through OpenKan's MDX pipeline, including headings, tables, code, quotes, lists, and supported MDX blocks."); }
185
+ function showHelp() { alert("OpenKan MDX\n\n# Heading\n**bold** · *italic* · `code`\n- bullet\n1. numbered\n[link](https://example.com)\n\nClick **Edit document** to switch into the source editor. The preview re-renders as you type and Save writes back to the same path."); }
157
186
  async function generate() {
158
187
  const path = askPath(state.path || "guides/new-guide.mdx"); if (!path) return;
159
188
  const promptText = prompt("What should the configured agent write?", "Create a practical, complete guide."); if (!promptText) return;
160
189
  state.root.classList.add("is-generating");
161
- try { const doc = await api("POST", "/api/docs/generate", { path, prompt: promptText, model: "default", effort: "high", permissionMode: "bypassPermissions" }); Object.assign(state, { path, content: doc.raw || "", html: doc.html || doc.rendered || "", mtime: doc.mtime || "", dirty: false, sourceOpen: false }); await refresh(); } finally { state?.root?.classList.remove("is-generating"); }
190
+ try { const doc = await api("POST", "/api/docs/generate", { path, prompt: promptText, model: "default", effort: "high", permissionMode: "bypassPermissions" }); Object.assign(state, { path, content: doc.raw || "", html: doc.html || doc.rendered || "", mtime: doc.mtime || "", dirty: false, editing: false }); await refresh(); } finally { state?.root?.classList.remove("is-generating"); }
162
191
  }
163
192
  function menuAt(html, x, y) { const menu = state.root.querySelector("#docs-context-menu"); menu.innerHTML = html; menu.hidden = false; menu.style.left = `${x}px`; menu.style.top = `${y}px`; return menu; }
164
193
  function dismissMenu(event) { if (!event?.target?.closest?.("#docs-context-menu")) { const menu = state?.root?.querySelector("#docs-context-menu"); if (menu) menu.hidden = true; } }
165
- function openMoreMenu() { const trigger = state.root.querySelector('[data-doc-action="more"]'); const box = trigger.getBoundingClientRect(); const menu = menuAt(`<button data-doc-menu="rename">Rename document</button><button data-doc-menu="delete" class="danger">Delete document</button>`, box.left, box.bottom + 6); menu.onclick = async (event) => { const actionName = event.target.dataset.docMenu; menu.hidden = true; if (actionName === "delete" && state.path && confirm(`Delete ${state.path}?`)) { await api("DELETE", `/api/docs/${encodeURI(state.path)}`); Object.assign(state, { path:"", content:"", html:"", dirty:false }); await refresh({ loadInitial:true }); } if (actionName === "rename" && state.path) { const next = askPath(state.path); if (!next || next === state.path) return; await api("PUT", `/api/docs/${encodeURI(next)}`, { content: state.content }); await api("DELETE", `/api/docs/${encodeURI(state.path)}`); state.path = next; await refresh(); } }; }
194
+ function openMoreMenu() { const trigger = state.root.querySelector('[data-doc-action="more"]'); const box = trigger.getBoundingClientRect(); const menu = menuAt(`<button data-doc-menu="rename">Rename document</button><button data-doc-menu="delete" class="danger">Delete document</button>`, box.left, box.bottom + 6); menu.onclick = async (event) => { const actionName = event.target.dataset.docMenu; menu.hidden = true; if (actionName === "delete" && state.path && confirm(`Delete ${state.path}?`)) { await api("DELETE", `/api/docs/${encodeURI(state.path)}`); Object.assign(state, { path:"", content:"", html:"", dirty:false, editing:false }); await refresh({ loadInitial:true }); } if (actionName === "rename" && state.path) { const next = askPath(state.path); if (!next || next === state.path) return; await api("PUT", `/api/docs/${encodeURI(next)}`, { content: state.content }); await api("DELETE", `/api/docs/${encodeURI(state.path)}`); state.path = next; await refresh(); } }; }
166
195
  function contextMenu(event) { const file = event.target.closest("[data-doc-path]"); const editor = event.target.closest("#docs-editor-input"); if (!file && !editor) return; event.preventDefault(); const menu = file ? menuAt(`<button data-doc-menu="open">Open</button><button data-doc-menu="rename">Rename</button><button data-doc-menu="delete" class="danger">Delete</button>`, event.clientX, event.clientY) : menuAt(`<button data-doc-format="h2">Heading</button><button data-doc-format="bold">Bold</button><button data-doc-format="italic">Italic</button><button data-doc-format="ul">Bulleted list</button><button data-doc-format="link">Link</button>`, event.clientX, event.clientY); menu.onclick = async (actionEvent) => { menu.hidden = true; const format = actionEvent.target.dataset.docFormat; if (format) return applyFormat(format); const menuAction = actionEvent.target.dataset.docMenu, path = file?.dataset.docPath; if (menuAction === "open") return load(path); if (menuAction === "delete" && confirm(`Delete ${path}?`)) { await api("DELETE", `/api/docs/${encodeURI(path)}`); return refresh({ loadInitial: path === state.path }); } if (menuAction === "rename") { const next = askPath(path); if (!next || next === path) return; const doc = await api("GET", `/api/docs/${encodeURI(path)}?raw=0`); await api("PUT", `/api/docs/${encodeURI(next)}`, { content: doc.raw || "" }); await api("DELETE", `/api/docs/${encodeURI(path)}`); if (path === state.path) state.path = next; return refresh(); } }; }
167
- window.OpenKanDocs = { async mount(root, options = {}) { if (state?.root === root) return; state = { root, entries: [], path:"", content:"", html:"", mtime:"", dirty:false, sourceOpen:false, docFilter:"", expandedDirs:new Set(), initialDoc:options.initialDoc || "" }; await refresh({ loadInitial:true }); }, unmount() { clearTimeout(renderTimer); state = null; } };
196
+ window.OpenKanDocs = { async mount(root, options = {}) { if (state?.root === root) return; state = { root, entries: [], path:"", content:"", html:"", mtime:"", dirty:false, editing:false, docFilter:"", expandedDirs:new Set(), initialDoc:options.initialDoc || "" }; await refresh({ loadInitial:true }); }, unmount() { clearTimeout(renderTimer); state = null; } };
168
197
  })();
@@ -83,7 +83,7 @@
83
83
  const metGoals = (Array.isArray(goals) ? goals : []).flatMap((prd) => prd.goals || []).filter((goal) => goal.status === "met").length;
84
84
  const docs = flatten(data.docs.entries || []).filter((entry) => /\.mdx?$/i.test(entry.path)).length;
85
85
  root.innerHTML = `<section class="home-view home-command-center">
86
- <header class="home-command-header"><div><span class="workspace-eyebrow">Workspace command center</span><h2>See the work before it becomes noise.</h2><p>One place for the active project, live operators, work in flight, and the signals that need your attention.</p></div><div class="home-command-actions"><button data-home-action="tasks">Open board</button><button data-home-action="agents">Inspect agents</button></div></header>
86
+ <header class="home-command-header"><div><span class="workspace-eyebrow">Workspace command center</span></div><div class="home-command-actions"><button data-home-action="tasks">Open board</button><button data-home-action="agents">Inspect agents</button></div></header>
87
87
  <section class="home-network-card"><div class="home-network-copy"><span class="home-section-label">LIVE WORKSPACE MAP</span><h3>${esc(projects.find((project) => project.active)?.name || "OpenKan")}</h3><p>${activeAgents.length ? `${activeAgents.length} agent${activeAgents.length === 1 ? " is" : "s are"} active across the workspace.` : "No agents are currently active. Start from a task or an agent session when you are ready."}</p><div class="home-network-legend"><span><i class="is-project"></i>Project</span><span><i class="is-agent"></i>Agent</span><span><i class="is-work"></i>Work</span></div></div><canvas class="home-network" aria-label="Animated workspace relationship map"></canvas></section>
88
88
  <section class="home-stat-grid"><article><span>Active tasks</span><strong>${total - (counts.done || 0)}</strong><small>${counts.doing || 0} in progress · ${counts.review || 0} in review</small></article><article><span>Projects</span><strong>${projects.length}</strong><small>${projects.filter((project) => project.active).length ? "1 currently selected" : "Select a project to begin"}</small></article><article><span>Knowledge base</span><strong>${docs}</strong><small>Markdown and MDX documents</small></article><article><span>Goals met</span><strong>${metGoals}</strong><small>Durable .ok planning goals</small></article></section>
89
89
  <section class="home-dashboard-grid"><article class="home-activity-card"><header><div><span class="home-section-label">FLOW</span><h3>Activity cadence</h3></div><span>${(data.velocity.days || []).length || 30} days</span></header>${flowSvg(data.velocity.days || [])}<footer><span>Quiet</span><span>Today</span></footer></article><article class="home-queue-card"><header><div><span class="home-section-label">QUEUE</span><h3>Where work sits</h3></div><button data-home-action="tasks">View board</button></header><div class="home-queue-list">${[["Backlog","backlog"],["Ready","todo"],["In progress","doing"],["Review","review"],["Done","done"]].map(([label, key]) => `<div><span>${label}</span><b>${counts[key] || 0}</b><i style="--queue:${Math.min(100, ((counts[key] || 0) / Math.max(1, total)) * 100)}%"></i></div>`).join("")}</div></article><article class="home-operators-card"><header><div><span class="home-section-label">OPERATORS</span><h3>Agents and projects</h3></div><button data-home-action="agents">All agents</button></header><div class="home-operator-list">${[...activeAgents, ...projects.slice(0, Math.max(0, 4 - activeAgents.length))].slice(0,4).map((item) => `<div><i class="${item.root ? "project" : "agent"}"></i><span><strong>${esc(item.name || item.id || short(item.root))}</strong><small>${esc(item.root ? short(item.root) : item.status || item.state || "Idle")}</small></span></div>`).join("") || "<p class=home-empty>No workspace signals yet.</p>"}</div></article></section>
@@ -104,8 +104,6 @@
104
104
  <div class="workspace-intro">
105
105
  <div>
106
106
  <span class="workspace-eyebrow">Project workspace</span>
107
- <h2>Move work forward</h2>
108
- <p>Plan, coordinate, and review every task in one durable board.</p>
109
107
  </div>
110
108
  <div id="board-overview" class="board-overview" aria-label="Board summary">
111
109
  <div class="overview-stat">
@@ -436,7 +434,7 @@
436
434
  </div>
437
435
 
438
436
  <footer class="footer">
439
- <span>OpenKan v0.3.0 · local-first workspace</span>
437
+ <span>OpenKan v0.3.0</span>
440
438
  </footer>
441
439
 
442
440
  <!-- api.js MUST load first; it exposes window.OpenKanAPI used by the others. -->
@@ -445,6 +443,9 @@
445
443
  <script src="cross-tab.js" defer></script>
446
444
  <script src="images.js" defer></script>
447
445
  <script src="mdx-viewer.js" defer></script>
446
+ <!-- status.js exposes window.OpenKanStatus used by task-view.js, app.js,
447
+ and contributors-view.js. Must load before any of them. -->
448
+ <script src="status.js" defer></script>
448
449
  <script src="task-view.js" defer></script>
449
450
  <script src="settings.js" defer></script>
450
451
  <script src="changelog-view.js" defer></script>
@@ -0,0 +1,40 @@
1
+ // web/status.js — shared task status derivation for the web UI.
2
+ //
3
+ // Mirrors `kanban/board.ts:mapColumnToStatus()` so the board cards and
4
+ // opened-task panel render the same canonical state. The server stores
5
+ // `state` only when an explicit lifecycle state exists (running / done /
6
+ // failed / cancelled / waiting-for-input); for the rest it defaults to
7
+ // `idle` regardless of column, which made every card look idle. This
8
+ // helper falls back to column + archived when `state` is the default
9
+ // idle marker.
10
+ //
11
+ // Output values: "pending" | "in_progress" | "review" | "done" |
12
+ // "cancelled" | "running" | "waiting-for-input" | "failed".
13
+ // (The trailing four mirror the explicit lifecycle states so the helper
14
+ // stays lossless when the server has already recorded one.)
15
+
16
+ (function () {
17
+ "use strict";
18
+
19
+ function displayState(task) {
20
+ if (!task) return "pending";
21
+ if (task.archived) return "cancelled";
22
+ const explicit = task.state ?? task.status;
23
+ // Explicit lifecycle states pass through unchanged so callers can
24
+ // still observe "running", "failed", "waiting-for-input", etc.
25
+ if (explicit && explicit !== "idle") return explicit;
26
+ // Otherwise derive from the column. The server defaults `state` to
27
+ // "idle" for everything that hasn't been claimed by an agent, so we
28
+ // trust the column here.
29
+ switch (task.column) {
30
+ case "doing": return "in_progress";
31
+ case "review": return "review";
32
+ case "done": return "done";
33
+ case "todo":
34
+ case "backlog":
35
+ default: return "pending";
36
+ }
37
+ }
38
+
39
+ window.OpenKanStatus = { displayState };
40
+ })();
@@ -473,6 +473,31 @@ body.chat-sidebar-resizing {
473
473
  flex-direction: column;
474
474
  gap: 8px;
475
475
  scrollbar-width: thin;
476
+ /* Firefox thumb + track — thumb uses the muted --border-strong so the
477
+ scrollbar disappears into the column chrome until hovered. */
478
+ scrollbar-color: var(--border-strong) transparent;
479
+ }
480
+
481
+ /* Webkit/Blink override (Chrome / Safari / Edge). The base ::-webkit-scrollbar
482
+ rule near the top of this file targets the whole page; .column-body is the
483
+ only task-list scroll container, so we scope and shrink it here. Width is
484
+ intentional: 8px wide with a 2px transparent border on the thumb yields a
485
+ ~4px visible pill, subtle against the column body surface. */
486
+ .column-body::-webkit-scrollbar {
487
+ width: 8px;
488
+ height: 8px;
489
+ }
490
+ .column-body::-webkit-scrollbar-track {
491
+ background: transparent;
492
+ }
493
+ .column-body::-webkit-scrollbar-thumb {
494
+ background: var(--border-strong);
495
+ border: 2px solid transparent;
496
+ border-radius: 999px;
497
+ background-clip: padding-box;
498
+ }
499
+ .column-body::-webkit-scrollbar-thumb:hover {
500
+ background: var(--border);
476
501
  }
477
502
 
478
503
  .column-empty {
@@ -593,14 +618,19 @@ body.chat-sidebar-resizing {
593
618
  flex-shrink: 0;
594
619
  }
595
620
 
596
- .status-dot.idle {
621
+ .status-dot.idle,
622
+ .status-dot.pending {
597
623
  background: var(--idle);
598
624
  }
599
- .status-dot.running {
625
+ .status-dot.running,
626
+ .status-dot.in_progress {
600
627
  background: var(--running);
601
628
  animation: pulse 1.4s ease-in-out infinite;
602
629
  box-shadow: 0 0 0 0 rgba(68, 147, 248, 0.5);
603
630
  }
631
+ .status-dot.review {
632
+ background: var(--warn);
633
+ }
604
634
  .status-dot.done {
605
635
  background: var(--done);
606
636
  }
@@ -1164,8 +1194,14 @@ body.chat-sidebar-resizing {
1164
1194
  .state-pill {
1165
1195
  text-transform: capitalize;
1166
1196
  }
1167
- .state-pill.state-idle { color: var(--text-mute); }
1168
- .state-pill.state-running { color: var(--accent-hover); }
1197
+ .state-pill.state-idle,
1198
+ .state-pill.state-pending { color: var(--text-mute); }
1199
+ .state-pill.state-running,
1200
+ .state-pill.state-in_progress { color: var(--accent-hover); }
1201
+ .state-pill.state-review {
1202
+ color: var(--warn);
1203
+ border-color: rgba(210, 153, 34, 0.4);
1204
+ }
1169
1205
  .state-pill.state-waiting,
1170
1206
  .state-pill.state-waiting-for-input {
1171
1207
  color: var(--warn);
@@ -2409,6 +2445,87 @@ button.tag-chip {
2409
2445
  color: var(--text-mute);
2410
2446
  }
2411
2447
 
2448
+ /* ---------- Task meta "Show details" toggle (tsk-KNQwScRg) ----------
2449
+ Collapses the secondary metadata (column, category, priority, effort,
2450
+ runner, source, created/updated timestamps) behind a native <details>
2451
+ toggle. Priority is hidden behind the toggle too because the header
2452
+ status column already shows a priority pill next to the title — keeping
2453
+ it in two places was the loudest piece of chrome in the panel. The
2454
+ primary dl (stale warning + assignees + last activity) and the tag row
2455
+ stay above the fold. */
2456
+
2457
+ /* Smaller visual gap between the always-visible primary dl and the tags
2458
+ row — they read as one block. */
2459
+ .task-header-meta .meta-dl-primary {
2460
+ margin-bottom: 0;
2461
+ }
2462
+
2463
+ /* Native <details>/<summary> defaults — strip the disclosure marker and
2464
+ render a small caret that rotates when open. Keyboard accessible (Enter
2465
+ and Space toggle) and announced correctly by screen readers via the
2466
+ implicit role. */
2467
+ .task-header-meta .task-meta-details {
2468
+ border-top: 1px solid var(--border);
2469
+ padding-top: var(--space-2);
2470
+ }
2471
+ .task-header-meta .task-meta-details-toggle {
2472
+ list-style: none;
2473
+ cursor: pointer;
2474
+ display: inline-flex;
2475
+ align-items: center;
2476
+ gap: 6px;
2477
+ font-size: 11px;
2478
+ font-weight: 600;
2479
+ text-transform: uppercase;
2480
+ letter-spacing: 0.5px;
2481
+ color: var(--text-mute);
2482
+ padding: 2px 0;
2483
+ user-select: none;
2484
+ }
2485
+ .task-header-meta .task-meta-details-toggle::-webkit-details-marker {
2486
+ display: none;
2487
+ }
2488
+ .task-header-meta .task-meta-details-toggle::marker {
2489
+ display: none;
2490
+ content: "";
2491
+ }
2492
+ .task-header-meta .task-meta-details-toggle::before {
2493
+ content: "▸";
2494
+ display: inline-block;
2495
+ font-size: 10px;
2496
+ color: var(--text-mute);
2497
+ transition: transform var(--transition);
2498
+ }
2499
+ .task-header-meta .task-meta-details[open] > .task-meta-details-toggle::before {
2500
+ transform: rotate(90deg);
2501
+ }
2502
+ .task-header-meta .task-meta-details-toggle:focus-visible {
2503
+ outline: 2px solid var(--accent);
2504
+ outline-offset: 2px;
2505
+ border-radius: 2px;
2506
+ }
2507
+ .task-header-meta .task-meta-details-toggle:hover {
2508
+ color: var(--text);
2509
+ }
2510
+
2511
+ /* The collapsed dl sits inside the <details> body with a small indent so
2512
+ it visually nests under the toggle label. Uses the existing meta-dl
2513
+ grid from above. */
2514
+ .task-header-meta .meta-dl-details {
2515
+ margin-top: var(--space-2);
2516
+ padding-left: var(--space-4);
2517
+ border-left: 2px solid var(--border);
2518
+ }
2519
+
2520
+ /* Compact relative-time styling for the "Activity" cell in the primary
2521
+ dl — it stays front-and-center but at lower visual weight than the
2522
+ assignees row. */
2523
+ .task-header-meta .meta-dl dd.meta-last-activity {
2524
+ font-size: 12px;
2525
+ color: var(--text-mute);
2526
+ font-variant-numeric: tabular-nums;
2527
+ }
2528
+
2412
2529
  /* Keep the old class names as no-op aliases so any inline usage elsewhere
2413
2530
  still renders. New code should use the dl/dt/dd structure above. */
2414
2531
  .task-header-meta .meta-label {
@@ -8199,9 +8316,9 @@ body.workspace-mode-chat { padding-left: 0; }
8199
8316
  .home-command-header { display:flex; align-items:flex-end; justify-content:space-between; gap:24px; margin-bottom:24px; }
8200
8317
  .home-command-header h2 { max-width:720px; margin:5px 0 8px; color:var(--ink-100); font-size:clamp(30px,4vw,52px); letter-spacing:-.052em; line-height:.98; }
8201
8318
  .home-command-header p { max-width:650px; margin:0; color:var(--ink-60); line-height:1.55; }
8202
- .home-command-actions,.docs-command-actions { display:flex; flex-wrap:wrap; gap:8px; }
8203
- .home-command-actions button,.docs-command-actions button { min-height:36px; padding:0 11px; border:1px solid var(--border); border-radius:8px; background:var(--bg-elev); color:var(--ink-80); cursor:pointer; font:700 12px inherit; }
8204
- .home-command-actions button:hover,.docs-command-actions button:hover { border-color:var(--border-strong); background:var(--bg-elev-2); }
8319
+ .home-command-actions { display:flex; flex-wrap:wrap; gap:8px; }
8320
+ .home-command-actions button { min-height:36px; padding:0 11px; border:1px solid var(--border); border-radius:8px; background:var(--bg-elev); color:var(--ink-80); cursor:pointer; font:700 12px inherit; }
8321
+ .home-command-actions button:hover { border-color:var(--border-strong); background:var(--bg-elev-2); }
8205
8322
  .home-network-card { display:grid; grid-template-columns:minmax(230px,.72fr) minmax(350px,1.28fr); min-height:270px; overflow:hidden; border:1px solid var(--border); border-radius:18px; background:linear-gradient(120deg,color-mix(in srgb,var(--bg-elev) 96%,#121d38),var(--bg)); }
8206
8323
  .home-network-copy { display:flex; flex-direction:column; justify-content:center; padding:clamp(24px,4vw,50px); border-right:1px solid var(--border); }
8207
8324
  .home-section-label { color:var(--accent); font-size:10px; font-weight:800; letter-spacing:.1em; }
@@ -8214,32 +8331,203 @@ body.workspace-mode-chat { padding-left: 0; }
8214
8331
  .home-dashboard-grid { display:grid; grid-template-columns:1.1fr 1fr .9fr; gap:10px; }.home-dashboard-grid article { min-height:220px; padding:18px; }.home-dashboard-grid header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }.home-dashboard-grid h3 { margin:5px 0 0; color:var(--ink-100); font-size:15px; }.home-dashboard-grid header>span,.home-dashboard-grid header button { color:var(--ink-50); font-size:11px; }.home-dashboard-grid header button { padding:0;border:0;background:none;cursor:pointer; }.home-flow-chart { width:100%; height:122px; margin-top:20px; overflow:visible; }.home-flow-area { fill:color-mix(in srgb,var(--accent) 14%,transparent); }.home-flow-line { fill:none; stroke:var(--accent); stroke-width:2; }.home-flow-chart circle { fill:var(--accent); }.home-activity-card footer { display:flex; justify-content:space-between; color:var(--ink-50); font-size:10px; }.home-queue-list { display:grid; gap:10px; margin-top:22px; }.home-queue-list>div { display:grid; grid-template-columns:88px 24px 1fr; align-items:center; gap:8px; color:var(--ink-60); font-size:11px; }.home-queue-list b { color:var(--ink-100); text-align:right; }.home-queue-list i { display:block; height:4px; border-radius:5px; background:linear-gradient(90deg,var(--accent) var(--queue),var(--bg) var(--queue)); }.home-operator-list { display:grid; gap:10px; margin-top:20px; }.home-operator-list>div { display:flex; align-items:center; gap:8px; }.home-operator-list span { display:grid; min-width:0; }.home-operator-list strong { overflow:hidden; color:var(--ink-80); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }.home-operator-list small { color:var(--ink-50); font-size:10px; }.home-loading { padding:50px; color:var(--ink-60); }
8215
8332
  @media(max-width:900px){.home-network-card,.home-dashboard-grid{grid-template-columns:1fr}.home-network-copy{border-right:0;border-bottom:1px solid var(--border)}.home-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.home-command-header{align-items:flex-start;flex-direction:column}}@media(max-width:520px){.home-stat-grid{grid-template-columns:1fr}.home-command-center{padding:22px 16px}}
8216
8333
 
8217
- /* Preview-first docs: the rendered MDX document owns the canvas, while source
8218
- slides in as an editor instead of replacing the reading experience. */
8334
+ /* Docs workspace — Preview-first docs page, no banner.
8335
+ The renderer is the default surface. An "Edit document" button in the file
8336
+ bar swaps the rendered preview for an in-place source editor (toolbar +
8337
+ textarea). All chrome below stays in sync with the workspace-mode class on
8338
+ `.docs-workspace`. */
8219
8339
  #tab-docs { background:var(--bg); }
8220
8340
  .docs-shell { min-height:calc(100vh - var(--topbar-height)); background:var(--bg); }
8221
- .docs-commandbar { display:flex; align-items:flex-end; justify-content:space-between; gap:24px; padding:28px clamp(20px,3vw,48px) 20px; border-bottom:1px solid var(--border); background:color-mix(in srgb,var(--bg-elev) 90%,transparent); }
8222
- .docs-commandbar h2 { margin:5px 0 7px; color:var(--ink-100); font-size:clamp(24px,3vw,36px); letter-spacing:-.04em; }.docs-commandbar p { max-width:640px; margin:0; color:var(--ink-60); line-height:1.5; }.docs-agent-button { border-color:color-mix(in srgb,var(--accent) 42%,var(--border)) !important; background:color-mix(in srgb,var(--accent) 11%,var(--bg-elev)) !important; }
8223
- .docs-workspace { display:grid; grid-template-columns:250px minmax(0,1fr) 0; min-height:calc(100vh - 183px); transition:grid-template-columns .22s ease; }.docs-workspace--source-open { grid-template-columns:250px minmax(0,1fr) minmax(320px,30vw); }.docs-sidebar { display:flex; flex-direction:column; min-width:0; padding:14px 10px; border-right:1px solid var(--border); background:var(--bg); }.docs-sidebar-head { display:flex; align-items:center; justify-content:space-between; padding:0 5px 10px; color:var(--ink-60); font:700 11px ui-monospace,monospace; }.docs-sidebar-head div { display:grid; gap:3px; }.docs-sidebar-head small { color:var(--ink-50); font:10px inherit; }.docs-sidebar-head button { width:28px;height:28px;border:1px solid var(--border);border-radius:7px;background:var(--bg-elev);color:var(--ink-80);cursor:pointer;font-size:17px; }.docs-sidebar-search { padding:0 3px 10px; }.docs-sidebar-search input { width:100%;box-sizing:border-box;min-height:31px;padding:0 9px;border:1px solid var(--border);border-radius:7px;background:var(--bg-elev);color:var(--text);font:11px inherit;outline:none; }.docs-sidebar-search input:focus { border-color:var(--border-strong); }.docs-file-list { display:grid; gap:3px; overflow:auto; padding:0 2px 16px; }.docs-file { display:grid; gap:2px; width:100%; min-width:0; padding:8px 9px; border:1px solid transparent !important; border-radius:8px !important; background:transparent !important; color:var(--ink-60) !important; cursor:pointer; text-align:left; }.docs-file span { overflow:hidden; color:var(--ink-50); font-size:9px; text-overflow:ellipsis; white-space:nowrap; }.docs-file strong { overflow:hidden; color:inherit; font-size:11px; text-overflow:ellipsis; white-space:nowrap; }.docs-file:hover { background:var(--bg-elev-2) !important; color:var(--ink-90) !important; }.docs-file.active { border-color:color-mix(in srgb,var(--accent) 34%,var(--border)) !important; background:color-mix(in srgb,var(--accent) 10%,var(--bg-elev)) !important; color:var(--ink-100) !important; }.docs-empty-tree { padding:16px 9px; color:var(--ink-50); font-size:11px; line-height:1.5; }.docs-stage { display:flex; min-width:0; flex-direction:column; background:var(--bg-elev); }.docs-filebar { display:flex; align-items:center; justify-content:space-between; gap:16px; min-height:58px; padding:0 22px; border-bottom:1px solid var(--border); }.docs-file-ident { display:flex; min-width:0; align-items:center; gap:9px; }.docs-file-ident>span:last-child { display:grid; gap:2px; min-width:0; }.docs-file-ident strong { overflow:hidden; color:var(--ink-90); font-size:13px; text-overflow:ellipsis; white-space:nowrap; }.docs-file-ident small { overflow:hidden; color:var(--ink-50); font-size:10px; text-overflow:ellipsis; white-space:nowrap; }.docs-file-icon { display:grid; width:28px;height:28px;place-items:center;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--accent); }.docs-file-actions { display:flex; gap:6px; }.docs-file-actions button,.docs-preview-toolbar button,.docs-source-panel button { min-height:28px;padding:0 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--ink-60);cursor:pointer;font:700 11px inherit; }.docs-file-actions button:hover,.docs-preview-toolbar button:hover,.docs-source-panel button:hover { border-color:var(--border-strong);color:var(--ink-100); }.docs-preview-toolbar { display:flex; align-items:center; gap:7px; min-height:42px; padding:0 22px; border-bottom:1px solid var(--border); background:var(--bg); }.docs-preview-toolbar>span { display:flex;align-items:center;gap:6px;margin-right:auto;color:var(--ink-60);font-size:11px;font-weight:700; }.docs-preview-toolbar i { width:6px;height:6px;border-radius:50%;background:var(--success);box-shadow:0 0 8px color-mix(in srgb,var(--success) 65%,transparent); }.docs-mdx-preview { max-width:900px; width:100%; box-sizing:border-box; margin:0 auto; padding:clamp(28px,5vw,72px); color:var(--ink-80); line-height:1.7; }.docs-mdx-preview h1,.docs-mdx-preview h2,.docs-mdx-preview h3,.docs-mdx-preview h4 { color:var(--ink-100); letter-spacing:-.03em; }.docs-mdx-preview h1 { margin-top:0; font-size:clamp(30px,4vw,50px); line-height:1.05; border:0; }.docs-mdx-preview h2 { margin-top:2.3em; font-size:1.7em; }.docs-mdx-preview h3 { margin-top:2em; }.docs-mdx-preview p,.docs-mdx-preview li { max-width:76ch; }.docs-mdx-preview pre { padding:16px; border:1px solid var(--border); border-radius:10px; background:var(--bg); overflow:auto; }.docs-mdx-preview table { width:100%; border-collapse:collapse; margin:22px 0; }.docs-mdx-preview th,.docs-mdx-preview td { padding:10px 12px; border:1px solid var(--border); text-align:left; }.docs-mdx-preview th { background:var(--bg); }.docs-mdx-preview blockquote { margin:20px 0;padding:8px 18px;border-left:3px solid var(--accent);color:var(--ink-60); }.docs-preview-empty { display:grid;place-items:start;gap:7px;min-height:280px;align-content:center; }.docs-preview-empty h3,.docs-preview-empty p { margin:0; }.docs-preview-empty button { margin-top:8px;padding:8px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--ink-80);cursor:pointer; }.docs-source-panel { display:flex; min-width:0; overflow:hidden; flex-direction:column; border-left:1px solid var(--border); background:var(--bg); opacity:0; pointer-events:none; transition:opacity .16s ease; }.docs-workspace--source-open .docs-source-panel { opacity:1; pointer-events:auto; }.docs-source-panel>header { display:flex;align-items:center;justify-content:space-between;min-height:58px;padding:0 14px;border-bottom:1px solid var(--border); }.docs-source-panel>header div { display:grid;gap:2px; }.docs-source-panel header span { color:var(--ink-80);font-size:12px;font-weight:800; }.docs-source-panel header small { color:var(--ink-50);font-size:10px; }.docs-source-tools { display:flex;flex-wrap:wrap;gap:4px;padding:8px 10px;border-bottom:1px solid var(--border); }.docs-source-panel textarea { flex:1; min-height:0; width:100%; box-sizing:border-box; padding:16px; border:0; outline:0; resize:none; background:var(--bg); color:var(--ink-80); font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace; }.docs-source-panel footer { display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:42px;padding:0 12px;border-top:1px solid var(--border);color:var(--ink-50);font-size:10px; }.docs-context-menu { position:fixed;z-index:150;display:grid;min-width:160px;padding:5px;border:1px solid var(--border-strong);border-radius:9px;background:var(--bg-elev);box-shadow:var(--shadow-2); }.docs-context-menu[hidden] { display:none; }.docs-context-menu button { padding:8px;border:0;border-radius:6px;background:transparent;color:var(--ink-70);cursor:pointer;text-align:left;font:600 11px inherit; }.docs-context-menu button:hover { background:var(--bg-elev-2);color:var(--ink-100); }.docs-context-menu .danger { color:var(--danger); }.docs-tab.is-generating { cursor:progress; }.docs-tab.is-generating .docs-stage { opacity:.6;pointer-events:none; }
8224
- @media(max-width:1050px){.docs-workspace--source-open{grid-template-columns:210px minmax(0,1fr) minmax(290px,34vw)}.docs-commandbar{align-items:flex-start;flex-direction:column}}@media(max-width:760px){.docs-workspace,.docs-workspace--source-open{grid-template-columns:1fr;min-height:0}.docs-sidebar{max-height:175px;border-right:0;border-bottom:1px solid var(--border)}.docs-source-panel{min-height:430px;border-left:0;border-top:1px solid var(--border)}.docs-mdx-preview{padding:32px 20px}.docs-filebar,.docs-preview-toolbar{padding-inline:14px}.docs-commandbar{padding:22px 16px 16px}}
8341
+ /* The old command bar (`Knowledge workspace / Docs that stay readable…`) is
8342
+ gone. We keep an inert selector so any leftover markup from a stale build
8343
+ collapses instead of stacking a phantom header on top of the file bar. */
8344
+ .docs-commandbar { display:none !important; }
8345
+ .docs-workspace { display:grid; grid-template-columns:250px minmax(0,1fr); min-height:calc(100vh - 56px); }
8346
+ .docs-workspace--editing { grid-template-columns:260px minmax(0,1fr); }
8347
+ .docs-sidebar { display:flex; flex-direction:column; min-width:0; padding:14px 10px; border-right:1px solid var(--border); background:var(--bg); gap:6px; }
8348
+ .docs-sidebar-head { display:flex; align-items:center; justify-content:space-between; padding:0 5px 10px; color:var(--ink-60); font:700 11px ui-monospace,monospace; }
8349
+ .docs-sidebar-head div { display:grid; gap:3px; }
8350
+ .docs-sidebar-head small { color:var(--ink-50); font:10px inherit; }
8351
+ .docs-sidebar-head button { width:28px;height:28px;border:1px solid var(--border);border-radius:7px;background:var(--bg-elev);color:var(--ink-80);cursor:pointer;font-size:17px; }
8352
+ .docs-sidebar-head button:hover { border-color:var(--border-strong); color:var(--ink-100); }
8353
+ .docs-sidebar-search { padding:0 3px 8px; }
8354
+ .docs-sidebar-search input { width:100%;box-sizing:border-box;min-height:31px;padding:0 9px;border:1px solid var(--border);border-radius:7px;background:var(--bg-elev);color:var(--text);font:11px inherit;outline:none; }
8355
+ .docs-sidebar-search input:focus { border-color:var(--border-strong); }
8356
+ .docs-file-list { display:grid; gap:3px; overflow:auto; padding:0 2px 12px; }
8357
+ .docs-sidebar-foot { padding:10px 3px 4px; border-top:1px solid var(--border); }
8358
+ .docs-sidebar-foot button { width:100%; padding:7px 10px; border:1px solid var(--border); border-radius:7px; background:var(--bg-elev); color:var(--ink-80); cursor:pointer; font:700 11px inherit; }
8359
+ .docs-sidebar-foot button:hover { border-color:var(--border-strong); color:var(--ink-100); }
8360
+ .docs-file { display:grid; gap:2px; width:100%; min-width:0; padding:8px 9px; border:1px solid transparent !important; border-radius:8px !important; background:transparent !important; color:var(--ink-60) !important; cursor:pointer; text-align:left; }
8361
+ .docs-file span { overflow:hidden; color:var(--ink-50); font-size:9px; text-overflow:ellipsis; white-space:nowrap; }
8362
+ .docs-file strong { overflow:hidden; color:inherit; font-size:11px; text-overflow:ellipsis; white-space:nowrap; }
8363
+ .docs-file:hover { background:var(--bg-elev-2) !important; color:var(--ink-90) !important; }
8364
+ .docs-file.active { border-color:color-mix(in srgb,var(--accent) 34%,var(--border)) !important; background:color-mix(in srgb,var(--accent) 10%,var(--bg-elev)) !important; color:var(--ink-100) !important; }
8365
+ .docs-empty-tree { padding:16px 9px; color:var(--ink-50); font-size:11px; line-height:1.5; }
8366
+ .docs-stage { display:flex; min-width:0; flex-direction:column; background:var(--bg-elev); }
8367
+ .docs-filebar { display:flex; align-items:center; justify-content:space-between; gap:16px; min-height:58px; padding:0 22px; border-bottom:1px solid var(--border); }
8368
+ .docs-file-ident { display:flex; min-width:0; align-items:center; gap:9px; }
8369
+ .docs-file-ident>span:last-child { display:grid; gap:2px; min-width:0; }
8370
+ .docs-file-ident strong { overflow:hidden; color:var(--ink-90); font-size:13px; text-overflow:ellipsis; white-space:nowrap; }
8371
+ .docs-file-ident small { overflow:hidden; color:var(--ink-50); font-size:10px; text-overflow:ellipsis; white-space:nowrap; }
8372
+ .docs-file-icon { display:grid; width:28px;height:28px;place-items:center;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--accent); }
8373
+ .docs-file-actions { display:flex; gap:6px; align-items:center; }
8374
+
8375
+ /* Button primitives — small, readable, consistent across the docs file bar. */
8376
+ .docs-btn-primary,
8377
+ .docs-btn-secondary,
8378
+ .docs-btn-icon,
8379
+ .docs-file-actions button { min-height:28px; padding:0 11px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--ink-70); cursor:pointer; font:700 11px/1 inherit; letter-spacing:.02em; transition: border-color .12s ease, color .12s ease, background .12s ease; }
8380
+ .docs-file-actions button:hover,
8381
+ .docs-btn-primary:hover,
8382
+ .docs-btn-secondary:hover,
8383
+ .docs-btn-icon:hover { border-color:var(--border-strong); color:var(--ink-100); }
8384
+ .docs-btn-primary { background: color-mix(in srgb, var(--accent) 14%, var(--bg-elev)); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); color: var(--ink-100); }
8385
+ .docs-btn-primary:hover { background: color-mix(in srgb, var(--accent) 22%, var(--bg-elev)); border-color: color-mix(in srgb, var(--accent) 65%, var(--border)); }
8386
+ .docs-btn-primary:disabled { opacity:.45; cursor:not-allowed; }
8387
+ .docs-btn-secondary { background:var(--bg-elev); }
8388
+ .docs-btn-icon { width:30px; min-height:28px; padding:0; font-size:13px; line-height:1; }
8389
+
8390
+ /* Rendered document — readable, breathable, capped. */
8391
+ .docs-mdx-preview {
8392
+ max-width: 760px;
8393
+ width: 100%;
8394
+ box-sizing: border-box;
8395
+ margin: 0 auto;
8396
+ padding: clamp(28px, 4.5vw, 56px) clamp(20px, 4vw, 48px) clamp(40px, 6vw, 96px);
8397
+ color: var(--ink-80);
8398
+ font-size: 15px;
8399
+ line-height: 1.72;
8400
+ letter-spacing: 0.005em;
8401
+ }
8402
+ .docs-mdx-preview > * + * { margin-top: 1.1em; }
8403
+ .docs-mdx-preview h1,
8404
+ .docs-mdx-preview h2,
8405
+ .docs-mdx-preview h3,
8406
+ .docs-mdx-preview h4 { color: var(--ink-100); letter-spacing: -.025em; line-height: 1.2; }
8407
+ .docs-mdx-preview h1 { margin-top: 0; font-size: clamp(28px, 3.6vw, 42px); font-weight: 700; }
8408
+ .docs-mdx-preview h2 { margin-top: 2.4em; font-size: 1.55em; font-weight: 700; }
8409
+ .docs-mdx-preview h3 { margin-top: 2em; font-size: 1.2em; font-weight: 700; color: var(--ink-90); }
8410
+ .docs-mdx-preview h4 { margin-top: 1.8em; font-size: 1em; font-weight: 700; color: var(--ink-90); text-transform: uppercase; letter-spacing: .06em; }
8411
+ .docs-mdx-preview p,
8412
+ .docs-mdx-preview li { max-width: 70ch; }
8413
+ .docs-mdx-preview ul,
8414
+ .docs-mdx-preview ol { padding-left: 1.4em; }
8415
+ .docs-mdx-preview li { margin: 4px 0; }
8416
+ .docs-mdx-preview li::marker { color: var(--ink-50); }
8417
+ .docs-mdx-preview hr { margin: 2.4em 0; border: 0; border-top: 1px solid var(--border); }
8418
+ .docs-mdx-preview strong { color: var(--ink-100); font-weight: 700; }
8419
+ .docs-mdx-preview em { color: var(--ink-90); }
8420
+ .docs-mdx-preview a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; text-decoration-thickness: 1px; }
8421
+ .docs-mdx-preview a:hover { text-decoration-thickness: 2px; }
8422
+ .docs-mdx-preview code {
8423
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
8424
+ font-size: 0.88em;
8425
+ padding: 2px 5px;
8426
+ border: 1px solid var(--border);
8427
+ border-radius: 5px;
8428
+ background: var(--bg);
8429
+ color: var(--ink-90);
8430
+ }
8431
+ .docs-mdx-preview pre {
8432
+ max-width: 100%;
8433
+ margin: 1.4em 0;
8434
+ padding: 16px 18px;
8435
+ border: 1px solid var(--border);
8436
+ border-radius: 10px;
8437
+ background: var(--bg);
8438
+ overflow-x: auto;
8439
+ white-space: pre;
8440
+ scrollbar-width: thin;
8441
+ }
8442
+ .docs-mdx-preview pre code {
8443
+ display: block;
8444
+ padding: 0;
8445
+ border: 0;
8446
+ background: transparent;
8447
+ color: var(--ink-90);
8448
+ font-size: 12.5px;
8449
+ line-height: 1.6;
8450
+ white-space: pre;
8451
+ }
8452
+ .docs-mdx-preview blockquote {
8453
+ margin: 1.6em 0;
8454
+ padding: 4px 18px;
8455
+ border-left: 3px solid var(--accent);
8456
+ color: var(--ink-70);
8457
+ font-style: italic;
8458
+ }
8459
+ .docs-mdx-preview blockquote > * + * { margin-top: .8em; }
8460
+ .docs-mdx-preview table {
8461
+ display: block;
8462
+ width: 100%;
8463
+ max-width: 100%;
8464
+ overflow-x: auto;
8465
+ border-collapse: collapse;
8466
+ margin: 1.6em 0;
8467
+ font-size: 14px;
8468
+ }
8469
+ .docs-mdx-preview th,
8470
+ .docs-mdx-preview td {
8471
+ padding: 9px 12px;
8472
+ border: 1px solid var(--border);
8473
+ text-align: left;
8474
+ vertical-align: top;
8475
+ }
8476
+ .docs-mdx-preview th { background: var(--bg); color: var(--ink-100); font-weight: 700; }
8477
+ .docs-mdx-preview tr:nth-child(odd) td { background: color-mix(in srgb, var(--bg-elev) 60%, transparent); }
8478
+ .docs-mdx-preview img { max-width: 100%; height: auto; border-radius: 6px; }
8479
+
8480
+ /* Per-block wrappers emitted by the server renderer — keep spacing sane. */
8481
+ .docs-mdx-preview section.mdx-block { display: block; }
8482
+ .docs-mdx-preview section.mdx-block > * + * { margin-top: inherit; }
8483
+
8484
+ /* Empty state. */
8485
+ .docs-preview-empty { display:grid; place-items:start; gap:10px; min-height:320px; align-content:center; }
8486
+ .docs-preview-empty h3 { margin:0; color:var(--ink-100); font-size:18px; }
8487
+ .docs-preview-empty p { margin:0; color:var(--ink-60); font-size:14px; line-height:1.55; max-width:52ch; }
8488
+ .docs-preview-empty button { margin-top:6px; padding:9px 14px; border:1px solid var(--border); border-radius:7px; background:var(--bg-elev); color:var(--ink-80); cursor:pointer; font:700 12px inherit; }
8489
+ .docs-preview-empty button:hover { border-color:var(--border-strong); color:var(--ink-100); }
8490
+
8491
+ /* In-place editor — replaces the preview when `state.editing` flips on. */
8492
+ .docs-editor-shell { display:flex; flex-direction:column; min-height:0; flex:1 1 auto; background: var(--bg); }
8493
+ .docs-source-tools { display:flex; flex-wrap:wrap; gap:4px; padding:10px 14px; border-bottom:1px solid var(--border); background:var(--bg-elev); }
8494
+ .docs-source-tools button { min-height:28px; min-width:34px; padding:0 8px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--ink-70); cursor:pointer; font:700 11px inherit; }
8495
+ .docs-source-tools button:hover { border-color:var(--border-strong); color:var(--ink-100); }
8496
+ .docs-editor-shell textarea { flex:1 1 auto; min-height:0; width:100%; box-sizing:border-box; padding:22px clamp(18px, 4vw, 40px); border:0; outline:0; resize:none; background:var(--bg); color:var(--ink-90); font:13px/1.65 ui-monospace,SFMono-Regular,Menlo,monospace; }
8497
+ .docs-editor-foot { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:40px; padding:0 16px; border-top:1px solid var(--border); color:var(--ink-50); font-size:10px; background:var(--bg-elev); }
8498
+ .docs-editor-hint { letter-spacing:.04em; text-transform:uppercase; }
8499
+
8500
+ .docs-context-menu { position:fixed; z-index:150; display:grid; min-width:160px; padding:5px; border:1px solid var(--border-strong); border-radius:9px; background:var(--bg-elev); box-shadow:var(--shadow-2); }
8501
+ .docs-context-menu[hidden] { display:none; }
8502
+ .docs-context-menu button { padding:8px; border:0; border-radius:6px; background:transparent; color:var(--ink-70); cursor:pointer; text-align:left; font:600 11px inherit; }
8503
+ .docs-context-menu button:hover { background:var(--bg-elev-2); color:var(--ink-100); }
8504
+ .docs-context-menu .danger { color:var(--danger); }
8505
+ .docs-tab.is-generating { cursor:progress; }
8506
+ .docs-tab.is-generating .docs-stage { opacity:.6; pointer-events:none; }
8507
+
8508
+ @media(max-width:760px){
8509
+ .docs-workspace,
8510
+ .docs-workspace--editing { grid-template-columns:1fr; min-height:0; }
8511
+ .docs-sidebar { max-height:200px; border-right:0; border-bottom:1px solid var(--border); }
8512
+ .docs-mdx-preview { padding:24px 18px 56px; }
8513
+ .docs-editor-shell textarea { padding:18px 16px; }
8514
+ .docs-filebar { padding-inline:14px; }
8515
+ }
8225
8516
 
8226
8517
  /* Docs owns its own scroll regions. The application shell intentionally does
8227
8518
  not scroll, so letting the preview inherit a fixed/min-height mix trapped
8228
- long documents below the viewport. Keep the command bar fixed and make the
8229
- document stage, file tree, and source editor independently scrollable. */
8519
+ long documents below the viewport. The file bar stays sticky and the
8520
+ document stage, file tree, and source editor scroll independently. */
8230
8521
  #tab-docs { min-height: 0; overflow: hidden; }
8231
8522
  .docs-shell { display: flex; min-height: 0; height: 100%; flex-direction: column; overflow: hidden; }
8232
8523
  .docs-workspace { min-height: 0; flex: 1 1 auto; }
8233
8524
  .docs-sidebar,
8234
8525
  .docs-stage,
8235
- .docs-source-panel { min-height: 0; }
8526
+ .docs-editor-shell { min-height: 0; }
8236
8527
  .docs-file-list { min-height: 0; flex: 1 1 auto; }
8237
8528
  .docs-stage { overflow: auto; overscroll-behavior: contain; }
8238
- .docs-filebar,
8239
- .docs-preview-toolbar { position: sticky; z-index: 2; }
8240
- .docs-filebar { top: 0; background: var(--bg-elev); }
8241
- .docs-preview-toolbar { top: 58px; }
8242
- .docs-source-panel textarea { min-height: 0; }
8529
+ .docs-filebar { position: sticky; top: 0; z-index: 2; background: var(--bg-elev); }
8530
+ .docs-editor-shell textarea { min-height: 0; }
8243
8531
 
8244
8532
  /* Docs navigation mirrors the actual docs/ filesystem. Directory rows own
8245
8533
  expansion; documents stay compact and the active document is unambiguous. */
@@ -8314,26 +8602,26 @@ body.workspace-mode-chat { padding-left: 0; }
8314
8602
  shell's scrolling contract. They are deliberately restrained in both themes. */
8315
8603
  .docs-file-list,
8316
8604
  .docs-stage,
8317
- .docs-source-panel textarea,
8605
+ .docs-editor-shell textarea,
8318
8606
  .docs-mdx-preview pre {
8319
8607
  scrollbar-width: thin;
8320
8608
  scrollbar-color: color-mix(in srgb, var(--ink-50) 55%, transparent) transparent;
8321
8609
  }
8322
8610
  .docs-file-list::-webkit-scrollbar,
8323
8611
  .docs-stage::-webkit-scrollbar,
8324
- .docs-source-panel textarea::-webkit-scrollbar,
8612
+ .docs-editor-shell textarea::-webkit-scrollbar,
8325
8613
  .docs-mdx-preview pre::-webkit-scrollbar { width: 9px; height: 9px; }
8326
8614
  .docs-file-list::-webkit-scrollbar-track,
8327
8615
  .docs-stage::-webkit-scrollbar-track,
8328
- .docs-source-panel textarea::-webkit-scrollbar-track,
8616
+ .docs-editor-shell textarea::-webkit-scrollbar-track,
8329
8617
  .docs-mdx-preview pre::-webkit-scrollbar-track { background: transparent; }
8330
8618
  .docs-file-list::-webkit-scrollbar-thumb,
8331
8619
  .docs-stage::-webkit-scrollbar-thumb,
8332
- .docs-source-panel textarea::-webkit-scrollbar-thumb,
8620
+ .docs-editor-shell textarea::-webkit-scrollbar-thumb,
8333
8621
  .docs-mdx-preview pre::-webkit-scrollbar-thumb { border: 3px solid transparent; border-radius: 999px; background: color-mix(in srgb, var(--ink-50) 58%, transparent); background-clip: padding-box; }
8334
8622
  .docs-file-list::-webkit-scrollbar-thumb:hover,
8335
8623
  .docs-stage::-webkit-scrollbar-thumb:hover,
8336
- .docs-source-panel textarea::-webkit-scrollbar-thumb:hover,
8624
+ .docs-editor-shell textarea::-webkit-scrollbar-thumb:hover,
8337
8625
  .docs-mdx-preview pre::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--ink-70) 74%, transparent); }
8338
8626
  /* Medium screens keep every primary destination reachable without a scrolling
8339
8627
  tab strip. The brand project picker remains the single project control. */
@@ -49,7 +49,7 @@
49
49
  }
50
50
 
51
51
  function effectiveState(t) {
52
- return t.state ?? t.status ?? "idle";
52
+ return window.OpenKanStatus?.displayState(t) ?? "pending";
53
53
  }
54
54
 
55
55
  // Build a clickable tag chip. When the user clicks one in the task view,
@@ -1028,89 +1028,24 @@
1028
1028
  }
1029
1029
 
1030
1030
  // ─── Render task header metadata strip ──────────────────────────────────────
1031
- // Clean, scannable dl/dt/dd grid with tag chips and assignees below.
1031
+ // Tightened layout (tsk-KNQwScRg): primary context assignees + tags +
1032
+ // stale warning — stays front and center. Secondary context (column,
1033
+ // category, priority, effort, runner, source, timestamps) is collapsed
1034
+ // behind a native <details> "Show details" toggle so the panel reads
1035
+ // cleanly at a glance. Priority duplicates the header status pill, so it
1036
+ // is moved to the details section too. Nothing is deleted — every field
1037
+ // remains reachable.
1032
1038
  function renderMetadata(root, task, lastActivity) {
1033
1039
  root.innerHTML = "";
1034
1040
  const meta = el("div", "task-header-meta");
1035
1041
 
1036
- // ── Definition list label/value pairs in a clean grid ──────────────
1037
- const dl = el("dl", "meta-dl");
1038
-
1039
- // Column → human label
1040
- if (task.column) {
1041
- const lbl = el("dt", null, { text: "Column" });
1042
- const val = el("dd", null, { text: COLUMN_LABEL[task.column] || task.column });
1043
- dl.append(lbl, val);
1044
- }
1045
-
1046
- // Category
1047
- const cat = String(task.category || "").toLowerCase();
1048
- if (cat) {
1049
- const lbl = el("dt", null, { text: "Category" });
1050
- const catCls = CATEGORIES.has(cat) ? `tag-chip category c-${cat}` : "tag-chip category";
1051
- const val = el("dd", null, {});
1052
- val.append(el("span", catCls, { text: cat, title: `category: ${cat}` }));
1053
- dl.append(lbl, val);
1054
- }
1055
-
1056
- // Priority
1057
- const p = PRIORITY_META[task.priority];
1058
- if (p && task.priority) {
1059
- const lbl = el("dt", null, { text: "Priority" });
1060
- const val = el("dd", null, {});
1061
- val.append(el("span", `tag-chip priority priority-${task.priority}`, {
1062
- text: `${p.code} ${p.label}`,
1063
- title: `priority: ${p.label}`,
1064
- }));
1065
- dl.append(lbl, val);
1066
- }
1067
-
1068
- // Effort
1069
- if (task.effort) {
1070
- const effortText = String(task.effort).toUpperCase();
1071
- const lbl = el("dt", null, { text: "Effort" });
1072
- const val = el("dd", null, {});
1073
- val.append(el("span", "tag-chip effort", { text: effortText, title: `effort: ${effortText}` }));
1074
- dl.append(lbl, val);
1075
- }
1076
-
1077
- // Agent / model
1078
- if (task.agent || task.model) {
1079
- const lbl = el("dt", null, { text: "Runner" });
1080
- const val = el("dd", "runner-value", {});
1081
- if (task.agent) val.append(el("span", "runner-agent", { text: task.agent }));
1082
- if (task.agent && task.model) val.append(el("span", "runner-sep", { text: " · " }));
1083
- if (task.model) val.append(el("span", "runner-model", { text: task.model }));
1084
- dl.append(lbl, val);
1085
- }
1086
-
1087
- // Source path (where it was imported from). Render as a clickable link
1088
- // so the user can jump straight to the file. The path is repo-relative
1089
- // (e.g. "docs/roadmap.mdx"), so we prefix with "/" for an absolute path
1090
- // that the dev server will serve.
1091
- if (task.source?.path) {
1092
- const lbl = el("dt", null, { text: "Source" });
1093
- const val = el("dd", "source-dd", {});
1094
- const path = String(task.source.path);
1095
- const line = task.source.line ?? "?";
1096
- const link = el("a", "source-link", {
1097
- href: `/${path}`,
1098
- target: "_blank",
1099
- rel: "noopener",
1100
- title: `Open ${path}:${line} in a new tab`,
1101
- });
1102
- link.append(
1103
- el("span", "source-link-icon", { text: "📄", "aria-hidden": "true" }),
1104
- el("span", "source-link-text", { text: `${path}:${line}` }),
1105
- );
1106
- val.append(link);
1107
- dl.append(lbl, val);
1108
- }
1042
+ // ── Always-visible: primary context ─────────────────────────────────
1043
+ const dl = el("dl", "meta-dl meta-dl-primary");
1109
1044
 
1110
1045
  // Stale — surfaces when the server detected the source file has changed
1111
1046
  // since import. The user can re-derive tags via the organize endpoint
1112
1047
  // (kind: "rederive"). Falls back to /api/tasks/:id/organize if Thor's
1113
- // per-task endpoint ships first.
1048
+ // per-task endpoint ships first. Always shown (rare, but critical).
1114
1049
  if (task.stale === true) {
1115
1050
  const lbl = el("dt", null, { text: "Stale" });
1116
1051
  const val = el("dd", "meta-stale", {});
@@ -1157,7 +1092,7 @@
1157
1092
  dl.append(lbl, val);
1158
1093
  }
1159
1094
 
1160
- // Assignees
1095
+ // Assignees — visible by default (action-driving context).
1161
1096
  const assigneesList = getAssignees(task);
1162
1097
  if (assigneesList.length > 0) {
1163
1098
  const lbl = el("dt", null, { text: "Assignees" });
@@ -1173,26 +1108,21 @@
1173
1108
  dl.append(lbl, val);
1174
1109
  }
1175
1110
 
1176
- // Created / updated timestamps
1177
- if (task.createdAt) {
1178
- const lbl = el("dt", null, { text: "Created" });
1179
- const val = el("dd", null, { text: shortDate(task.createdAt), title: task.createdAt });
1180
- dl.append(lbl, val);
1181
- }
1182
- if (task.updatedAt && task.updatedAt !== task.createdAt) {
1183
- const lbl = el("dt", null, { text: "Updated" });
1184
- const val = el("dd", null, { text: relativeTime(task.updatedAt), title: task.updatedAt });
1185
- dl.append(lbl, val);
1186
- }
1111
+ // Last activity recent-activity is the user-named "front and center"
1112
+ // content, so we promote it out of the collapsed details. Compact
1113
+ // relative time so it doesn't add visual weight.
1187
1114
  if (lastActivity) {
1188
- const lbl = el("dt", null, { text: "Last activity" });
1189
- const val = el("dd", null, { text: relativeTime(lastActivity), title: lastActivity });
1115
+ const lbl = el("dt", null, { text: "Activity" });
1116
+ const val = el("dd", "meta-last-activity", {
1117
+ text: relativeTime(lastActivity),
1118
+ title: lastActivity,
1119
+ });
1190
1120
  dl.append(lbl, val);
1191
1121
  }
1192
1122
 
1193
1123
  if (dl.children.length > 0) meta.append(dl);
1194
1124
 
1195
- // ── Tag chips row — below the dl ────────────────────────────────────
1125
+ // ── Tag chips row — also always-visible ─────────────────────────────
1196
1126
  const tags = Array.isArray(task.tags) ? task.tags : [];
1197
1127
  if (tags.length > 0) {
1198
1128
  const tagRow = el("div", "meta-tag-row");
@@ -1202,6 +1132,106 @@
1202
1132
  meta.append(tagRow);
1203
1133
  }
1204
1134
 
1135
+ // ── Collapsed secondary metadata behind a native <details> toggle ────
1136
+ const detailDl = el("dl", "meta-dl meta-dl-details");
1137
+ let hasDetailItem = false;
1138
+ const pushDetail = (lblText, valueEl) => {
1139
+ detailDl.append(
1140
+ el("dt", null, { text: lblText }),
1141
+ valueEl,
1142
+ );
1143
+ hasDetailItem = true;
1144
+ };
1145
+
1146
+ // Column → human label
1147
+ if (task.column) {
1148
+ pushDetail("Column", el("dd", null, { text: COLUMN_LABEL[task.column] || task.column }));
1149
+ }
1150
+
1151
+ // Category
1152
+ const cat = String(task.category || "").toLowerCase();
1153
+ if (cat) {
1154
+ const catCls = CATEGORIES.has(cat) ? `tag-chip category c-${cat}` : "tag-chip category";
1155
+ const val = el("dd", null, {});
1156
+ val.append(el("span", catCls, { text: cat, title: `category: ${cat}` }));
1157
+ pushDetail("Category", val);
1158
+ }
1159
+
1160
+ // Priority — moved here from the always-visible dl; it duplicates the
1161
+ // pill in the header status column, but stays reachable.
1162
+ const p = PRIORITY_META[task.priority];
1163
+ if (p && task.priority) {
1164
+ const val = el("dd", null, {});
1165
+ val.append(el("span", `tag-chip priority priority-${task.priority}`, {
1166
+ text: `${p.code} ${p.label}`,
1167
+ title: `priority: ${p.label}`,
1168
+ }));
1169
+ pushDetail("Priority", val);
1170
+ }
1171
+
1172
+ // Effort
1173
+ if (task.effort) {
1174
+ const effortText = String(task.effort).toUpperCase();
1175
+ const val = el("dd", null, {});
1176
+ val.append(el("span", "tag-chip effort", { text: effortText, title: `effort: ${effortText}` }));
1177
+ pushDetail("Effort", val);
1178
+ }
1179
+
1180
+ // Agent / model
1181
+ if (task.agent || task.model) {
1182
+ const val = el("dd", "runner-value", {});
1183
+ if (task.agent) val.append(el("span", "runner-agent", { text: task.agent }));
1184
+ if (task.agent && task.model) val.append(el("span", "runner-sep", { text: " · " }));
1185
+ if (task.model) val.append(el("span", "runner-model", { text: task.model }));
1186
+ pushDetail("Runner", val);
1187
+ }
1188
+
1189
+ // Source path (where it was imported from). Render as a clickable link
1190
+ // so the user can jump straight to the file. The path is repo-relative
1191
+ // (e.g. "docs/roadmap.mdx"), so we prefix with "/" for an absolute path
1192
+ // that the dev server will serve.
1193
+ if (task.source?.path) {
1194
+ const val = el("dd", "source-dd", {});
1195
+ const path = String(task.source.path);
1196
+ const line = task.source.line ?? "?";
1197
+ const link = el("a", "source-link", {
1198
+ href: `/${path}`,
1199
+ target: "_blank",
1200
+ rel: "noopener",
1201
+ title: `Open ${path}:${line} in a new tab`,
1202
+ });
1203
+ link.append(
1204
+ el("span", "source-link-icon", { text: "📄", "aria-hidden": "true" }),
1205
+ el("span", "source-link-text", { text: `${path}:${line}` }),
1206
+ );
1207
+ val.append(link);
1208
+ pushDetail("Source", val);
1209
+ }
1210
+
1211
+ // Created / updated timestamps (long-form).
1212
+ if (task.createdAt) {
1213
+ pushDetail(
1214
+ "Created",
1215
+ el("dd", null, { text: shortDate(task.createdAt), title: task.createdAt }),
1216
+ );
1217
+ }
1218
+ if (task.updatedAt && task.updatedAt !== task.createdAt) {
1219
+ pushDetail(
1220
+ "Updated",
1221
+ el("dd", null, { text: relativeTime(task.updatedAt), title: task.updatedAt }),
1222
+ );
1223
+ }
1224
+
1225
+ if (hasDetailItem) {
1226
+ const details = el("details", "task-meta-details");
1227
+ const summary = el("summary", "task-meta-details-toggle", {
1228
+ text: "Show details",
1229
+ "aria-label": "Show secondary metadata (column, category, priority, effort, runner, source, timestamps)",
1230
+ });
1231
+ details.append(summary, detailDl);
1232
+ meta.append(details);
1233
+ }
1234
+
1205
1235
  root.append(meta);
1206
1236
  }
1207
1237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/openkan",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Local-first MDX kanban with native Claude Code control plane, .ok/ planning system, and embedded chat sidebar.",
5
5
  "type": "module",
6
6
  "license": "MIT",