@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.
- package/dist/kanban/mdx-render.js +76 -8
- package/dist/web/app.js +1 -1
- package/dist/web/contributors-view.js +1 -1
- package/dist/web/docs-view.js +47 -18
- package/dist/web/home-view.js +1 -1
- package/dist/web/index.html +4 -3
- package/dist/web/status.js +40 -0
- package/dist/web/style.css +314 -26
- package/dist/web/task-view.js +121 -91
- package/package.json +1 -1
|
@@ -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(/{([^{}\n]+)}/g, (_match, inner) => `{${inner}}`);
|
|
197
|
+
// 4. Inline images:  — must come before links so the leading `!`
|
|
198
|
+
// isn't accidentally consumed.
|
|
199
|
+
escaped = escaped.replace(/!\[([^\]\n]*)\]\(([^()\s]+)(?:\s+"[^&]*")?\)/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+"[^&]*")?\)/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}>${
|
|
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>${
|
|
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>${
|
|
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>${
|
|
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>${
|
|
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>${
|
|
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>${
|
|
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
|
@@ -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-${
|
|
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 || "" }));
|
package/dist/web/docs-view.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
// OpenKan Docs — preview-first MDX workspace with an
|
|
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
|
-
<
|
|
65
|
-
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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,
|
|
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.
|
|
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,
|
|
148
|
-
if (name === "source") { state.
|
|
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\
|
|
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,
|
|
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,
|
|
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
|
})();
|
package/dist/web/home-view.js
CHANGED
|
@@ -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
|
|
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>
|
package/dist/web/index.html
CHANGED
|
@@ -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
|
|
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
|
+
})();
|
package/dist/web/style.css
CHANGED
|
@@ -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
|
|
1168
|
-
.state-pill.state-
|
|
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
|
|
8203
|
-
.home-command-actions button
|
|
8204
|
-
.home-command-actions button:hover
|
|
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
|
|
8218
|
-
|
|
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
|
-
|
|
8222
|
-
.
|
|
8223
|
-
|
|
8224
|
-
|
|
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.
|
|
8229
|
-
document stage, file tree, and source editor independently
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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. */
|
package/dist/web/task-view.js
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function effectiveState(t) {
|
|
52
|
-
return
|
|
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
|
-
//
|
|
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
|
-
// ──
|
|
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
|
-
//
|
|
1177
|
-
|
|
1178
|
-
|
|
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: "
|
|
1189
|
-
const val = el("dd",
|
|
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 —
|
|
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