@polderlabs/openkan 0.4.0

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.
Files changed (114) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/agents/openkan.md +254 -0
  5. package/bin/install-agent.mjs +63 -0
  6. package/bin/ok.mjs +17 -0
  7. package/bin/openkan.mjs +10 -0
  8. package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
  9. package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
  10. package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
  11. package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
  12. package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
  13. package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
  14. package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
  15. package/dist/agents/openkan.md +254 -0
  16. package/dist/bin/install-agent.mjs +76 -0
  17. package/dist/bin/ok-install.js +58 -0
  18. package/dist/bin/ok.js +138 -0
  19. package/dist/bin/openkan.js +804 -0
  20. package/dist/commands/organize.md +15 -0
  21. package/dist/kanban/agent-profile.js +8 -0
  22. package/dist/kanban/archive.js +49 -0
  23. package/dist/kanban/bizar.js +242 -0
  24. package/dist/kanban/board.js +367 -0
  25. package/dist/kanban/bulk.js +139 -0
  26. package/dist/kanban/changelog.js +186 -0
  27. package/dist/kanban/chat.js +1280 -0
  28. package/dist/kanban/claude-state.js +974 -0
  29. package/dist/kanban/comments.js +80 -0
  30. package/dist/kanban/docs.js +144 -0
  31. package/dist/kanban/fs.js +163 -0
  32. package/dist/kanban/git.js +196 -0
  33. package/dist/kanban/images.js +140 -0
  34. package/dist/kanban/import.js +295 -0
  35. package/dist/kanban/inputs.js +94 -0
  36. package/dist/kanban/insights.js +140 -0
  37. package/dist/kanban/io.js +75 -0
  38. package/dist/kanban/mdx-render.js +348 -0
  39. package/dist/kanban/mdx.js +231 -0
  40. package/dist/kanban/projects.js +545 -0
  41. package/dist/kanban/search.js +121 -0
  42. package/dist/kanban/server.js +3296 -0
  43. package/dist/kanban/tags.js +124 -0
  44. package/dist/kanban/template.js +145 -0
  45. package/dist/kanban/tsx-sandbox.js +187 -0
  46. package/dist/kanban/watcher.js +270 -0
  47. package/dist/ok/commands/goal.js +65 -0
  48. package/dist/ok/commands/index.js +87 -0
  49. package/dist/ok/commands/init.js +15 -0
  50. package/dist/ok/commands/plan.js +155 -0
  51. package/dist/ok/commands/prd.js +202 -0
  52. package/dist/ok/commands/progress.js +31 -0
  53. package/dist/ok/commands/task.js +377 -0
  54. package/dist/ok/ids.js +98 -0
  55. package/dist/ok/lock.js +156 -0
  56. package/dist/ok/migrate.js +197 -0
  57. package/dist/ok/schemas.js +402 -0
  58. package/dist/ok/storage.js +222 -0
  59. package/dist/skills/openkan/SKILL.md +111 -0
  60. package/dist/skills/openkan/agents/openai.yaml +4 -0
  61. package/dist/skills/openkan/examples/simple-task.mdx +34 -0
  62. package/dist/skills/openkan/examples/with-ask.mdx +32 -0
  63. package/dist/skills/openkan/examples/with-choice.mdx +51 -0
  64. package/dist/skills/openkan/examples/with-preview.mdx +54 -0
  65. package/dist/skills/openkan/references/api.md +169 -0
  66. package/dist/skills/openkan/templates/task.mdx +46 -0
  67. package/dist/web/api.js +257 -0
  68. package/dist/web/app.js +4251 -0
  69. package/dist/web/bizar.js +39 -0
  70. package/dist/web/brand/agent-activity-sprite.svg +1 -0
  71. package/dist/web/brand/banner-docs.svg +24 -0
  72. package/dist/web/brand/banner.svg +32 -0
  73. package/dist/web/brand/empty-sessions.svg +17 -0
  74. package/dist/web/brand/empty-tasks.svg +17 -0
  75. package/dist/web/brand/favicon.svg +9 -0
  76. package/dist/web/brand/infinity-loader-animated.svg +220 -0
  77. package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
  78. package/dist/web/brand/logo-wordmark.svg +10 -0
  79. package/dist/web/brand/logo.svg +9 -0
  80. package/dist/web/brand/pixel-infinity-track.svg +1 -0
  81. package/dist/web/brand/social-card.svg +26 -0
  82. package/dist/web/changelog-view.js +456 -0
  83. package/dist/web/charts.js +269 -0
  84. package/dist/web/chat-sidebar.js +2397 -0
  85. package/dist/web/chat-status-motion.js +154 -0
  86. package/dist/web/claude-pane.js +820 -0
  87. package/dist/web/command-palette.js +381 -0
  88. package/dist/web/contributors-view.js +317 -0
  89. package/dist/web/cross-tab.js +102 -0
  90. package/dist/web/docs-view.js +168 -0
  91. package/dist/web/experience.css +165 -0
  92. package/dist/web/goals-view.js +45 -0
  93. package/dist/web/home-view.js +113 -0
  94. package/dist/web/images.js +311 -0
  95. package/dist/web/index.html +485 -0
  96. package/dist/web/insights.js +217 -0
  97. package/dist/web/keyboard.js +446 -0
  98. package/dist/web/mdx-viewer.js +600 -0
  99. package/dist/web/path-picker.js +787 -0
  100. package/dist/web/preview-frame.html +187 -0
  101. package/dist/web/settings.js +582 -0
  102. package/dist/web/style.css +8545 -0
  103. package/dist/web/task-view.js +1759 -0
  104. package/dist/web/vendor/gsap.min.js +11 -0
  105. package/dist/web/workspace.css +1513 -0
  106. package/package.json +71 -0
  107. package/skills/openkan/SKILL.md +111 -0
  108. package/skills/openkan/agents/openai.yaml +4 -0
  109. package/skills/openkan/examples/simple-task.mdx +34 -0
  110. package/skills/openkan/examples/with-ask.mdx +32 -0
  111. package/skills/openkan/examples/with-choice.mdx +51 -0
  112. package/skills/openkan/examples/with-preview.mdx +54 -0
  113. package/skills/openkan/references/api.md +169 -0
  114. package/skills/openkan/templates/task.mdx +46 -0
@@ -0,0 +1,102 @@
1
+ // OpenKan — cross-tab sync (M13).
2
+ // window.OpenKanCrossTab = { publish(event, data), subscribe(event, handler) }
3
+ //
4
+ // Thin wrapper around BroadcastChannel('openkan'). When SSE is unavailable or
5
+ // behind a load balancer that strips long-lived connections, this keeps two
6
+ // tabs of the same project in sync. The channel is fire-and-forget on the
7
+ // publisher side; subscribers receive the parsed payload from message events
8
+ // on other tabs of the same browser, same origin.
9
+ //
10
+ // The event names here mirror what SSE would emit, so app.js can subscribe to
11
+ // cross-tab and SSE with the same handler shape.
12
+ //
13
+ // Graceful degradation: if BroadcastChannel isn't available (older browsers,
14
+ // sandboxed contexts), the wrapper becomes a no-op — publish() does nothing
15
+ // and subscribe() returns an unsubscribe that does nothing. Cross-tab sync is
16
+ // a "nice to have"; SSE remains the source of truth.
17
+
18
+ (() => {
19
+ "use strict";
20
+
21
+ const CHANNEL_NAME = "openkan";
22
+
23
+ /** @type {BroadcastChannel|null} */
24
+ let channel = null;
25
+ /** @type {Map<string, Set<Function>>} */
26
+ const handlers = new Map();
27
+
28
+ // Skip origin-mismatch channels silently — the wrapper assumes same-origin.
29
+ if (typeof BroadcastChannel !== "undefined") {
30
+ try {
31
+ channel = new BroadcastChannel(CHANNEL_NAME);
32
+ channel.addEventListener("message", (ev) => {
33
+ const data = ev.data;
34
+ if (!data || typeof data !== "object") return;
35
+ const { event, payload } = data;
36
+ if (typeof event !== "string") return;
37
+ const set = handlers.get(event);
38
+ if (!set) return;
39
+ for (const fn of set) {
40
+ try { fn(payload); } catch (e) {
41
+ console.error(`[cross-tab] handler for ${event} threw:`, e);
42
+ }
43
+ }
44
+ });
45
+ // Close on page unload so we don't keep a stale handle around.
46
+ window.addEventListener("pagehide", () => {
47
+ try { channel?.close(); } catch {}
48
+ channel = null;
49
+ });
50
+ } catch (err) {
51
+ // SecurityError / disabled — fall through to no-op below.
52
+ channel = null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Publish an event to other tabs. Returns true if a channel is available,
58
+ * false if the call was a no-op.
59
+ * @param {string} event
60
+ * @param {any} data
61
+ * @returns {boolean}
62
+ */
63
+ function publish(event, data) {
64
+ if (!channel) return false;
65
+ if (typeof event !== "string" || !event) return false;
66
+ try {
67
+ channel.postMessage({ event, payload: data });
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Subscribe to events from other tabs. Returns an unsubscribe function.
76
+ * @param {string} event
77
+ * @param {(data:any) => void} handler
78
+ * @returns {() => void}
79
+ */
80
+ function subscribe(event, handler) {
81
+ if (typeof event !== "string" || !event || typeof handler !== "function") {
82
+ return () => {};
83
+ }
84
+ let set = handlers.get(event);
85
+ if (!set) { set = new Set(); handlers.set(event, set); }
86
+ set.add(handler);
87
+ return () => {
88
+ const s = handlers.get(event);
89
+ if (!s) return;
90
+ s.delete(handler);
91
+ if (s.size === 0) handlers.delete(event);
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Test-only: expose hasChannel() so callers can detect whether the wrapper
97
+ * is actually live. Not part of the public API used by app.js.
98
+ */
99
+ function hasChannel() { return channel !== null; }
100
+
101
+ window.OpenKanCrossTab = { publish, subscribe, hasChannel };
102
+ })();
@@ -0,0 +1,168 @@
1
+ // OpenKan Docs — preview-first MDX workspace with an always-available source editor.
2
+ (() => {
3
+ "use strict";
4
+ const { api } = window.OpenKanAPI;
5
+ let state = null;
6
+ let renderTimer = 0;
7
+ const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" })[c]);
8
+ const DOC_FILE_PATTERN = /\.(md|mdx|txt)$/i;
9
+ const flatten = (entries, out = []) => { for (const entry of entries || []) entry.isDir ? flatten(entry.children, out) : out.push(entry); return out; };
10
+ const safePath = (value = "") => String(value).trim().replace(/^\/+/, "").replace(/\.\.+/g, "");
11
+ const askPath = (value = "") => safePath(prompt("Document path relative to docs/", value) || "");
12
+ const fileName = (path) => path ? path.split("/").pop() : "Untitled document";
13
+ const relativeTime = (iso) => iso ? new Intl.DateTimeFormat(undefined, { month:"short", day:"numeric", hour:"numeric", minute:"2-digit" }).format(new Date(iso)) : "";
14
+
15
+ function sourceToolbar() {
16
+ return `<div class="docs-source-tools" aria-label="Markdown formatting"><button data-doc-format="h2" title="Heading">H2</button><button data-doc-format="bold" title="Bold"><b>B</b></button><button data-doc-format="italic" title="Italic"><i>I</i></button><button data-doc-format="ul" title="Bulleted list">• List</button><button data-doc-format="ol" title="Numbered list">1. List</button><button data-doc-format="link" title="Link">↗</button><button data-doc-format="code" title="Code">&lt;/&gt;</button></div>`;
17
+ }
18
+ function visibleTree(entries, query = "") {
19
+ return (entries || []).flatMap((entry) => {
20
+ if (entry.isDir) {
21
+ const children = visibleTree(entry.children, query);
22
+ return children.length ? [{ ...entry, children }] : [];
23
+ }
24
+ return DOC_FILE_PATTERN.test(entry.path) && (!query || entry.path.toLowerCase().includes(query)) ? [entry] : [];
25
+ });
26
+ }
27
+ function directoryCount(entries) {
28
+ return (entries || []).reduce((count, entry) => count + (entry.isDir ? directoryCount(entry.children) : 1), 0);
29
+ }
30
+ function openParentDirectories(path) {
31
+ const parts = String(path || "").split("/").filter(Boolean);
32
+ parts.pop();
33
+ let current = "";
34
+ for (const part of parts) { current = current ? `${current}/${part}` : part; state.expandedDirs.add(current); }
35
+ }
36
+ function docTree(entries, depth = 0) {
37
+ return entries.map((entry) => {
38
+ if (!entry.isDir) {
39
+ const extension = entry.name.includes(".") ? entry.name.split(".").pop().toUpperCase() : "TEXT";
40
+ return `<button class="docs-tree-file${entry.path === state.path ? " active" : ""}" data-doc-path="${esc(entry.path)}" title="${esc(entry.path)}"><strong>${esc(entry.name)}</strong><span>${extension}</span></button>`;
41
+ }
42
+ const open = state.docFilter || state.expandedDirs.has(entry.path) || state.path.startsWith(`${entry.path}/`);
43
+ const children = docTree(entry.children || [], depth + 1);
44
+ return `<details class="docs-tree-directory" data-doc-directory="${esc(entry.path)}" ${open ? "open" : ""}><summary class="docs-tree-directory-summary"><span class="docs-tree-directory-name">${esc(entry.name)}</span><small>${directoryCount(entry.children)}</small></summary><div class="docs-tree-children" style="--docs-tree-depth:${depth + 1}">${children}</div></details>`;
45
+ }).join("");
46
+ }
47
+ function renderTree() {
48
+ const list = state?.root?.querySelector("[data-doc-list]");
49
+ if (!list) return;
50
+ const query = String(state.docFilter || "").trim().toLowerCase();
51
+ const tree = visibleTree(state.entries, query);
52
+ list.innerHTML = docTree(tree) || `<div class="docs-empty-tree">${query ? "No documents match this filter." : "No documents yet.<br>Create your first workspace note."}</div>`;
53
+ list.querySelectorAll("[data-doc-path]").forEach((button) => button.addEventListener("click", () => load(button.dataset.docPath)));
54
+ list.querySelectorAll("[data-doc-directory]").forEach((directory) => directory.addEventListener("toggle", () => {
55
+ if (directory.open) state.expandedDirs.add(directory.dataset.docDirectory);
56
+ else state.expandedDirs.delete(directory.dataset.docDirectory);
57
+ }));
58
+ }
59
+ function render() {
60
+ if (!state?.root) return;
61
+ const files = flatten(state.entries).filter((entry) => DOC_FILE_PATTERN.test(entry.path));
62
+ const status = state.dirty ? "Unsaved draft" : state.path ? `Saved ${relativeTime(state.mtime)}` : "New draft";
63
+ 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>
70
+ </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
+ </div><div id="docs-context-menu" class="docs-context-menu" hidden></div></section>`;
73
+ wire();
74
+ }
75
+ function wire() {
76
+ const root = state.root;
77
+ renderTree();
78
+ root.querySelectorAll("[data-doc-action]").forEach((button) => button.addEventListener("click", () => action(button.dataset.docAction)));
79
+ root.querySelectorAll("[data-doc-format]").forEach((button) => button.addEventListener("click", () => applyFormat(button.dataset.docFormat)));
80
+ root.querySelector("#docs-editor-input")?.addEventListener("input", (event) => {
81
+ state.content = event.target.value; state.dirty = true;
82
+ const status = root.querySelector(".docs-file-ident small");
83
+ if (status) status.textContent = `${state.path || "Choose a path before saving"} · Unsaved draft`;
84
+ schedulePreview();
85
+ });
86
+ root.querySelector("[data-doc-filter]")?.addEventListener("input", (event) => {
87
+ state.docFilter = event.target.value;
88
+ renderTree();
89
+ });
90
+ root.addEventListener("contextmenu", contextMenu);
91
+ document.addEventListener("click", dismissMenu, { once: true });
92
+ }
93
+ async function load(path) {
94
+ if (!path || !state) return;
95
+ const doc = await api("GET", `/api/docs/${encodeURI(path)}?raw=0`);
96
+ openParentDirectories(path);
97
+ Object.assign(state, { path, content: doc.raw || "", html: doc.html || doc.rendered || "", mtime: doc.mtime || "", dirty: false, sourceOpen: false });
98
+ render();
99
+ }
100
+ async function refresh({ loadInitial = false } = {}) {
101
+ const docs = await api("GET", "/api/docs");
102
+ state.entries = docs.entries || [];
103
+ const files = flatten(state.entries).filter((entry) => DOC_FILE_PATTERN.test(entry.path));
104
+ if (loadInitial && files.length) return load(state.initialDoc && files.some((file) => file.path === state.initialDoc) ? state.initialDoc : files[0].path);
105
+ render();
106
+ }
107
+ async function renderPreview() {
108
+ if (!state) return;
109
+ const rendered = await api("POST", "/api/docs/render", { content: state.content });
110
+ state.html = rendered.html || rendered.rendered || "";
111
+ }
112
+ function schedulePreview() {
113
+ clearTimeout(renderTimer);
114
+ renderTimer = setTimeout(async () => {
115
+ try { await renderPreview(); } catch { return; }
116
+ if (!state?.root) return;
117
+ const preview = state.root.querySelector("[data-doc-preview]");
118
+ if (preview) preview.innerHTML = state.html;
119
+ }, 480);
120
+ }
121
+ async function save() {
122
+ if (!state.path) return;
123
+ await renderPreview();
124
+ const doc = await api("PUT", `/api/docs/${encodeURI(state.path)}`, { content: state.content });
125
+ Object.assign(state, { html: doc.html || doc.rendered || state.html, mtime: doc.mtime || new Date().toISOString(), dirty: false });
126
+ await refresh();
127
+ }
128
+ function insertAtSelection(before, after = "", placeholder = "text") {
129
+ const textarea = state.root.querySelector("#docs-editor-input");
130
+ if (!textarea) { state.sourceOpen = true; render(); requestAnimationFrame(() => insertAtSelection(before, after, placeholder)); return; }
131
+ const start = textarea.selectionStart, end = textarea.selectionEnd;
132
+ const chosen = textarea.value.slice(start, end) || placeholder;
133
+ const output = `${textarea.value.slice(0, start)}${before}${chosen}${after}${textarea.value.slice(end)}`;
134
+ textarea.value = output; textarea.focus(); textarea.setSelectionRange(start + before.length, start + before.length + chosen.length);
135
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
136
+ }
137
+ function applyFormat(kind) {
138
+ if (kind === "h2") return insertAtSelection("## ", "", "Heading");
139
+ if (kind === "bold") return insertAtSelection("**", "**", "bold text");
140
+ if (kind === "italic") return insertAtSelection("*", "*", "emphasis");
141
+ if (kind === "ul") return insertAtSelection("- ", "", "List item");
142
+ if (kind === "ol") return insertAtSelection("1. ", "", "List item");
143
+ if (kind === "code") return insertAtSelection("`", "`", "code");
144
+ if (kind === "link") { const url = prompt("Link URL", "https://"); if (url) insertAtSelection("[", `](${url})`, "link text"); }
145
+ }
146
+ 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(); }
149
+ 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
+ if (name === "more") return openMoreMenu();
153
+ if (name === "help") return showHelp();
154
+ if (name === "generate") return generate();
155
+ }
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."); }
157
+ async function generate() {
158
+ const path = askPath(state.path || "guides/new-guide.mdx"); if (!path) return;
159
+ const promptText = prompt("What should the configured agent write?", "Create a practical, complete guide."); if (!promptText) return;
160
+ 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"); }
162
+ }
163
+ 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
+ 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(); } }; }
166
+ 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; } };
168
+ })();
@@ -0,0 +1,165 @@
1
+ /* Chat and task workflows: scoped refinements using the workspace theme. */
2
+ .chat-sidebar[hidden] { display: none !important; }
3
+ .chat-sidebar { container-type: inline-size; box-shadow: none; }
4
+ .chat-sidebar .chat-sidebar__topbar { min-height: 60px; padding: 12px 18px; gap: 12px; background: var(--bg); }
5
+ .chat-sidebar .chat-sidebar__session-chip { min-width: 0; flex: 1; max-width: 100%; }
6
+ .chat-sidebar .chat-sidebar__session-chip-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
7
+ .chat-sidebar .chat-sidebar__new-chat { flex-shrink: 0; min-height: 36px; }
8
+ .chat-sidebar .chat-sidebar__workspace-tools { order: 2; padding: 0 20px 20px; border: 0; background: transparent; }
9
+ .chat-sidebar .chat-sidebar__workspace-label { display: none; }
10
+ .chat-sidebar .chat-sidebar__hero { order: 1; flex: 1 1 auto; min-height: 100px; padding: 32px 24px 20px; justify-content: center; }
11
+ .chat-sidebar .chat-sidebar__hero-title { font-size: 24px; line-height: 1.25; letter-spacing: -.025em; max-width: 320px; }
12
+ .chat-sidebar .chat-sidebar__hero-copy { font-size: 13px; line-height: 1.65; max-width: 320px; margin: 12px auto 0; }
13
+ .chat-sidebar .chat-sidebar__hero-mark { width: 38px; height: 38px; margin-bottom: 18px; }
14
+ .chat-sidebar .chat-sidebar__quick-prompts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; gap: 8px; }
15
+ .chat-sidebar .chat-sidebar__quick-prompts button { min-height: 42px; white-space: normal; text-align: left; padding: 10px 12px; font-size: 12px; color: var(--text-dim); background: var(--bg-elev); border-color: var(--border); }
16
+ .chat-sidebar .chat-sidebar__quick-prompts button::before { display: none; }
17
+ .chat-sidebar.chat-sidebar--has-messages .chat-sidebar__workspace-tools { display: none; }
18
+ .chat-sidebar .chat-sidebar__transcript { order: 2; flex: 1 1 0; min-height: 0; width: 100%; max-width: none; padding: 24px 20px; overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; overscroll-behavior: contain; }
19
+ .chat-sidebar:not(.chat-sidebar--has-messages) .chat-sidebar__transcript { display: none; }
20
+ .chat-sidebar .chat-sidebar__composer { order: 3; position: relative; flex: 0 0 auto; padding: 12px 16px 10px; border: 0; background: var(--bg); width: 100%; box-sizing: border-box; }
21
+ .chat-sidebar .chat-sidebar__composer-surface { width: 100%; min-width: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-elev-2); box-shadow: 0 4px 18px #00000012; }
22
+ .chat-sidebar .chat-sidebar__composer-surface:focus-within { border-color: var(--border); box-shadow: 0 4px 18px #00000012; outline: none; }
23
+ .chat-sidebar .chat-sidebar__composer-input { width: 100%; min-height: 72px; margin: 0; padding: 16px 16px 10px; font: 400 14px/1.6 var(--font-sans, system-ui); color: var(--text); background: transparent; border: 0 !important; outline: none !important; box-shadow: none !important; border-radius: 0; }
24
+ .chat-sidebar .chat-sidebar__composer-input::placeholder { color: var(--text-mute); }
25
+ .chat-sidebar .chat-sidebar__composer-input::selection { color: var(--text); background: color-mix(in srgb, var(--text) 12%, transparent); }
26
+ .chat-sidebar .chat-sidebar__composer-footer { display: flex; flex-wrap: wrap; gap: 4px; min-height: 46px; padding: 6px 8px 8px; }
27
+ .chat-sidebar .chat-sidebar__composer-model { min-width: 0; max-width: 42%; min-height: 32px; padding-inline: 6px; color: var(--text-dim); }
28
+ .chat-sidebar .chat-sidebar__composer-model-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
29
+ .chat-sidebar .chat-sidebar__composer-send, .chat-sidebar .chat-sidebar__composer-abort { margin-left: auto; flex: 0 0 34px; width: 34px; height: 34px; }
30
+ .chat-sidebar .chat-sidebar__send-hint { display: none; }
31
+ .chat-sidebar button:disabled { opacity: .42; cursor: not-allowed; }
32
+ .chat-sidebar button:focus-visible, .chat-sidebar summary:focus-visible { outline: 2px solid var(--text-dim); outline-offset: 3px; }
33
+ .chat-sidebar__input-help { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; margin: 8px 4px 0; color: var(--text-mute); font-size: 10px; line-height: 1.5; }
34
+ .chat-sidebar__feedback { margin: 8px 4px 0; color: var(--text-dim); font-size: 12px; line-height: 1.5; }
35
+ .chat-sidebar__feedback.is-error { color: var(--error, #e87979); }
36
+ .chat-sidebar .chat-sidebar__disclaimer { margin: 6px 0 0; font-size: 10px; line-height: 1.4; }
37
+ .chat-sidebar .chat-bubble-row { margin-block: 0 26px; min-width: 0; }
38
+ .chat-sidebar .chat-bubble-user { border-radius: 16px 16px 4px 16px; background: color-mix(in srgb, var(--accent) 17%, var(--bg-elev)); color: var(--text); border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); max-width: 90%; }
39
+ .chat-sidebar .chat-bubble-body { font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; }
40
+ .chat-sidebar .chat-bubble-body p { margin: 0 0 12px; }
41
+ .chat-sidebar .chat-bubble-body pre { max-width: 100%; overflow: auto; padding: 14px; border-radius: 10px; font-size: 12px; line-height: 1.65; }
42
+ .chat-sidebar .chat-bubble-body table { display: block; max-width: 100%; overflow: auto; }
43
+ /* Preserve the bubble height between debounced incremental markdown renders
44
+ so the message-done swap doesn't visibly reflow the transcript. */
45
+ .chat-sidebar .chat-bubble-body-stream { min-height: 1.4em; }
46
+ .chat-sidebar .chat-bubble-meta { gap: 8px; font-size: 10px; letter-spacing: .025em; color: var(--text-mute); margin-bottom: 10px; }
47
+ .chat-sidebar .chat-copy-button { width: 30px; height: 30px; font-size: 19px; border-radius: 6px; color: var(--text-dim); }
48
+ .chat-sidebar .chat-bubble-streaming-indicator { min-height: 30px; gap: 10px; margin: 4px 0 18px; }
49
+ .chat-sidebar .chat-sidebar-new-messages { position: absolute; z-index: 7; top: 72px; bottom: auto; right: 20px; padding: 7px 12px; border-radius: 18px; background: var(--bg-elev-2); color: var(--text); border: 1px solid var(--border-strong); box-shadow: 0 3px 12px #0002; }
50
+ .chat-sidebar .chat-sidebar__popover { max-width: calc(100% - 16px); max-height: min(440px, 65dvh); overflow-y: auto; scrollbar-width: thin; border-radius: 12px; border-color: var(--border-strong); }
51
+ .chat-sidebar .chat-sidebar__popover-list label { min-height: 38px; }
52
+ .chat-sidebar .chat-sidebar__mention-chip { background: var(--bg-elev); border-color: var(--border); color: var(--text-dim); }
53
+ .workspace-mode-chat .chat-sidebar .chat-sidebar__composer { position: relative; inset: auto !important; transform: none; width: min(100% - 48px, 820px) !important; max-width: 820px; margin: 0 auto 16px; padding: 0 !important; background: transparent; }
54
+ .workspace-mode-chat .chat-sidebar .chat-sidebar__transcript { padding: 28px max(24px, calc((100% - 800px) / 2)); }
55
+ .workspace-mode-chat .chat-sidebar .chat-sidebar__workspace-tools { width: min(100% - 48px, 760px); margin-inline: auto; padding: 0 0 28px; }
56
+ .workspace-mode-chat .chat-sidebar .chat-sidebar__hero { padding-top: 32px; }
57
+ .workspace-mode-chat .chat-sidebar .chat-sidebar__hero-title { font-size: 30px; max-width: 440px; }
58
+ @container (max-width: 380px) {
59
+ .chat-sidebar__input-help span:last-child { display: none; }
60
+ .chat-sidebar .chat-sidebar__composer { padding-inline: 10px; }
61
+ .chat-sidebar .chat-sidebar__composer-model { max-width: 38%; font-size: 11px; }
62
+ .chat-sidebar .chat-sidebar__composer-effort { max-width: 29%; }
63
+ }
64
+ #modal-backdrop .modal { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); border-radius: 16px; display: flex; flex-direction: column; }
65
+ #modal-backdrop .modal-header { flex: 0 0 auto; padding: 20px 24px 16px; }
66
+ #new-task-form { overflow-y: auto; min-height: 0; padding: 0 24px 20px; display: flex; flex-direction: column; gap: 18px; }
67
+ #new-task-form .field { gap: 8px; margin: 0; min-width: 0; }
68
+ #new-task-form .field > span { display: flex; align-items: center; gap: 8px; }
69
+ #new-task-form .field-required, #new-task-form .field-optional { color: var(--text-mute); font-size: 11px; font-weight: 400; }
70
+ #new-task-form .field-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
71
+ #new-task-form input:not([type="checkbox"]), #new-task-form textarea, #new-task-form select { width: 100%; min-height: 40px; font-size: 14px; }
72
+ #new-task-form input[aria-invalid="true"] { border-color: var(--error, #e87979); }
73
+ .task-create-context { margin: 0; color: var(--text-dim); font-size: 12px; line-height: 1.6; }
74
+ .task-create-options { border-block: 1px solid var(--border); padding-block: 14px; }
75
+ .task-create-options summary { cursor: pointer; color: var(--text); font-size: 13px; }
76
+ .task-create-options summary span { margin-left: 8px; color: var(--text-mute); font-size: 11px; }
77
+ .task-create-options p { font-size: 12px; line-height: 1.6; color: var(--text-dim); margin: 12px 0; }
78
+ .task-create-open { display: flex; align-items: center; gap: 8px; color: var(--text-dim); font-size: 12px; }
79
+ #task-create-error { margin: 0; padding: 10px 12px; border-radius: 8px; color: var(--error, #e87979); background: color-mix(in srgb, var(--error, #e87979) 8%, transparent); font-size: 13px; line-height: 1.6; }
80
+ #new-task-form .modal-footer { padding: 14px 0 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
81
+ .task-create-shortcut { flex: 1 0 100%; color: var(--text-mute); font-size: 11px; }
82
+ #task-discard-draft { margin-right: auto; color: var(--text-dim); }
83
+ .project-switcher-identity { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 4px; }
84
+ .project-switcher-identity .project-switcher-name { max-width: none; }
85
+ .project-switcher-path { color: var(--text-mute); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 400; }
86
+ .project-switcher-item { min-height: 58px; }
87
+ .project-switcher-popover { max-height: min(560px, 75dvh); overflow-y: auto; scrollbar-width: thin; }
88
+ @media (max-width: 600px) {
89
+ #modal-backdrop .modal { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); }
90
+ #modal-backdrop .modal-header { padding: 16px; }
91
+ #new-task-form { padding: 0 16px 16px; gap: 14px; }
92
+ .task-create-shortcut { display: none; }
93
+ .chat-sidebar .chat-sidebar__hero { padding: 20px; }
94
+ .chat-sidebar .chat-sidebar__hero-title { font-size: 23px; }
95
+ .workspace-mode-chat .chat-sidebar .chat-sidebar__composer { width: calc(100% - 24px) !important; margin-bottom: 10px; }
96
+ }
97
+ @media (prefers-reduced-motion: reduce) {
98
+ .chat-sidebar *, #modal-backdrop *, .project-switcher-item { scroll-behavior: auto !important; transition: none !important; }
99
+ }
100
+ #new-task-form { padding-top: 12px; }
101
+ #new-task-form .modal-footer { position: sticky; bottom: -20px; z-index: 1; background: var(--bg-elev); margin-inline: -24px; padding: 12px 24px 20px; border-top: 1px solid var(--border); }
102
+ #new-task-form textarea { font-family: inherit; line-height: 1.6; }
103
+ /* Let navigation reflow when the chat rail leaves less horizontal room. */
104
+ .topbar { display: flex; gap: 12px; flex-wrap: wrap; height: auto; min-height: 68px; padding-block: 10px; }
105
+ .topbar .brand { min-width: 0; flex: 0 1 auto; }
106
+ .topbar .topbar-tabs { margin-left: auto; flex: 0 0 auto; }
107
+ .topbar .topbar-right { margin-left: auto; }
108
+ .topbar .workspace-mode { display: flex; flex: 0 0 auto; }
109
+ .topbar .brand-project-chip { min-width: 0; max-width: 190px; }
110
+ body.chat-sidebar-open:not(.workspace-mode-chat) .topbar .brand-wordmark { width: 30px; -webkit-mask-image: url('./brand/logo.svg'); mask-image: url('./brand/logo.svg'); }
111
+ @media (min-width: 901px) and (max-width: 1500px) {
112
+ body.chat-sidebar-open:not(.workspace-mode-chat) .topbar .topbar-tabs { order: 5; margin-left: 0; }
113
+ body.chat-sidebar-open:not(.workspace-mode-chat) .topbar .workspace-mode { order: 4; }
114
+ }
115
+ @media (max-width: 900px) {
116
+ .topbar .brand-wordmark { width: 30px; -webkit-mask-image: url('./brand/logo.svg'); mask-image: url('./brand/logo.svg'); }
117
+ .topbar .brand-project-chip { max-width: 160px; }
118
+ .topbar .topbar-tabs { width: auto; order: 4; }
119
+ .topbar .workspace-mode { order: 3; }
120
+ }
121
+ @media (max-width: 600px) {
122
+ #new-task-form .modal-footer { bottom: -16px; margin-inline: -16px; padding: 12px 16px 16px; }
123
+ }
124
+ .chat-sidebar .chat-sidebar__hero { flex: 1 0 auto; }
125
+ .workspace-mode-chat #board .column:nth-child(n + 3) { display: flex; }
126
+ @media (max-height: 700px) {
127
+ .chat-sidebar .chat-sidebar__hero, .workspace-mode-chat .chat-sidebar .chat-sidebar__hero { min-height: 100px; padding: 16px 20px; }
128
+ .chat-sidebar .chat-sidebar__hero-mark { display: none; }
129
+ .chat-sidebar .chat-sidebar__hero-title, .workspace-mode-chat .chat-sidebar .chat-sidebar__hero-title { font-size: 24px; margin: 0; }
130
+ .chat-sidebar .chat-sidebar__hero-copy { margin-top: 8px; }
131
+ .chat-sidebar .chat-sidebar__workspace-tools, .workspace-mode-chat .chat-sidebar .chat-sidebar__workspace-tools { padding-bottom: 16px; }
132
+ }
133
+ .chat-task-search { display: grid; gap: 10px; padding: 12px; font-size: 13px; color: var(--text-dim); }
134
+ .chat-task-search input { width: 100%; min-height: 38px; }
135
+ .chat-task-option { display: block; width: 100%; text-align: left; border: 0; padding: 12px; background: transparent; color: var(--text); font-size: 13px; border-radius: 8px; }
136
+ .chat-task-option:hover { background: var(--bg-elev-2); }
137
+ [data-task-results] { padding: 4px; font-size: 12px; }
138
+ @media (max-width: 600px) {
139
+ .topbar { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; }
140
+ .topbar .brand { grid-row: 1; grid-column: 1 / 3; }
141
+ .topbar #home-page-btn { grid-row: 1; grid-column: 3; }
142
+ .topbar .workspace-mode { grid-row: 2; grid-column: 1; }
143
+ .topbar .topbar-right { grid-row: 2; grid-column: 2 / 4; width: auto; min-width: 0; }
144
+ .topbar .topbar-tabs { grid-row: 3; grid-column: 1 / -1; margin: 0; justify-self: stretch; width: 100%; }
145
+ .chat-sidebar .chat-sidebar__hero-title, .workspace-mode-chat .chat-sidebar .chat-sidebar__hero-title { font-size: 24px; }
146
+ }
147
+ .chat-sidebar__composer-agent { display: inline-flex; align-items: center; gap: 8px; min-height: 32px; min-width: 0; max-width: 180px; padding: 4px 8px; border: 0; border-radius: 7px; color: var(--text); background: transparent; font: 600 12px/1.4 inherit; }
148
+ .chat-sidebar__composer-agent:hover { background: var(--bg-elev); }
149
+ #chat-sidebar-agent-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
150
+ .chat-agent-option { display: grid; gap: 4px; min-width: 0; }
151
+ .chat-agent-option strong { font-size: 13px; color: var(--text); }
152
+ .chat-agent-option small { font-size: 11px; line-height: 1.5; font-weight: 400; color: var(--text-dim); }
153
+ #chat-sidebar-agent-popover { width: 360px; }
154
+ @container (max-width: 480px) {
155
+ .chat-sidebar__composer-agent { flex: 1 0 100%; max-width: 100%; justify-content: flex-start; }
156
+ }
157
+ .workspace-mode-chat:has(#task-view:not([hidden])) .chat-sidebar { right: min(48vw, 640px); }
158
+ .workspace-mode-chat #task-view:not([hidden]) { position: fixed; top: var(--topbar-height); bottom: 0; right: 0; left: auto; width: min(48vw, 640px); z-index: 20; overflow: auto; padding: 16px; background: var(--bg); border-left: 1px solid var(--border); }
159
+ .workspace-mode-chat #task-view .task-main { grid-template-columns: 1fr; }
160
+ .workspace-mode-chat #task-view .task-header { display: flex; flex-wrap: wrap; gap: 12px; }
161
+ .workspace-mode-chat #task-view .task-header .task-title { flex: 1 0 100%; }
162
+ @media (max-width: 900px) {
163
+ .workspace-mode-chat #task-view:not([hidden]) { width: 100%; z-index: 60; }
164
+ .workspace-mode-chat:has(#task-view:not([hidden])) .chat-sidebar { right: 0; }
165
+ }
@@ -0,0 +1,45 @@
1
+ // OpenKan Goals — durable PRD goals from the canonical .ok/ workspace.
2
+ (() => {
3
+ "use strict";
4
+ const { api } = window.OpenKanAPI;
5
+ let root = null;
6
+ let mounted = false;
7
+ const STATUS_LABELS = { open: "Open", in_progress: "In progress", met: "Met", dropped: "Dropped" };
8
+ const STATUS_ORDER = ["open", "in_progress", "met", "dropped"];
9
+
10
+ function escapeHtml(value) {
11
+ const el = document.createElement("span");
12
+ el.textContent = String(value ?? "");
13
+ return el.innerHTML;
14
+ }
15
+ function goalRow(prd, goal) {
16
+ const actions = STATUS_ORDER.map((status) => `<button type="button" class="goal-status ${goal.status === status ? "is-active" : ""}" data-prd="${prd.id}" data-goal="${goal.id}" data-status="${status}" aria-pressed="${goal.status === status}">${STATUS_LABELS[status]}</button>`).join("");
17
+ return `<li class="goal-row"><div><span class="goal-copy">${escapeHtml(goal.text)}</span><span class="goal-current goal-${goal.status}">${STATUS_LABELS[goal.status]}</span></div><div class="goal-actions" aria-label="Set goal status">${actions}</div></li>`;
18
+ }
19
+ function render(prds) {
20
+ if (!root) return;
21
+ const activeGoals = prds.flatMap((prd) => prd.goals || []).filter((goal) => goal.status !== "met" && goal.status !== "dropped").length;
22
+ const metGoals = prds.flatMap((prd) => prd.goals || []).filter((goal) => goal.status === "met").length;
23
+ root.innerHTML = `<div class="goals-view">
24
+ <header class="goals-header"><div><span class="workspace-eyebrow">Planning workspace</span><h2>Goals</h2><p>Long-horizon outcomes stored directly in <code>.ok/prds</code>.</p></div><div class="goals-summary"><span><strong>${activeGoals}</strong> active</span><span><strong>${metGoals}</strong> met</span></div></header>
25
+ ${prds.length ? `<div class="goals-list">${prds.map((prd) => `<article class="goal-prd-card"><header><div><span class="goal-prd-id">${escapeHtml(prd.id)}</span><h3>${escapeHtml(prd.title)}</h3></div><span class="goal-prd-status">${escapeHtml(prd.status)}</span></header><p>${escapeHtml(prd.vision)}</p><ul>${(prd.goals || []).map((goal) => goalRow(prd, goal)).join("") || "<li class=\"goal-row-empty\">No goals defined yet.</li>"}</ul></article>`).join("")}</div>` : `<div class="goals-empty"><strong>No goals yet</strong><p>Create a PRD with <code>ok prd add</code>; it will appear here automatically.</p></div>`}
26
+ </div>`;
27
+ }
28
+ async function refresh() {
29
+ if (!root) return;
30
+ root.innerHTML = '<div class="goals-loading">Loading durable goals…</div>';
31
+ try { render((await api("GET", "/api/goals")).prds || []); }
32
+ catch (error) { root.innerHTML = `<div class="goals-empty"><strong>Goals could not load</strong><p>${escapeHtml(error.message)}</p></div>`; }
33
+ }
34
+ async function onClick(event) {
35
+ const button = event.target.closest("[data-goal][data-status]");
36
+ if (!button || button.classList.contains("is-active")) return;
37
+ button.disabled = true;
38
+ try { await api("PATCH", `/api/goals/${button.dataset.prd}/${button.dataset.goal}`, { status: button.dataset.status }); await refresh(); }
39
+ catch (error) { button.disabled = false; window.alert(`Could not update goal: ${error.message}`); }
40
+ }
41
+ window.OpenKanGoals = {
42
+ mount(target) { if (mounted && root === target) return; root = target; mounted = true; root.addEventListener("click", onClick); refresh(); },
43
+ unmount() { if (root) root.removeEventListener("click", onClick); root = null; mounted = false; },
44
+ };
45
+ })();
@@ -0,0 +1,113 @@
1
+ // OpenKan Home — a live operational overview built from the active workspace.
2
+ (() => {
3
+ "use strict";
4
+ const { api } = window.OpenKanAPI;
5
+ let frame = 0;
6
+ let activeRoot = null;
7
+ const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" })[c]);
8
+ const isWorktree = (root) => /\/(?:\.claude|\.git)\/worktrees\//.test(String(root || "").replace(/\\/g, "/"));
9
+ const flatten = (entries, out = []) => { for (const entry of entries || []) entry.isDir ? flatten(entry.children, out) : out.push(entry); return out; };
10
+ const countColumns = (tasks) => tasks.reduce((acc, task) => { acc[task.column || "backlog"] = (acc[task.column || "backlog"] || 0) + 1; return acc; }, {});
11
+ const short = (value) => String(value || "").replace(/^.*\//, "");
12
+
13
+ function flowSvg(days) {
14
+ const points = (days || []).map((day, index) => ({ x: 8 + index * (248 / Math.max(1, days.length - 1)), y: 56 - Math.min(48, (day.total || 0) * 8) }));
15
+ const line = points.map((point, index) => `${index ? "L" : "M"}${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(" ");
16
+ const area = `${line} L256 64 L8 64 Z`;
17
+ return `<svg class="home-flow-chart" viewBox="0 0 264 70" role="img" aria-label="Task activity over the last 30 days"><path class="home-flow-area" d="${area}"/><path class="home-flow-line" d="${line}"/>${points.filter((_, i) => i % Math.max(1, Math.ceil(points.length / 7)) === 0).map((p) => `<circle cx="${p.x}" cy="${p.y}" r="2"/>`).join("")}</svg>`;
18
+ }
19
+
20
+ function drawNetwork(canvas, projects, agents, counts) {
21
+ cancelAnimationFrame(frame);
22
+ const context = canvas.getContext("2d");
23
+ if (!context) return;
24
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
25
+ const resize = () => {
26
+ const box = canvas.getBoundingClientRect();
27
+ canvas.width = Math.max(1, Math.round(box.width * dpr));
28
+ canvas.height = Math.max(1, Math.round(box.height * dpr));
29
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
30
+ return box;
31
+ };
32
+ let box = resize();
33
+ const seed = [...projects, ...agents].map((item, index) => ({
34
+ label: item.name || item.id || `Node ${index + 1}`,
35
+ kind: item.root ? "project" : "agent",
36
+ angle: (Math.PI * 2 * index) / Math.max(1, projects.length + agents.length),
37
+ orbit: 0.21 + ((index * 37) % 38) / 100,
38
+ offset: index * 0.67,
39
+ }));
40
+ const taskNode = { label: `${(counts.backlog || 0) + (counts.todo || 0) + (counts.doing || 0) + (counts.review || 0)} active tasks`, kind: "task" };
41
+ const paint = (time) => {
42
+ if (!activeRoot?.contains(canvas)) return;
43
+ box = resize();
44
+ const width = box.width, height = box.height, cx = width * 0.5, cy = height * 0.5;
45
+ context.clearRect(0, 0, width, height);
46
+ const gradient = context.createRadialGradient(cx, cy, 4, cx, cy, Math.max(width, height) * .62);
47
+ gradient.addColorStop(0, "rgba(111, 104, 255, .15)"); gradient.addColorStop(1, "rgba(111, 104, 255, 0)");
48
+ context.fillStyle = gradient; context.fillRect(0, 0, width, height);
49
+ const nodes = seed.map((item, index) => {
50
+ const r = Math.min(width, height) * item.orbit;
51
+ const phase = time / 5000 + item.offset;
52
+ return { ...item, x: cx + Math.cos(item.angle + phase * .2) * r, y: cy + Math.sin(item.angle + phase * .24) * r, radius: item.kind === "project" ? 6 : 4 };
53
+ });
54
+ context.lineWidth = 1;
55
+ nodes.forEach((node, index) => {
56
+ const next = nodes[(index + 1) % Math.max(1, nodes.length)];
57
+ if (!next) return;
58
+ context.strokeStyle = "rgba(126, 149, 195, .22)";
59
+ context.beginPath(); context.moveTo(cx, cy); context.lineTo(node.x, node.y); context.lineTo(next.x, next.y); context.stroke();
60
+ });
61
+ nodes.forEach((node) => {
62
+ const color = node.kind === "project" ? "#7c73ff" : "#55d6a3";
63
+ context.fillStyle = color; context.shadowBlur = node.kind === "project" ? 15 : 10; context.shadowColor = color;
64
+ context.beginPath(); context.arc(node.x, node.y, node.radius, 0, Math.PI * 2); context.fill(); context.shadowBlur = 0;
65
+ });
66
+ context.fillStyle = "#111b2c"; context.strokeStyle = "rgba(135, 151, 180, .8)"; context.lineWidth = 1.2;
67
+ context.beginPath(); context.arc(cx, cy, 18, 0, Math.PI * 2); context.fill(); context.stroke();
68
+ context.fillStyle = "#e7edff"; context.font = "600 10px ui-monospace, monospace"; context.textAlign = "center"; context.fillText("WORK", cx, cy + 3);
69
+ frame = requestAnimationFrame(paint);
70
+ };
71
+ const observer = new ResizeObserver(() => { box = resize(); }); observer.observe(canvas);
72
+ canvas._openKanObserver = observer;
73
+ frame = requestAnimationFrame(paint);
74
+ }
75
+
76
+ function render(root, data) {
77
+ const projects = data.projects.filter((project) => !isWorktree(project.root));
78
+ const tasks = data.board.tasks || data.board || [];
79
+ const counts = countColumns(Array.isArray(tasks) ? tasks : []);
80
+ const activeAgents = data.agents.filter((agent) => /running|active|working|busy/i.test(String(agent.status || agent.state || "")));
81
+ const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
82
+ const goals = data.goals.prds || data.goals || [];
83
+ const metGoals = (Array.isArray(goals) ? goals : []).flatMap((prd) => prd.goals || []).filter((goal) => goal.status === "met").length;
84
+ const docs = flatten(data.docs.entries || []).filter((entry) => /\.mdx?$/i.test(entry.path)).length;
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>
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
+ <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
+ <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>
90
+ </section>`;
91
+ root.querySelectorAll("[data-home-action]").forEach((button) => button.addEventListener("click", () => window.OpenKanTabs?.activate?.(button.dataset.homeAction)));
92
+ drawNetwork(root.querySelector(".home-network"), projects, activeAgents, counts);
93
+ }
94
+
95
+ async function mount(root) {
96
+ activeRoot = root;
97
+ root.innerHTML = `<section class="home-view home-loading">Preparing workspace overview…</section>`;
98
+ const [projects, board, agents, goals, velocity, docs] = await Promise.allSettled([
99
+ api("GET", "/api/projects"), api("GET", "/api/board"), api("GET", "/api/claude/agents"), api("GET", "/api/goals"), api("GET", "/api/insights/velocity?days=30"), api("GET", "/api/docs"),
100
+ ]);
101
+ if (activeRoot !== root) return;
102
+ render(root, {
103
+ projects: projects.status === "fulfilled" ? projects.value.projects || [] : [],
104
+ board: board.status === "fulfilled" ? board.value : { tasks: [] },
105
+ agents: agents.status === "fulfilled" ? agents.value.agents || agents.value || [] : [],
106
+ goals: goals.status === "fulfilled" ? goals.value : [],
107
+ velocity: velocity.status === "fulfilled" ? velocity.value : { days: [] },
108
+ docs: docs.status === "fulfilled" ? docs.value : { entries: [] },
109
+ });
110
+ }
111
+ function unmount() { cancelAnimationFrame(frame); activeRoot?.querySelectorAll("canvas").forEach((canvas) => canvas._openKanObserver?.disconnect()); activeRoot = null; }
112
+ window.OpenKanHome = { mount, unmount };
113
+ })();