diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 59b71c0da..8018eb5ed 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -166,6 +166,12 @@ "description": "A comprehensive collection of specialized agents for software analysis, impact assessment, structural quality advisories, and architectural review using CAST Imaging.", "version": "1.0.0" }, + { + "name": "catpilot-canvas", + "source": "extensions/catpilot-canvas", + "description": "Visual command center canvas for CatPilot: manage tasks, journal, milestones, memos, learning, growth, projects, Copilot reports and an activity timeline with dashboards, charts, a markdown editor and interactive settings.", + "version": "1.0.0" + }, { "name": "chrome-devtools-plugin", "description": "Reliable automation, in-depth debugging, and performance analysis in Chrome using Chrome DevTools and Puppeteer.", diff --git a/extensions/catpilot-canvas/.github/plugin/plugin.json b/extensions/catpilot-canvas/.github/plugin/plugin.json new file mode 100644 index 000000000..1f614f378 --- /dev/null +++ b/extensions/catpilot-canvas/.github/plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "catpilot-canvas", + "description": "Visual command center canvas for CatPilot: manage tasks, journal, milestones, memos, learning, growth, projects, Copilot reports and an activity timeline with dashboards, charts, a markdown editor and interactive settings.", + "version": "1.0.0", + "author": { + "name": "Albert Tanure", + "url": "https://github.com/tanure" + }, + "keywords": [ + "canvas", + "productivity", + "task-management", + "dashboard", + "personal-assistant", + "markdown-editor" + ], + "logo": "assets/preview.png", + "extensions": "." +} diff --git a/extensions/catpilot-canvas/README.md b/extensions/catpilot-canvas/README.md new file mode 100644 index 000000000..57b4da9f7 --- /dev/null +++ b/extensions/catpilot-canvas/README.md @@ -0,0 +1,27 @@ +# CatPilot Canvas + +A beautiful, modern **GitHub Copilot canvas extension** that turns [CatPilot](https://github.com/tanure/cat-copilot) into a visual command center — manage everything CatPilot tracks without leaving the Copilot app. + +![CatPilot canvas dashboard](assets/preview.png) + +## What it does + +The canvas reads and writes the **same** config-driven storage as the CatPilot CLI, agent, skills, and MCP server, so every surface stays in sync. + +- **Dashboard** — hero greeting, summary cards, an open-tasks-by-priority donut, a last-3-days activity chart, a focus list and a recent-activity feed. +- **Tasks** — switch between **list (table)** and **board (kanban)** views, complete inline, edit/save locally, and open a detail popup. +- **Journal, Milestones, Memos, Learning, Growth, Projects** — browse, add, and open full detail views. +- **Reports** — generate GitHub Copilot **executive reports** for any period (this week, last month, all time…) and open them as markdown or HTML. +- **Timeline** — a 7/14/30-day activity rail grouped by day, with one-click **agent actions**. +- **Settings** — an interactive config wizard that **previews** exactly which files a storage/partition change would move, gated behind an explicit approval before anything is migrated. +- **Help** — an in-canvas capabilities guide. +- **Markdown everywhere** — every text field has a formatting toolbar, a live preview toggle, and a **Generate with Copilot** button. A global **Ask Copilot** button hands any prompt to the agent. +- **Light / dark** theme toggle. + +## Assets + +- `assets/preview.png` — dashboard screenshot used for the extension card. + +## Author + +Built by [Albert Tanure](https://github.com/tanure). Source: [tanure/cat-copilot](https://github.com/tanure/cat-copilot). diff --git a/extensions/catpilot-canvas/assets/preview.png b/extensions/catpilot-canvas/assets/preview.png new file mode 100644 index 000000000..627a5ca24 Binary files /dev/null and b/extensions/catpilot-canvas/assets/preview.png differ diff --git a/extensions/catpilot-canvas/catpilot-store.mjs b/extensions/catpilot-canvas/catpilot-store.mjs new file mode 100644 index 000000000..8095369ca --- /dev/null +++ b/extensions/catpilot-canvas/catpilot-store.mjs @@ -0,0 +1,1145 @@ +// catpilot-store.mjs +// Self-contained storage engine for the CatPilot canvas. +// +// This mirrors the EXACT file formats used by CatPilot's lib/cli-utils.js and +// lib/domains.js so everything written here is read back identically by the +// CatPilot agent, the `cat-pilot` CLI, and the CatPilot MCP server. It is kept +// dependency-free and self-contained so the extension folder is portable and +// can be shared as a unit (repo commit or gist). + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const GLOBAL_CONFIG_DIR = path.join(os.homedir(), ".catpilot"); +const GLOBAL_CONFIG_PATH = path.join(GLOBAL_CONFIG_DIR, "config.json"); + +const DEFAULT_FILES = { + tasks: "tasks.md", + journal: "journal.md", + milestones: "milestones.md", + memos: "memos", + learning: "learning", + growth: "growth", + projects: "projects", + reports: "reports", +}; + +const DOMAIN_TAG = { learning: "learning", growth: "growth", projects: "project" }; + +// --------------------------------------------------------------------------- +// Config resolution (matches lib/cli-utils.js resolveConfigPath order) +// --------------------------------------------------------------------------- +function resolveConfigPath(projectRoot = process.cwd()) { + if (process.env.CATPILOT_CONFIG) return { path: process.env.CATPILOT_CONFIG, scope: "env-config" }; + if (process.env.CATPILOT_ROOT) return { path: path.join(process.env.CATPILOT_ROOT, "data", "config.json"), scope: "env-root" }; + const localPath = path.join(projectRoot, "data", "config.json"); + if (fs.existsSync(localPath)) return { path: localPath, scope: "local" }; + if (fs.existsSync(GLOBAL_CONFIG_PATH)) return { path: GLOBAL_CONFIG_PATH, scope: "global" }; + return { path: GLOBAL_CONFIG_PATH, scope: "none" }; +} + +function configBaseDir(configPath) { + const dir = path.dirname(configPath); + if (path.basename(dir).toLowerCase() === "data") return path.dirname(dir); + return dir; +} + +export function configStatus(projectRoot = process.cwd()) { + const { path: configPath, scope } = resolveConfigPath(projectRoot); + const exists = scope !== "none" && fs.existsSync(configPath); + return { configured: exists, configPath, scope }; +} + +export function loadConfig(projectRoot = process.cwd()) { + const { path: configPath } = resolveConfigPath(projectRoot); + if (!fs.existsSync(configPath)) { + const err = new Error("CatPilot is not set up yet."); + err.code = "NOT_CONFIGURED"; + throw err; + } + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + config.__baseDir = configBaseDir(configPath); + config.__configPath = configPath; + return config; +} + +// Write a global config during onboarding. First-run setup has no prior +// configured location, so there is nothing to move/copy — always adopt the +// chosen root. (Move/Copy migration is offered later from Settings, where the +// plan/preview/approve flow in applyConfigChange handles it safely.) +export function writeConfig({ root, partitioning = "month" } = {}) { + if (!root || !String(root).trim()) throw new Error("A storage root path is required."); + if (!["day", "week", "month"].includes(partitioning)) throw new Error("partitioning must be day, week, or month."); + const config = { + version: 1, + storage: { + root: String(root).trim(), + partitioning, + allowExternalPaths: true, + files: { ...DEFAULT_FILES }, + }, + migration: { mode: "adopt" }, + }; + fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true }); + fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8"); + config.__baseDir = configBaseDir(GLOBAL_CONFIG_PATH); + config.__configPath = GLOBAL_CONFIG_PATH; + return config; +} + +// --------------------------------------------------------------------------- +// Path resolution (matches getPartitionFolder / resolveFilePath) +// --------------------------------------------------------------------------- +function getISOWeek(date) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); +} + +function getPartitionFolder(partitioning = "month") { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const date = String(now.getDate()).padStart(2, "0"); + if (partitioning === "day") return `${year}/${year}-${month}/${year}-${month}-${date}`; + if (partitioning === "week") return `${year}/W${String(getISOWeek(now)).padStart(2, "0")}`; + return `${year}/${year}-${month}`; +} + +function resolveFilePath(type, config) { + const partition = getPartitionFolder(config.storage.partitioning); + const base = config.__baseDir || process.cwd(); + const storageRoot = path.resolve(base, config.storage.root); + const fileName = (config.storage.files && config.storage.files[type]) || DEFAULT_FILES[type]; + if (!fileName) throw new Error(`Unknown file type: ${type}`); + return path.join(storageRoot, partition, fileName); +} + +function ensureDir(dir) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +// Never overwrite an existing file: if the target path is taken, append +// -2, -3, … before the extension so a "Create" action can never destroy data. +function uniquePath(p) { + if (!fs.existsSync(p)) return p; + const dir = path.dirname(p); + const ext = path.extname(p); + const stem = path.basename(p, ext); + for (let i = 2; i < 10000; i++) { + const candidate = path.join(dir, `${stem}-${i}${ext}`); + if (!fs.existsSync(candidate)) return candidate; + } + throw new Error(`Could not allocate a unique filename for ${p}`); +} + +function readFileOrCreate(filePath, def = "") { + ensureDir(path.dirname(filePath)); + if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, def, "utf8"); + return fs.readFileSync(filePath, "utf8"); +} + +function todayISO() { + return new Date().toISOString().split("T")[0]; +} + +function slugify(title) { + return String(title) + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .substring(0, 50) || "note"; +} + +// --------------------------------------------------------------------------- +// Tasks +// --------------------------------------------------------------------------- +// Markdown-table cells are pipe/newline delimited, so any user value that +// contains `|` or a line break must be escaped on write and restored on read, +// otherwise it spills into extra cells/rows and corrupts the table. +function encodeCell(v) { + return String(v == null ? "" : v).replace(/\r?\n/g, "
").replace(/\|/g, "\\|"); +} + +function decodeCell(v) { + return String(v == null ? "" : v).replace(//gi, "\n").replace(/\\\|/g, "|").trim(); +} + +function splitCells(line) { + return line.split(/(?= 7) { + tasks.push({ + id: parseInt(cells[0], 10) || 0, + status: cells[1] || "Open", + title: cells[2] || "", + dueDate: cells[3] || "", + priority: cells[4] || "", + tags: cells[5] || "", + context: cells[6] || "", + }); + } + } + } + return tasks; +} + +function formatTasksTable(tasks) { + const open = tasks.filter((t) => t.status === "Open" || t.status === "open"); + const done = tasks.filter((t) => t.status === "Done" || t.status === "done"); + const header = "| ID | Status | Title | Due Date | Priority | Tags | Context |\n| --- | --- | --- | --- | --- | --- | --- |\n"; + let out = ""; + if (open.length) { + out += "## Open Tasks\n\n" + header; + open.forEach((t) => { out += `| ${t.id} | ${encodeCell(t.status)} | ${encodeCell(t.title)} | ${encodeCell(t.dueDate)} | ${encodeCell(t.priority)} | ${encodeCell(t.tags)} | ${encodeCell(t.context)} |\n`; }); + out += "\n"; + } + if (done.length) { + out += "## Completed Tasks\n\n" + header; + done.forEach((t) => { out += `| ${t.id} | ${encodeCell(t.status)} | ${encodeCell(t.title)} | ${encodeCell(t.dueDate)} | ${encodeCell(t.priority)} | ${encodeCell(t.tags)} | ${encodeCell(t.context)} |\n`; }); + } + return out.trimEnd(); +} + +function nextId(rows) { + if (!rows.length) return 1; + return Math.max(...rows.map((r) => r.id || 0)) + 1; +} + +export function listTasks(status = "all") { + const config = loadConfig(); + const p = resolveFilePath("tasks", config); + const tasks = parseTasksTable(readFileOrCreate(p)); + if (status === "all") return tasks; + return tasks.filter((t) => t.status.toLowerCase() === status.toLowerCase()); +} + +export function addTask(params = {}) { + if (!params.title) throw new Error("title is required"); + const config = loadConfig(); + const p = resolveFilePath("tasks", config); + const tasks = parseTasksTable(readFileOrCreate(p)); + const task = { + id: nextId(tasks), + status: "Open", + title: params.title, + dueDate: params.due || "", + priority: params.priority || "", + tags: params.tags || "", + context: params.context || "", + }; + tasks.push(task); + fs.writeFileSync(p, formatTasksTable(tasks), "utf8"); + return task; +} + +export function updateTask(id, patch = {}) { + const config = loadConfig(); + const p = resolveFilePath("tasks", config); + const tasks = parseTasksTable(readFileOrCreate(p)); + const t = tasks.find((x) => x.id === parseInt(id, 10)); + if (!t) throw new Error(`Task #${id} not found`); + for (const k of ["status", "title", "dueDate", "priority", "tags", "context"]) { + if (patch[k] !== undefined) t[k] = patch[k]; + } + fs.writeFileSync(p, formatTasksTable(tasks), "utf8"); + return t; +} + +export function completeTask(id) { + return updateTask(id, { status: "Done" }); +} + +export function removeTask(id) { + const config = loadConfig(); + const p = resolveFilePath("tasks", config); + const tasks = parseTasksTable(readFileOrCreate(p)); + const idx = tasks.findIndex((x) => x.id === parseInt(id, 10)); + if (idx === -1) throw new Error(`Task #${id} not found`); + const [removed] = tasks.splice(idx, 1); + fs.writeFileSync(p, formatTasksTable(tasks), "utf8"); + return removed; +} + +// --------------------------------------------------------------------------- +// Journal +// --------------------------------------------------------------------------- +function parseJournalEntries(content) { + const lines = content.split("\n"); + const entries = []; + let date = null; + let text = []; + for (const line of lines) { + if (line.match(/^###\s+\d{4}-\d{2}-\d{2}$/)) { + if (date) entries.push({ date, text: text.join("\n").trim() }); + date = line.replace("### ", "").trim(); + text = []; + } else if (date) { + text.push(line); + } + } + if (date) entries.push({ date, text: text.join("\n").trim() }); + return entries; +} + +export function listJournal(days = 3650) { + const config = loadConfig(); + const p = resolveFilePath("journal", config); + const entries = parseJournalEntries(readFileOrCreate(p)); + return entries.slice(-days).reverse(); +} + +export function addJournal(text) { + if (!text || !String(text).trim()) throw new Error("text is required"); + const config = loadConfig(); + const p = resolveFilePath("journal", config); + const content = readFileOrCreate(p); + const today = todayISO(); + const heading = `### ${today}`; + let next; + if (content.includes(heading)) next = content.replace(heading, `${heading}\n\n${text}`); + else next = content ? `${content}\n\n${heading}\n\n${text}` : `${heading}\n\n${text}`; + fs.writeFileSync(p, next, "utf8"); + return { date: today, text }; +} + +// --------------------------------------------------------------------------- +// Milestones (table: ID | Name | Target Date | Status | Notes) +// --------------------------------------------------------------------------- +function parseMilestones(content) { + const lines = content.split("\n"); + const rows = []; + for (const line of lines) { + if (!line.includes("|")) continue; + if (line.match(/^[\s\-|]+$/)) continue; + const cells = splitCells(line); + if (cells.length && cells[0] === "") cells.shift(); + if (cells.length && cells[cells.length - 1] === "") cells.pop(); + if (!cells.length) continue; + if (/^id$/i.test(cells[0])) continue; // header + const id = parseInt(cells[0], 10); + if (!Number.isFinite(id)) continue; + rows.push({ + id, + name: cells[1] || "", + targetDate: cells[2] || "", + status: cells[3] || "Planned", + notes: cells[4] || "", + }); + } + return rows; +} + +function formatMilestones(rows) { + let out = "# Milestones\n\n| ID | Name | Target Date | Status | Notes |\n| --- | --- | --- | --- | --- |\n"; + rows.forEach((m) => { out += `| ${m.id} | ${encodeCell(m.name)} | ${encodeCell(m.targetDate)} | ${encodeCell(m.status)} | ${encodeCell(m.notes)} |\n`; }); + return out.trimEnd(); +} + +export function listMilestones() { + const config = loadConfig(); + const p = resolveFilePath("milestones", config); + return parseMilestones(readFileOrCreate(p)); +} + +export function addMilestone(params = {}) { + if (!params.name) throw new Error("name is required"); + const config = loadConfig(); + const p = resolveFilePath("milestones", config); + const rows = parseMilestones(readFileOrCreate(p)); + const m = { + id: nextId(rows), + name: params.name, + targetDate: params.targetDate || "", + status: params.status || "Planned", + notes: params.notes || "", + }; + rows.push(m); + fs.writeFileSync(p, formatMilestones(rows), "utf8"); + return m; +} + +export function updateMilestone(id, patch = {}) { + const config = loadConfig(); + const p = resolveFilePath("milestones", config); + const rows = parseMilestones(readFileOrCreate(p)); + const m = rows.find((x) => x.id === parseInt(id, 10)); + if (!m) throw new Error(`Milestone #${id} not found`); + for (const k of ["name", "targetDate", "status", "notes"]) if (patch[k] !== undefined) m[k] = patch[k]; + fs.writeFileSync(p, formatMilestones(rows), "utf8"); + return m; +} + +// --------------------------------------------------------------------------- +// Memos +// --------------------------------------------------------------------------- +export function listMemos() { + const config = loadConfig(); + const dir = resolveFilePath("memos", config); + ensureDir(dir); + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort().reverse(); + return files.map((filename) => { + let title = filename.replace(/\.md$/, ""); + try { + const first = fs.readFileSync(path.join(dir, filename), "utf8").split("\n")[0]; + title = first.replace(/^#\s+/, "").trim() || title; + } catch { /* ignore */ } + const dateMatch = filename.match(/^(\d{4}-\d{2}-\d{2})_/); + return { filename, title, date: dateMatch ? dateMatch[1] : "" }; + }); +} + +export function readMemo(filename) { + const config = loadConfig(); + const dir = resolveFilePath("memos", config); + const safe = path.basename(String(filename || "")); + const p = path.join(dir, safe); + if (path.dirname(path.resolve(p)) !== path.resolve(dir)) throw new Error("Invalid memo path"); + if (!fs.existsSync(p)) throw new Error(`Memo not found: ${safe}`); + const content = fs.readFileSync(p, "utf8"); + const lines = content.split("\n"); + const title = (lines[0] || "").replace(/^#\s+/, "").trim(); + const body = lines.slice(2).join("\n").trim(); + return { filename: safe, title, content: body }; +} + +export function createMemo(params = {}) { + if (!params.title) throw new Error("title is required"); + const config = loadConfig(); + const dir = resolveFilePath("memos", config); + ensureDir(dir); + const filename = `${todayISO()}_${slugify(params.title)}.md`; + const p = uniquePath(path.join(dir, filename)); + fs.writeFileSync(p, `# ${params.title}\n\n${params.content || "Add your memo content here."}`, "utf8"); + return { filename: path.basename(p), title: params.title }; +} + +// --------------------------------------------------------------------------- +// Per-file domains: learning, growth, projects (YAML frontmatter notes) +// --------------------------------------------------------------------------- +function toFrontmatter(obj) { + const lines = ["---"]; + for (const [k, v] of Object.entries(obj)) { + if (Array.isArray(v)) lines.push(`${k}: [${v.map((x) => JSON.stringify(String(x))).join(", ")}]`); + else lines.push(`${k}: ${JSON.stringify(v == null ? "" : String(v))}`); + } + lines.push("---"); + return lines.join("\n"); +} + +function parseFrontmatter(content) { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + const data = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + let raw = line.slice(idx + 1).trim(); + if (raw.startsWith("[") && raw.endsWith("]")) { + raw = raw.slice(1, -1).split(",").map((s) => s.trim().replace(/^"|"$/g, "")).filter(Boolean); + } else { + raw = raw.replace(/^"|"$/g, ""); + } + data[key] = raw; + } + return data; +} + +function assertDomain(domain) { + if (!(domain in DOMAIN_TAG)) throw new Error(`Unknown domain: ${domain}`); +} + +export function listNotes(domain) { + assertDomain(domain); + const config = loadConfig(); + const dir = resolveFilePath(domain, config); + ensureDir(dir); + return fs.readdirSync(dir) + .filter((f) => f.endsWith(".md")) + .sort() + .reverse() + .map((filename) => { + let frontmatter = {}; + try { frontmatter = parseFrontmatter(fs.readFileSync(path.join(dir, filename), "utf8")); } catch { /* ignore */ } + return { filename, frontmatter }; + }); +} + +export function readNote(domain, filename) { + assertDomain(domain); + const config = loadConfig(); + const dir = resolveFilePath(domain, config); + const safe = path.basename(String(filename || "")); + const p = path.join(dir, safe); + if (path.dirname(path.resolve(p)) !== path.resolve(dir)) throw new Error("Invalid note path"); + if (!fs.existsSync(p)) throw new Error(`Note not found: ${safe}`); + const content = fs.readFileSync(p, "utf8"); + return { filename: safe, frontmatter: parseFrontmatter(content), body: content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim() }; +} + +export function addNote(domain, params = {}) { + assertDomain(domain); + if (!params.title) throw new Error("title is required"); + const config = loadConfig(); + const dir = resolveFilePath(domain, config); + ensureDir(dir); + const filename = `${todayISO()}_${slugify(params.title)}.md`; + const p = uniquePath(path.join(dir, filename)); + if (path.dirname(path.resolve(p)) !== path.resolve(dir)) throw new Error("Invalid note path"); + const frontmatter = { catpilot: DOMAIN_TAG[domain], title: params.title, date: todayISO(), ...(params.frontmatter || {}) }; + const body = params.body ? `\n${params.body}\n` : "\n"; + fs.writeFileSync(p, `${toFrontmatter(frontmatter)}\n\n# ${params.title}\n${body}`, "utf8"); + return { filename: path.basename(p), frontmatter }; +} + +// --------------------------------------------------------------------------- +// Dashboard summary aggregation +// --------------------------------------------------------------------------- +function daysAgoISO(n) { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().split("T")[0]; +} + +// --------------------------------------------------------------------------- +// Cross-partition aggregation. Range-based reads (dashboard last-3-days, +// timeline, report journal) must see data across every day/week/month +// partition, not just the current one. ID-keyed editable domains (tasks, +// milestones) intentionally stay current-partition to match their list views +// and avoid cross-partition ID collisions on edit. +// --------------------------------------------------------------------------- +function walkFind(root, targetName, isDir, depth, acc) { + if (depth > 5 || !fs.existsSync(root)) return acc; + let entries; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return acc; } + for (const e of entries) { + const full = path.join(root, e.name); + if (e.name === targetName && ((isDir && e.isDirectory()) || (!isDir && e.isFile()))) { + acc.push(full); + } else if (e.isDirectory() && !e.name.startsWith(".")) { + // Skip dotfolders (.trash, .git, .obsidian, …) — they are tooling + // state, not CatPilot partitions. + walkFind(full, targetName, isDir, depth + 1, acc); + } + } + return acc; +} + +function allPartitionPaths(config, type) { + const base = config.__baseDir || process.cwd(); + const storageRoot = path.resolve(base, config.storage.root); + const fileName = (config.storage.files && config.storage.files[type]) || DEFAULT_FILES[type]; + if (!fileName) return []; + const isDir = !/\.[a-z]+$/i.test(fileName); + return walkFind(storageRoot, fileName, isDir, 0, []); +} + +function collectJournal(config) { + const out = []; + for (const p of allPartitionPaths(config, "journal")) { + try { out.push(...parseJournalEntries(fs.readFileSync(p, "utf8"))); } catch { /* ignore */ } + } + return out; +} + +function collectMemos(config) { + const out = []; + for (const dir of allPartitionPaths(config, "memos")) { + let files = []; + try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")); } catch { /* ignore */ } + for (const filename of files) { + let title = filename.replace(/\.md$/, ""); + try { + const first = fs.readFileSync(path.join(dir, filename), "utf8").split("\n")[0]; + title = first.replace(/^#\s+/, "").trim() || title; + } catch { /* ignore */ } + const m = filename.match(/^(\d{4}-\d{2}-\d{2})_/); + out.push({ filename, title, date: m ? m[1] : "" }); + } + } + return out; +} + +function collectNotes(config, domain) { + const out = []; + for (const dir of allPartitionPaths(config, domain)) { + let files = []; + try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")); } catch { /* ignore */ } + for (const filename of files) { + let frontmatter = {}; + try { frontmatter = parseFrontmatter(fs.readFileSync(path.join(dir, filename), "utf8")); } catch { /* ignore */ } + out.push({ filename, frontmatter }); + } + } + return out; +} + +function collectReports(config) { + const out = []; + for (const dir of allPartitionPaths(config, "reports")) { + let files = []; + try { files = fs.readdirSync(dir).filter((f) => /\.(md|html?)$/i.test(f)); } catch { /* ignore */ } + for (const filename of files) { + let title = filename; + try { + const c = fs.readFileSync(path.join(dir, filename), "utf8"); + const h = c.match(/^#\s+(.+)$/m) || c.match(/([\s\S]*?)<\/title>/i); + if (h) title = h[1].replace(/<[^>]+>/g, "").trim(); + } catch { /* ignore */ } + out.push({ filename, title, date: reportDate(filename) }); + } + } + return out; +} + +// Existing destination paths that a migration would otherwise overwrite. +function destConflicts(src, dest, isDir) { + const out = []; + try { + if (isDir) { + const names = fs.existsSync(src) ? fs.readdirSync(src) : []; + for (const name of names) if (fs.existsSync(path.join(dest, name))) out.push(path.join(dest, name)); + } else if (fs.existsSync(dest)) { + out.push(dest); + } + } catch { /* ignore */ } + return out; +} + +export function summary() { + const config = loadConfig(); + const tasks = parseTasksTable(readFileOrCreate(resolveFilePath("tasks", config))); + const milestones = parseMilestones(readFileOrCreate(resolveFilePath("milestones", config))); + const journal = parseJournalEntries(readFileOrCreate(resolveFilePath("journal", config))); + const memos = listMemos(); + const learning = safeList("learning"); + const growth = safeList("growth"); + const projects = safeList("projects"); + + const today = todayISO(); + const open = tasks.filter((t) => t.status.toLowerCase() === "open"); + const done = tasks.filter((t) => t.status.toLowerCase() === "done"); + const overdue = open.filter((t) => t.dueDate && t.dueDate < today); + const dueToday = open.filter((t) => t.dueDate === today); + + // Priority distribution among open tasks + const priorityBuckets = { P0: 0, P1: 0, P2: 0, P3: 0, Other: 0 }; + for (const t of open) { + const key = (t.priority || "").toUpperCase().replace(/\s/g, ""); + if (key === "P0" || key === "HIGH") priorityBuckets.P0++; + else if (key === "P1") priorityBuckets.P1++; + else if (key === "P2" || key === "MED" || key === "MEDIUM") priorityBuckets.P2++; + else if (key === "P3" || key === "LOW") priorityBuckets.P3++; + else priorityBuckets.Other++; + } + + // Last-3-days activity spans partition boundaries, so aggregate across + // every partition rather than just the current one. + const journalAll = collectJournal(config); + const memosAll = collectMemos(config); + const learningAll = collectNotes(config, "learning"); + const growthAll = collectNotes(config, "growth"); + const projectsAll = collectNotes(config, "projects"); + + const since = daysAgoISO(2); // today, -1, -2 => 3 days inclusive + const last3 = []; + for (let i = 0; i < 3; i++) { + const day = daysAgoISO(i); + const j = journalAll.find((e) => e.date === day); + const memoCount = memosAll.filter((m) => m.date === day).length; + const noteCount = [...learningAll, ...growthAll, ...projectsAll].filter((n) => n.frontmatter?.date === day).length; + last3.push({ date: day, journal: j ? 1 : 0, memos: memoCount, notes: noteCount, doneTasks: 0 }); + } + + const recentActivity = buildTimeline({ journal: journalAll, memos: memosAll, learning: learningAll, growth: growthAll, projects: projectsAll, since }); + + const milestoneStatus = { Planned: 0, "In Progress": 0, Done: 0 }; + for (const m of milestones) { + const s = m.status || "Planned"; + if (milestoneStatus[s] === undefined) milestoneStatus[s] = 0; + milestoneStatus[s]++; + } + + return { + counts: { + tasksOpen: open.length, + tasksDone: done.length, + tasksOverdue: overdue.length, + tasksDueToday: dueToday.length, + milestones: milestones.length, + memos: memos.length, + journal: journal.length, + learning: learning.length, + growth: growth.length, + projects: projects.length, + }, + priorityBuckets, + milestoneStatus, + last3, + recentActivity, + overdue: overdue.slice(0, 6), + dueToday: dueToday.slice(0, 6), + upcomingMilestones: milestones + .filter((m) => (m.status || "").toLowerCase() !== "done") + .sort((a, b) => (a.targetDate || "9999").localeCompare(b.targetDate || "9999")) + .slice(0, 5), + storageRoot: path.resolve(config.__baseDir, config.storage.root), + partition: getPartitionFolder(config.storage.partitioning), + }; +} + +function safeList(domain) { + try { return listNotes(domain); } catch { return []; } +} + +function buildTimeline({ journal, memos, learning, growth, projects, since }) { + const events = []; + for (const e of journal) if (e.date >= since) events.push({ date: e.date, type: "journal", label: e.text.slice(0, 80) || "Journal entry" }); + for (const m of memos) if (m.date && m.date >= since) events.push({ date: m.date, type: "memo", label: m.title }); + for (const n of learning) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "learning", label: n.frontmatter.title || n.filename }); + for (const n of growth) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "growth", label: n.frontmatter.title || n.filename }); + for (const n of projects) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "project", label: n.frontmatter.title || n.filename }); + return events.sort((a, b) => b.date.localeCompare(a.date)).slice(0, 25); +} + +// --------------------------------------------------------------------------- +// Timeline / activity feed (parametrized, grouped by day) +// --------------------------------------------------------------------------- +function oneLine(s, n = 120) { + const t = String(s || "").replace(/\s+/g, " ").trim(); + return t.length > n ? t.slice(0, n - 1) + "…" : t; +} + +export function activity({ days = 14 } = {}) { + const config = loadConfig(); + const since = daysAgoISO(Math.max(0, days - 1)); + const journal = collectJournal(config); + const memos = collectMemos(config); + const learning = collectNotes(config, "learning"); + const growth = collectNotes(config, "growth"); + const projects = collectNotes(config, "projects"); + const milestones = parseMilestones(readFileOrCreate(resolveFilePath("milestones", config))); + let reports = []; + try { reports = collectReports(config); } catch { /* ignore */ } + + const events = []; + const today = todayISO(); + for (const e of journal) if (e.date >= since) events.push({ date: e.date, type: "journal", label: "Journal entry", detail: oneLine(e.text) }); + for (const m of memos) if (m.date && m.date >= since) events.push({ date: m.date, type: "memo", label: m.title, detail: m.filename }); + for (const n of learning) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "learning", label: n.frontmatter.title || n.filename, detail: n.frontmatter.status || "" }); + for (const n of growth) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "growth", label: n.frontmatter.title || n.filename, detail: n.frontmatter.impact || "" }); + for (const n of projects) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "project", label: n.frontmatter.title || n.filename, detail: n.frontmatter.status || "" }); + for (const m of milestones) if (m.targetDate && m.targetDate >= since && m.targetDate <= today) events.push({ date: m.targetDate, type: "milestone", label: m.name, detail: `Target · ${m.status || "Planned"}` }); + for (const r of reports) if (r.date && r.date >= since) events.push({ date: r.date, type: "report", label: r.title, detail: r.filename }); + + events.sort((a, b) => b.date.localeCompare(a.date)); + const groups = []; + const idx = {}; + for (const e of events) { + if (!idx[e.date]) { idx[e.date] = { date: e.date, items: [] }; groups.push(idx[e.date]); } + idx[e.date].items.push(e); + } + const byType = {}; + for (const e of events) byType[e.type] = (byType[e.type] || 0) + 1; + return { since, days, count: events.length, byType, groups, events }; +} + +// --------------------------------------------------------------------------- +// Reports (Copilot-generated executive reports; shares the partitioned +// `reports/` folder used by the CatPilot report-generator skill) +// --------------------------------------------------------------------------- +function nowStamp() { + const d = new Date(); + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; +} + +function reportDate(filename) { + const m = filename.match(/(\d{4})-?(\d{2})-?(\d{2})/); + return m ? `${m[1]}-${m[2]}-${m[3]}` : ""; +} + +export function listReports() { + const config = loadConfig(); + const dir = resolveFilePath("reports", config); + ensureDir(dir); + const files = fs.readdirSync(dir).filter((f) => /\.(md|html?)$/i.test(f)); + const rows = files.map((filename) => { + const full = path.join(dir, filename); + let mtime = 0, size = 0; + try { const st = fs.statSync(full); mtime = st.mtimeMs; size = st.size; } catch { /* ignore */ } + const ext = path.extname(filename).slice(1).toLowerCase(); + let title = filename; + try { + const c = fs.readFileSync(full, "utf8"); + if (ext === "md") { + const h = c.match(/^#\s+(.+)$/m); + if (h) title = h[1].trim(); + } else { + const h = c.match(/<title>([\s\S]*?)<\/title>/i) || c.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i); + if (h) title = h[1].replace(/<[^>]+>/g, "").trim(); + } + } catch { /* ignore */ } + return { filename, title, date: reportDate(filename), ext, format: ext === "md" ? "markdown" : "html", mtime, size }; + }); + rows.sort((a, b) => (b.mtime - a.mtime) || b.filename.localeCompare(a.filename)); + return rows; +} + +export function readReport(filename) { + const config = loadConfig(); + const dir = resolveFilePath("reports", config); + const safe = path.basename(String(filename || "")); + const full = path.join(dir, safe); + if (!path.resolve(full).startsWith(path.resolve(dir))) throw new Error("Invalid report path"); + if (!fs.existsSync(full)) { const e = new Error(`Report not found: ${safe}`); e.code = "NOT_FOUND"; throw e; } + const ext = path.extname(safe).slice(1).toLowerCase(); + const content = fs.readFileSync(full, "utf8"); + let title = safe; + const h = content.match(/^#\s+(.+)$/m); + if (h) title = h[1].trim(); + return { filename: safe, title, content, format: ext === "md" ? "markdown" : "html", date: reportDate(safe) }; +} + +export function saveReport({ title, body, format = "markdown" } = {}) { + const config = loadConfig(); + const dir = resolveFilePath("reports", config); + ensureDir(dir); + const ext = format === "html" ? "html" : "md"; + const slug = slugify(title || "report"); + const filename = `report-${nowStamp()}${slug ? "-" + slug : ""}.${ext}`; + let content = body || ""; + if (ext === "md" && title && !/^#\s/m.test(content)) content = `# ${title}\n\n${content}`; + const full = uniquePath(path.join(dir, filename)); + fs.writeFileSync(full, content, "utf8"); + const finalName = path.basename(full); + return { filename: finalName, title: title || finalName, content, format: ext === "md" ? "markdown" : "html", date: reportDate(finalName) }; +} + +export function deleteReport(filename) { + const config = loadConfig(); + const dir = resolveFilePath("reports", config); + const safe = path.basename(String(filename || "")); + const full = path.join(dir, safe); + if (!path.resolve(full).startsWith(path.resolve(dir))) throw new Error("Invalid report path"); + if (fs.existsSync(full)) fs.unlinkSync(full); + return { ok: true, filename: safe }; +} + +function periodRange(period = "this-week") { + const now = new Date(); + const iso = (d) => d.toISOString().split("T")[0]; + const startOfWeek = (d) => { const x = new Date(d); const day = (x.getDay() + 6) % 7; x.setDate(x.getDate() - day); return x; }; + let since, until, label; + if (period === "today") { since = iso(now); until = iso(now); label = "Today"; } + else if (period === "last-week") { const s = startOfWeek(now); s.setDate(s.getDate() - 7); const e = new Date(s); e.setDate(e.getDate() + 6); since = iso(s); until = iso(e); label = "Last week"; } + else if (period === "this-month") { since = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`; until = iso(now); label = "This month"; } + else if (period === "last-month") { const m = new Date(now.getFullYear(), now.getMonth() - 1, 1); const e = new Date(now.getFullYear(), now.getMonth(), 0); since = iso(m); until = iso(e); label = "Last month"; } + else if (period === "last-7") { const s = new Date(now); s.setDate(s.getDate() - 6); since = iso(s); until = iso(now); label = "Last 7 days"; } + else if (period === "last-30") { const s = new Date(now); s.setDate(s.getDate() - 29); since = iso(s); until = iso(now); label = "Last 30 days"; } + else if (period === "all") { since = "0000-01-01"; until = "9999-12-31"; label = "All time"; } + else { const s = startOfWeek(now); since = iso(s); until = iso(now); label = "This week"; } + return { since, until, label }; +} + +// Build a professional executive report (markdown) from tasks + milestones + +// journal, mirroring the CatPilot report-generator skill's section layout, then +// persist it to the reports folder. +export function generateReport({ period = "this-week", title } = {}) { + const config = loadConfig(); + const tasks = parseTasksTable(readFileOrCreate(resolveFilePath("tasks", config))); + const milestones = parseMilestones(readFileOrCreate(resolveFilePath("milestones", config))); + const journal = collectJournal(config); + const { since, until, label } = periodRange(period); + const today = todayISO(); + + const isDone = (t) => /^(done|completed)$/i.test(t.status || ""); + const done = tasks.filter(isDone); + const open = tasks.filter((t) => !isDone(t)); + const total = tasks.length; + const rate = total ? Math.round((done.length / total) * 100) : 0; + const overdue = open.filter((t) => t.dueDate && t.dueDate < today); + const dueToday = open.filter((t) => t.dueDate === today); + + const msStatus = milestones.reduce((a, m) => { const s = m.status || "Planned"; a[s] = (a[s] || 0) + 1; return a; }, {}); + const msDue = milestones.filter((m) => m.targetDate && m.targetDate >= since && m.targetDate <= until); + const jHits = journal.filter((j) => j.date >= since && j.date <= until); + + const bar = (v, max, w = 20) => { const n = max ? Math.round((v / max) * w) : 0; return "█".repeat(n) + "░".repeat(w - n); }; + const rptTitle = title || `CatPilot Report — ${label}`; + + const insights = []; + if (overdue.length) insights.push(`⚠️ **${overdue.length} overdue task${overdue.length > 1 ? "s" : ""}** need attention.`); + if (rate >= 70) insights.push(`✅ Strong completion rate at **${rate}%**.`); + else if (total) insights.push(`ℹ️ Completion rate is **${rate}%** — room to close out open work.`); + if ((msStatus["In Progress"] || 0) > 0) insights.push(`🎯 **${msStatus["In Progress"]} milestone${msStatus["In Progress"] > 1 ? "s" : ""}** in progress.`); + if (!jHits.length) insights.push(`ℹ️ No journal entries in ${label.toLowerCase()} — capture context as you go.`); + if (!insights.length) insights.push("ℹ️ Limited data for this period. Add tasks and milestones to enrich future reports."); + + const recs = []; + if (overdue.length) recs.push(`Reschedule or close the ${overdue.length} overdue item${overdue.length > 1 ? "s" : ""}.`); + if (dueToday.length) recs.push(`Prioritise ${dueToday.length} task${dueToday.length > 1 ? "s" : ""} due today.`); + if ((msStatus.Planned || 0) > 0) recs.push(`Kick off ${msStatus.Planned} planned milestone${msStatus.Planned > 1 ? "s" : ""}.`); + recs.push("Review this report with stakeholders and set next-period targets."); + + const md = [ + `# 📊 ${rptTitle}`, + "", + `## 🧭 Executive Summary`, + `Over **${label.toLowerCase()}** (${since} → ${until}), the workspace tracked **${total} task${total !== 1 ? "s" : ""}** ` + + `(${done.length} completed, ${open.length} open, ${rate}% completion) across **${milestones.length} milestone${milestones.length !== 1 ? "s" : ""}**. ` + + (overdue.length ? `There ${overdue.length === 1 ? "is" : "are"} **${overdue.length} overdue** item${overdue.length > 1 ? "s" : ""} to address.` : `Nothing is overdue.`), + "", + `## 🔢 KPI Snapshot`, + `| Metric | Value |`, + `| --- | --- |`, + `| Total tasks | ${total} |`, + `| Open tasks | ${open.length} |`, + `| Completed tasks | ${done.length} |`, + `| Completion rate | ${rate}% |`, + `| Overdue | ${overdue.length} |`, + `| Due today | ${dueToday.length} |`, + `| Milestones | ${milestones.length} |`, + `| Journal entries (period) | ${jHits.length} |`, + "", + `## ✅ Tasks Analysis`, + `\`\`\``, + `Completed ${bar(done.length, total)} ${done.length}/${total}`, + `Open ${bar(open.length, total)} ${open.length}/${total}`, + `\`\`\``, + overdue.length ? "**Overdue:**\n" + overdue.slice(0, 8).map((t) => `- ${t.title}${t.dueDate ? ` _(due ${t.dueDate})_` : ""}`).join("\n") : "_No overdue tasks._", + "", + `## 🎯 Milestones Analysis`, + Object.keys(msStatus).length + ? Object.entries(msStatus).map(([s, n]) => `- **${s}:** ${n}`).join("\n") + : "_No milestones tracked yet._", + msDue.length ? "\n**Targeting this period:**\n" + msDue.map((m) => `- ${m.name} — ${m.targetDate} _(${m.status})_`).join("\n") : "", + "", + `## 📈 Trends`, + `- ${jHits.length} journal entr${jHits.length === 1 ? "y" : "ies"} logged in ${label.toLowerCase()}.`, + `- ${done.length} task${done.length !== 1 ? "s" : ""} marked done overall.`, + "", + `## ⚠️ Risks & Insights`, + insights.map((i) => `- ${i}`).join("\n"), + "", + `## 🚀 Recommendations`, + recs.map((r, i) => `${i + 1}. ${r}`).join("\n"), + "", + `---`, + `_Generated by the CatPilot canvas on ${today}. Period: ${label}._`, + ].join("\n"); + + return saveReport({ title: rptTitle, body: md, format: "markdown" }); +} + +// --------------------------------------------------------------------------- +// Config editing + interactive data migration +// --------------------------------------------------------------------------- + +// Resolve every domain path for a given (root, partitioning) pair without +// mutating the active config. +function resolveDomainPaths(baseDir, root, partitioning, files = DEFAULT_FILES) { + const storageRoot = path.resolve(baseDir, root); + const partition = getPartitionFolder(partitioning); + const out = {}; + for (const [type, fileName] of Object.entries({ ...DEFAULT_FILES, ...(files || {}) })) { + out[type] = { path: path.join(storageRoot, partition, fileName), isDir: !/\.[a-z]+$/i.test(fileName) }; + } + return { storageRoot, partition, paths: out }; +} + +function countEntries(p, isDir) { + try { + if (isDir) return fs.existsSync(p) ? fs.readdirSync(p).length : 0; + return fs.existsSync(p) ? 1 : 0; + } catch { return 0; } +} + +// Return the current config plus a normalized view for the settings UI. +export function getConfig() { + const status = configStatus(); + if (!status.configured) return { configured: false }; + const config = loadConfig(); + const { storageRoot, partition } = resolveDomainPaths(config.__baseDir, config.storage.root, config.storage.partitioning, config.storage.files); + return { + configured: true, + configPath: config.__configPath, + scope: status.scope, + root: config.storage.root, + resolvedRoot: storageRoot, + partitioning: config.storage.partitioning, + partition, + migration: config.migration?.mode || "adopt", + allowExternalPaths: config.storage.allowExternalPaths !== false, + }; +} + +// Build a preview of what changing the config would do — no writes. +export function planConfigChange({ root, partitioning, migration = "move" } = {}) { + const current = getConfig(); + if (!current.configured) throw Object.assign(new Error("CatPilot is not set up yet."), { code: "NOT_CONFIGURED" }); + const config = loadConfig(); + const baseDir = config.__baseDir; + const nextRoot = (root && String(root).trim()) || current.root; + const nextPart = partitioning || current.partitioning; + + const from = resolveDomainPaths(baseDir, current.root, current.partitioning, config.storage.files); + const to = resolveDomainPaths(baseDir, nextRoot, nextPart, config.storage.files); + + const rootChanged = path.resolve(from.storageRoot) !== path.resolve(to.storageRoot); + const partChanged = current.partitioning !== nextPart; + + const mkItem = (type, isDir, srcPath, destPath) => { + const count = countEntries(srcPath, isDir); + const samePath = path.resolve(srcPath) === path.resolve(destPath); + const willMove = !samePath && count > 0 && migration !== "adopt"; + return { + type, + isDir, + from: srcPath, + to: destPath, + count, + willMove, + exists: count > 0, + conflicts: willMove ? destConflicts(srcPath, destPath, isDir) : [], + }; + }; + + const items = []; + if (rootChanged && !partChanged) { + // Root-only change: migrate EVERY partition, preserving the relative + // layout, so historical data is not left behind in the old root. + for (const [type, fileName] of Object.entries({ ...DEFAULT_FILES, ...(config.storage.files || {}) })) { + const isDir = !/\.[a-z]+$/i.test(fileName); + const srcs = allPartitionPaths(config, type); + if (!srcs.length) { + items.push(mkItem(type, from.paths[type].isDir, from.paths[type].path, to.paths[type].path)); + continue; + } + for (const src of srcs) { + const rel = path.relative(from.storageRoot, src); + items.push(mkItem(type, isDir, src, path.join(to.storageRoot, rel))); + } + } + } else { + // Partitioning change (re-bucketing history is ambiguous) or same root: + // operate on the current partition only. + for (const [type, meta] of Object.entries(from.paths)) { + items.push(mkItem(type, meta.isDir, meta.path, to.paths[type].path)); + } + } + + const moving = items.filter((i) => i.willMove); + const conflicts = items.flatMap((i) => i.conflicts || []); + return { + current: { root: current.root, partitioning: current.partitioning, resolvedRoot: from.storageRoot, migration: current.migration }, + next: { root: nextRoot, partitioning: nextPart, resolvedRoot: to.storageRoot, migration }, + rootChanged, + partitioningChanged: partChanged, + needsMigration: (rootChanged || partChanged) && migration !== "adopt", + totalItems: moving.reduce((a, i) => a + i.count, 0), + conflicts, + hasConflicts: conflicts.length > 0, + items, + }; +} + +// Move or copy without ever overwriting an existing destination. Returns the +// count of entries moved and the count skipped because the destination existed. +function moveOrCopyPath(src, dest, isDir, mode) { + if (!fs.existsSync(src)) return { moved: 0, skipped: 0 }; + if (isDir) { + ensureDir(dest); + let moved = 0, skipped = 0; + for (const name of fs.readdirSync(src)) { + const s = path.join(src, name); + const d = path.join(dest, name); + if (fs.existsSync(d)) { skipped++; continue; } + if (mode === "copy") fs.copyFileSync(s, d); + else fs.renameSync(s, d); + moved++; + } + return { moved, skipped }; + } + if (fs.existsSync(dest)) return { moved: 0, skipped: 1 }; + ensureDir(path.dirname(dest)); + if (mode === "copy") fs.copyFileSync(src, dest); + else fs.renameSync(src, dest); + return { moved: 1, skipped: 0 }; +} + +// Persist an in-memory config object back to its own config file, preserving +// any custom fields (storage.files, allowExternalPaths, active config path). +function persistConfig(config) { + const { __baseDir, __configPath, ...clean } = config; + const target = __configPath || GLOBAL_CONFIG_PATH; + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify(clean, null, 2), "utf8"); + return target; +} + +// Apply a config change, optionally migrating data across partitions. Requires +// an explicit confirm flag so the UI can gate it behind user approval. If any +// item fails to migrate, the whole operation aborts BEFORE the config is +// rewritten, so config and data never drift out of sync. +export function applyConfigChange({ root, partitioning, migration = "move", confirm = false } = {}) { + if (!confirm) throw new Error("applyConfigChange requires confirm=true"); + const config = loadConfig(); + const plan = planConfigChange({ root, partitioning, migration }); + + let moved = 0, skipped = 0; + const errors = []; + if (plan.needsMigration && (migration === "move" || migration === "copy")) { + for (const item of plan.items) { + if (!item.willMove) continue; + try { + const r = moveOrCopyPath(item.from, item.to, item.isDir, migration); + moved += r.moved; + skipped += r.skipped; + } catch (err) { + errors.push({ type: item.type, from: item.from, to: item.to, error: String(err && err.message || err) }); + } + } + } + + if (errors.length) { + // Config is left untouched; already-moved files stay where they landed + // but the source→config mapping is unchanged, so a retry is safe. + throw Object.assign(new Error(`Migration failed for ${errors.length} item(s); configuration was not changed.`), { + code: "MIGRATION_FAILED", + details: errors, + moved, + skipped, + }); + } + + config.storage.root = plan.next.root; + config.storage.partitioning = plan.next.partitioning; + config.migration = { ...(config.migration || {}), mode: migration }; + const configPath = persistConfig(config); + + return { ok: true, migrated: moved, moved, skipped, config: getConfig(), applied: plan.next, note: configPath }; +} diff --git a/extensions/catpilot-canvas/extension.mjs b/extensions/catpilot-canvas/extension.mjs new file mode 100644 index 000000000..1c10476b7 --- /dev/null +++ b/extensions/catpilot-canvas/extension.mjs @@ -0,0 +1,348 @@ +// Extension: catpilot-canvas +// A modern visual command center for CatPilot. Serves a single-page app over a +// loopback HTTP server and reads/writes the SAME storage the CatPilot agent, +// `cat-pilot` CLI and CatPilot MCP server use (via catpilot-store.mjs). + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import * as store from "./catpilot-store.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const UI_DIR = path.join(HERE, "ui"); + +let sessionRef = null; +function log(message, level = "info") { + try { sessionRef?.log?.(message, { level }); } catch { /* ignore */ } +} + +// --------------------------------------------------------------------------- +// Single shared loopback server (data is global; every instance is identical). +// --------------------------------------------------------------------------- +let sharedServer = null; +let serverInit = null; +const openInstances = new Set(); + +const STATIC = { + "/": { file: "index.html", type: "text/html; charset=utf-8" }, + "/index.html": { file: "index.html", type: "text/html; charset=utf-8" }, + "/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" }, + "/styles.css": { file: "styles.css", type: "text/css; charset=utf-8" }, + "/hero.png": { file: "hero.png", type: "image/png" }, + "/hero.svg": { file: "hero.svg", type: "image/svg+xml" }, +}; + +function sendJson(res, code, payload) { + const body = JSON.stringify(payload); + res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); + res.end(body); +} + +async function readBody(req) { + const chunks = []; + for await (const c of req) chunks.push(c); + if (!chunks.length) return {}; + try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return {}; } +} + +// REST API: (method, pathname) -> handler(req, params, body) returning data +async function handleApi(req, url) { + const p = url.pathname; + const m = req.method; + + // Status / onboarding + if (p === "/api/status" && m === "GET") { + return { ...store.configStatus() }; + } + if (p === "/api/setup" && m === "POST") { + store.writeConfig(await readBody(req)); + return { ok: true, ...store.configStatus() }; + } + if (p === "/api/summary" && m === "GET") return store.summary(); + + // Tasks + if (p === "/api/tasks" && m === "GET") return { tasks: store.listTasks(url.searchParams.get("status") || "all") }; + if (p === "/api/tasks" && m === "POST") return { task: store.addTask(await readBody(req)) }; + let mm = p.match(/^\/api\/tasks\/(\d+)$/); + if (mm && m === "PUT") return { task: store.updateTask(mm[1], await readBody(req)) }; + if (mm && m === "DELETE") return { removed: store.removeTask(mm[1]) }; + mm = p.match(/^\/api\/tasks\/(\d+)\/complete$/); + if (mm && m === "POST") return { task: store.completeTask(mm[1]) }; + + // Journal + if (p === "/api/journal" && m === "GET") return { entries: store.listJournal(Number(url.searchParams.get("days")) || 3650) }; + if (p === "/api/journal" && m === "POST") return { entry: store.addJournal((await readBody(req)).text) }; + + // Milestones + if (p === "/api/milestones" && m === "GET") return { milestones: store.listMilestones() }; + if (p === "/api/milestones" && m === "POST") return { milestone: store.addMilestone(await readBody(req)) }; + mm = p.match(/^\/api\/milestones\/(\d+)$/); + if (mm && m === "PUT") return { milestone: store.updateMilestone(mm[1], await readBody(req)) }; + + // Memos + if (p === "/api/memos" && m === "GET") return { memos: store.listMemos() }; + if (p === "/api/memos" && m === "POST") return { memo: store.createMemo(await readBody(req)) }; + mm = p.match(/^\/api\/memos\/(.+)$/); + if (mm && m === "GET") return { memo: store.readMemo(decodeURIComponent(mm[1])) }; + + // Domains: learning | growth | projects + mm = p.match(/^\/api\/(learning|growth|projects)$/); + if (mm && m === "GET") return { notes: store.listNotes(mm[1]) }; + if (mm && m === "POST") { + const body = await readBody(req); + const { title, body: noteBody, ...frontmatter } = body; + return { note: store.addNote(mm[1], { title, body: noteBody, frontmatter }) }; + } + mm = p.match(/^\/api\/(learning|growth|projects)\/(.+)$/); + if (mm && m === "GET") return { note: store.readNote(mm[1], decodeURIComponent(mm[2])) }; + + // Reports (Copilot-generated executive reports) + if (p === "/api/reports" && m === "GET") return { reports: store.listReports() }; + if (p === "/api/reports" && m === "POST") { + const body = await readBody(req); + if (body && typeof body.body === "string" && body.body.length) return { report: store.saveReport(body) }; + return { report: store.generateReport(body || {}) }; + } + mm = p.match(/^\/api\/reports\/(.+)$/); + if (mm && m === "GET") return { report: store.readReport(decodeURIComponent(mm[1])) }; + if (mm && m === "DELETE") return { removed: store.deleteReport(decodeURIComponent(mm[1])) }; + + // Timeline / activity feed + if (p === "/api/timeline" && m === "GET") return store.activity({ days: Number(url.searchParams.get("days")) || 14 }); + + // Config + interactive data migration + if (p === "/api/config" && m === "GET") return store.getConfig(); + if (p === "/api/config/plan" && m === "POST") return store.planConfigChange(await readBody(req)); + if (p === "/api/config/apply" && m === "POST") return store.applyConfigChange(await readBody(req)); + + // Agent bridge — drive the Copilot session from the canvas + if (p === "/api/agent" && m === "POST") { + const { prompt } = await readBody(req); + if (!prompt || !String(prompt).trim()) throw new Error("prompt is required"); + if (!sessionRef?.send) return { ok: false, error: "No active session." }; +await sessionRef.send({ prompt: String(prompt) }); + return { ok: true }; + } + if (p === "/api/agent/generate" && m === "POST") { + const { prompt, timeout } = await readBody(req); + if (!prompt || !String(prompt).trim()) throw new Error("prompt is required"); + if (!sessionRef?.sendAndWait) return { ok: false, error: "No active session." }; + const ev = await sessionRef.sendAndWait(String(prompt), Number(timeout) || 120000); + return { ok: true, content: ev?.data?.content || "" }; + } + + return null; // not an API route we know +} + +// Reject cross-origin / DNS-rebinding attempts against the loopback API. The +// server binds to 127.0.0.1, but a malicious web page could still POST to it +// via a spoofed Host header or a cross-site form/fetch, so we defend in depth: +// only loopback Host values are accepted, cross-site Sec-Fetch-Site is refused, +// and any Origin present must itself be loopback. +function isLoopbackHost(value) { + if (!value) return false; + let host = String(value).trim().toLowerCase(); + if (host.startsWith("[")) host = host.slice(1, host.indexOf("]") === -1 ? host.length : host.indexOf("]")); + else host = host.split(":")[0]; + return host === "127.0.0.1" || host === "localhost" || host === "::1"; +} + +function apiGuardReason(req) { + const host = req.headers["host"]; + if (host && !isLoopbackHost(host)) return "host"; + const site = req.headers["sec-fetch-site"]; + if (site && site !== "same-origin" && site !== "none") return "sec-fetch-site"; + const origin = req.headers["origin"]; + if (origin) { + try { if (!isLoopbackHost(new URL(origin).hostname)) return "origin"; } + catch { return "origin"; } + } + return null; +} + +async function requestListener(req, res) { + let url; + try { url = new URL(req.url, "http://127.0.0.1"); } catch { res.writeHead(400); return res.end(); } + const p = url.pathname; + + if (p.startsWith("/api/")) { + const denied = apiGuardReason(req); + if (denied) return sendJson(res, 403, { error: "Forbidden", code: "CROSS_ORIGIN_BLOCKED", reason: denied }); + try { + const data = await handleApi(req, url); + if (data === null) return sendJson(res, 404, { error: "Not found" }); + return sendJson(res, 200, data); + } catch (err) { + const notConfigured = err?.code === "NOT_CONFIGURED"; + return sendJson(res, notConfigured ? 409 : 400, { error: err.message, code: err.code || null }); + } + } + + const asset = STATIC[p]; + if (asset) { + try { + const buf = await readFile(path.join(UI_DIR, asset.file)); + res.writeHead(200, { "Content-Type": asset.type, "Cache-Control": "no-store" }); + return res.end(buf); + } catch { + res.writeHead(404); return res.end("Not found"); + } + } + res.writeHead(404); res.end("Not found"); +} + +async function ensureServer() { + if (sharedServer) return sharedServer; + // Cache the in-flight init so concurrent open() calls share one server + // instead of racing to bind two loopback listeners. + if (!serverInit) { + serverInit = (async () => { + const server = createServer((req, res) => { + requestListener(req, res).catch(() => { try { res.writeHead(500); res.end(); } catch { /* */ } }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = server.address().port; + sharedServer = { server, url: `http://127.0.0.1:${port}/` }; + log(`CatPilot canvas server listening on ${sharedServer.url}`); + return sharedServer; + })().catch((err) => { serverInit = null; throw err; }); + } + return serverInit; +} + +// --------------------------------------------------------------------------- +// Agent-facing actions (mirror the main mutations so the agent can drive it). +// --------------------------------------------------------------------------- +const actions = [ + { + name: "refresh", + description: "Return the latest CatPilot dashboard summary (counts, activity, charts data).", + handler: async () => { + try { return { ok: true, summary: store.summary() }; } + catch (err) { + if (err.code === "NOT_CONFIGURED") return { ok: false, configured: false }; + throw new CanvasError("summary_failed", err.message); + } + }, + }, + { + name: "add_task", + description: "Add a CatPilot task.", + inputSchema: { + type: "object", + required: ["title"], + properties: { + title: { type: "string" }, + due: { type: "string", description: "YYYY-MM-DD" }, + priority: { type: "string", description: "P0/P1/P2/P3 or High/Med/Low" }, + tags: { type: "string" }, + context: { type: "string" }, + }, + }, + handler: async (ctx) => { + try { return { ok: true, task: store.addTask(ctx.input || {}) }; } + catch (err) { throw new CanvasError("add_task_failed", err.message); } + }, + }, + { + name: "complete_task", + description: "Mark a CatPilot task as Done by numeric ID.", + inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" } } }, + handler: async (ctx) => { + try { return { ok: true, task: store.completeTask(ctx.input.id) }; } + catch (err) { throw new CanvasError("complete_task_failed", err.message); } + }, + }, + { + name: "add_journal", + description: "Append a CatPilot journal entry for today.", + inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" } } }, + handler: async (ctx) => { + try { return { ok: true, entry: store.addJournal(ctx.input.text) }; } + catch (err) { throw new CanvasError("add_journal_failed", err.message); } + }, + }, + { + name: "add_milestone", + description: "Add a CatPilot milestone.", + inputSchema: { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + targetDate: { type: "string", description: "YYYY-MM-DD" }, + status: { type: "string", enum: ["Planned", "In Progress", "Done"] }, + notes: { type: "string" }, + }, + }, + handler: async (ctx) => { + try { return { ok: true, milestone: store.addMilestone(ctx.input || {}) }; } + catch (err) { throw new CanvasError("add_milestone_failed", err.message); } + }, + }, + { + name: "generate_report", + description: "Generate and save a CatPilot executive report (markdown) for a period, built from tasks, milestones and journal.", + inputSchema: { + type: "object", + properties: { + period: { type: "string", enum: ["today", "this-week", "last-week", "last-7", "this-month", "last-month", "last-30", "all"], description: "Reporting period (default this-week)." }, + title: { type: "string", description: "Optional report title." }, + }, + }, + handler: async (ctx) => { + try { return { ok: true, report: store.generateReport(ctx.input || {}) }; } + catch (err) { throw new CanvasError("generate_report_failed", err.message); } + }, + }, + { + name: "save_report", + description: "Save a CatPilot report the agent has authored (e.g. via the report-generator skill) into the canvas reports folder.", + inputSchema: { + type: "object", + required: ["title", "body"], + properties: { + title: { type: "string" }, + body: { type: "string", description: "Report content (markdown or HTML)." }, + format: { type: "string", enum: ["markdown", "html"], description: "Defaults to markdown." }, + }, + }, + handler: async (ctx) => { + try { return { ok: true, report: store.saveReport(ctx.input || {}) }; } + catch (err) { throw new CanvasError("save_report_failed", err.message); } + }, + }, +]; + +const canvas = createCanvas({ + id: "catpilot-canvas", + displayName: "CatPilot", + description: "Visual command center for CatPilot: dashboard, tasks, journal, milestones, memos, learning, growth, projects, timeline, Copilot reports, settings/migration and a help guide — with charts and agent actions.", + inputSchema: { + type: "object", + properties: { view: { type: "string", description: "Optional initial view: dashboard|timeline|tasks|journal|milestones|memos|learning|growth|projects|reports|settings|help" } }, + }, + actions, + open: async (ctx) => { + const srv = await ensureServer(); + openInstances.add(ctx.instanceId); + const view = ctx.input?.view ? `#${encodeURIComponent(ctx.input.view)}` : ""; + return { title: "CatPilot", status: "Ready", url: `${srv.url}${view}` }; + }, + onClose: async (ctx) => { + openInstances.delete(ctx.instanceId); + if (openInstances.size === 0 && sharedServer) { + const { server } = sharedServer; + sharedServer = null; + serverInit = null; + await new Promise((resolve) => server.close(() => resolve())); + } + }, +}); + +sessionRef = await joinSession({ canvases: [canvas] }); diff --git a/extensions/catpilot-canvas/package-lock.json b/extensions/catpilot-canvas/package-lock.json new file mode 100644 index 000000000..3a2d1aaad --- /dev/null +++ b/extensions/catpilot-canvas/package-lock.json @@ -0,0 +1,218 @@ +{ + "name": "catpilot-canvas", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "catpilot-canvas", + "version": "1.0.0", + "dependencies": { + "@github/copilot-sdk": "1.0.1" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.70", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot/-/copilot-1.0.70.tgz", + "integrity": "sha1-ZXwZ/95WrolCJp3qxmhGeJm+i48=", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.70", + "@github/copilot-darwin-x64": "1.0.70", + "@github/copilot-linux-arm64": "1.0.70", + "@github/copilot-linux-x64": "1.0.70", + "@github/copilot-linuxmusl-arm64": "1.0.70", + "@github/copilot-linuxmusl-x64": "1.0.70", + "@github/copilot-win32-arm64": "1.0.70", + "@github/copilot-win32-x64": "1.0.70" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.70", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.70.tgz", + "integrity": "sha1-a++8R/8ywfJGyVmS3eSvyg5EFXY=", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "1.0.70", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.70.tgz", + "integrity": "sha1-cniNbh3UEEpdYuUcoWixBXVwIC0=", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "1.0.70", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.70.tgz", + "integrity": "sha1-CC0twbGPuSbuPQF5J8QMn0tUNF0=", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "1.0.70", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.70.tgz", + "integrity": "sha1-oqU2h9sYafFeM6hEA6+3rvB2wWo=", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.70", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.70.tgz", + "integrity": "sha1-GBDVmhv4wbopdNxFJHqaeNKV6fc=", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.70", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.70.tgz", + "integrity": "sha1-67W11PTO3uE3mEVWDSzLMTivBRw=", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz", + "integrity": "sha1-bmIiUzYx4TEzb9910i1Ksz2GJME=", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.61", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.70", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.70.tgz", + "integrity": "sha1-QMzrmlebPAOUNbCTMNk6TBQRmuU=", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "1.0.70", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.70.tgz", + "integrity": "sha1-GxOGU2eCSxWt2TUWxy8f+9/ODVU=", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha1-aJxdzcGQDvVYOky59te0c3QgdK0=", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha1-oyLMDx2X95T/2cTNKomKC94JfzQ=", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zod/-/zod-4.4.3.tgz", + "integrity": "sha1-toDxcohdGLvr8hqDTqJeVaG781Y=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/catpilot-canvas/package.json b/extensions/catpilot-canvas/package.json new file mode 100644 index 000000000..0dca5f766 --- /dev/null +++ b/extensions/catpilot-canvas/package.json @@ -0,0 +1,18 @@ +{ + "name": "catpilot-canvas", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "dependencies": { + "@github/copilot-sdk": "1.0.1" + }, + "description": "Visual command center canvas for CatPilot: tasks, journal, milestones, memos, learning, growth, projects, Copilot reports and an activity timeline with dashboards, charts, a markdown editor and interactive settings.", + "keywords": [ + "canvas", + "productivity", + "task-management", + "dashboard", + "personal-assistant", + "markdown-editor" + ] +} diff --git a/extensions/catpilot-canvas/ui/app.js b/extensions/catpilot-canvas/ui/app.js new file mode 100644 index 000000000..09604e327 --- /dev/null +++ b/extensions/catpilot-canvas/ui/app.js @@ -0,0 +1,1172 @@ +/* CatPilot canvas SPA. Vanilla JS, no build step, no external deps. */ +(() => { + "use strict"; + + // ---------------------------------------------------------------- helpers + const $ = (sel, root = document) => root.querySelector(sel); + const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + const todayISO = () => new Date().toISOString().split("T")[0]; + + function el(tag, attrs = {}, ...kids) { + const n = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v == null || v === false) continue; + if (k === "class") n.className = v; + else if (k === "html") n.innerHTML = v; + else if (k === "text") n.textContent = v; + else if (k.startsWith("on") && typeof v === "function") n.addEventListener(k.slice(2), v); + else if (k === "dataset") Object.assign(n.dataset, v); + else n.setAttribute(k, v); + } + for (const kid of kids.flat()) { + if (kid == null || kid === false) continue; + n.append(kid.nodeType ? kid : document.createTextNode(kid)); + } + return n; + } + + async function api(path, { method = "GET", body } = {}) { + const res = await fetch(path, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(data.error || `Request failed (${res.status})`); + err.code = data.code; err.status = res.status; + throw err; + } + return data; + } + + function toast(title, sub, kind = "") { + const icon = kind === "err" ? "!" : kind === "ok" ? "✓" : "😺"; + const t = el("div", { class: `toast ${kind}` }, + el("div", { class: "t-ic", text: icon }), + el("div", {}, el("strong", { text: title }), sub ? el("span", { text: sub }) : null)); + $("#toasts").append(t); + setTimeout(() => { t.style.opacity = "0"; t.style.transform = "translateX(20px)"; setTimeout(() => t.remove(), 250); }, 3200); + } + + // ---------------------------------------------------------------- modal + let modalReturnFocus = null; + function openModal({ title, body, foot, width }) { + closeModal(); + modalReturnFocus = document.activeElement; + const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) closeModal(); } }); + const titleId = `modal-title-${Date.now()}`; + const modal = el("div", { class: "modal", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, tabindex: "-1" }); + if (width) modal.style.width = `min(${width}px, 100%)`; + modal.append( + el("div", { class: "modal-head" }, + el("h3", { id: titleId, text: title }), + el("button", { class: "icon-btn", text: "✕", "aria-label": "Close dialog", onclick: closeModal })), + el("div", { class: "modal-body" }, body), + foot ? el("div", { class: "modal-foot" }, foot) : null, + ); + backdrop.append(modal); + $("#modal-root").append(backdrop); + document.addEventListener("keydown", escClose); + modal.addEventListener("keydown", trapFocus); + const focusables = modal.querySelectorAll("a[href], button, textarea, input, select, [tabindex]:not([tabindex='-1'])"); + (focusables[0] || modal).focus(); + return { close: closeModal, modal }; + } + function trapFocus(e) { + if (e.key !== "Tab") return; + const modal = e.currentTarget; + const f = [...modal.querySelectorAll("a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])")] + .filter((n) => n.offsetParent !== null || n === document.activeElement); + if (!f.length) return; + const first = f[0], last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + } + function escClose(e) { if (e.key === "Escape") closeModal(); } + function closeModal() { + $("#modal-root").innerHTML = ""; + document.removeEventListener("keydown", escClose); + if (modalReturnFocus && typeof modalReturnFocus.focus === "function") { + try { modalReturnFocus.focus(); } catch { /* */ } + } + modalReturnFocus = null; + } + + // ---------------------------------------------------------------- state + const state = { summary: null, theme: localStorage.getItem("cp-theme") || "dark", view: "dashboard" }; + + const NAV = [ + { id: "dashboard", icon: "◆", label: "Dashboard" }, + { id: "timeline", icon: "🕑", label: "Timeline" }, + { id: "tasks", icon: "✓", label: "Tasks", countKey: "tasksOpen" }, + { id: "journal", icon: "✎", label: "Journal", countKey: "journal" }, + { id: "milestones", icon: "⚑", label: "Milestones", countKey: "milestones" }, + { id: "memos", icon: "▤", label: "Memos", countKey: "memos" }, + { id: "learning", icon: "🎓", label: "Learning", countKey: "learning" }, + { id: "growth", icon: "↗", label: "Growth", countKey: "growth" }, + { id: "projects", icon: "❏", label: "Projects", countKey: "projects" }, + { id: "reports", icon: "📊", label: "Reports" }, + { id: "settings", icon: "⚙️", label: "Settings", footer: true }, + { id: "help", icon: "❔", label: "Help", footer: true }, + ]; + + const VIEW_SUB = { + dashboard: "Your CatPilot at a glance", + timeline: "A running story of what you've done", + tasks: "Capture, organize and complete work", + journal: "Daily notes and decisions", + milestones: "Track goals to completion", + memos: "Handoffs, summaries and notes", + learning: "Certifications and study topics", + growth: "Accomplishments and impact log", + projects: "Lightweight project status", + reports: "Executive reports from your data", + settings: "Storage, migration and preferences", + help: "Capabilities and how to use this canvas", + }; + + // ---------------------------------------------------------------- theme + function applyTheme() { + document.documentElement.setAttribute("data-theme", state.theme); + const btn = $("#theme-btn"); + if (btn) btn.textContent = state.theme === "dark" ? "☀️" : "🌙"; + } + function toggleTheme() { state.theme = state.theme === "dark" ? "light" : "dark"; localStorage.setItem("cp-theme", state.theme); applyTheme(); } + + // ---------------------------------------------------------------- badges + function priorityClass(p) { + const k = (p || "").toUpperCase().replace(/\s/g, ""); + if (k === "P0" || k === "HIGH") return "p0"; + if (k === "P1") return "p1"; + if (k === "P2" || k === "MED" || k === "MEDIUM") return "p2"; + if (k === "P3" || k === "LOW") return "p3"; + return ""; + } + function priorityBadge(p) { return p ? el("span", { class: `badge ${priorityClass(p)}`, text: p }) : el("span", { class: "muted small", text: "—" }); } + function statusBadge(s) { + const k = (s || "").toLowerCase().replace(/\s/g, ""); + const cls = k === "done" ? "st-done" : k === "inprogress" ? "st-inprogress" : k === "planned" ? "st-planned" : "st-open"; + return el("span", { class: `badge ${cls}`, text: s || "Open" }); + } + function tagChips(tags) { + const list = String(tags || "").split(",").map((t) => t.trim()).filter(Boolean); + if (!list.length) return el("span", { class: "muted small", text: "—" }); + return el("div", { class: "tags" }, list.map((t) => el("span", { class: "tag", text: t }))); + } + + // ---------------------------------------------------------------- charts + function donut(segments, size = 150) { + const total = segments.reduce((a, s) => a + s.value, 0); + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.setAttribute("viewBox", `0 0 ${size} ${size}`); + svg.setAttribute("width", size); svg.setAttribute("height", size); + const cx = size / 2, cy = size / 2, r = size / 2 - 14, C = 2 * Math.PI * r; + const track = document.createElementNS(NS, "circle"); + track.setAttribute("cx", cx); track.setAttribute("cy", cy); track.setAttribute("r", r); + track.setAttribute("fill", "none"); track.setAttribute("stroke", "var(--panel-2)"); track.setAttribute("stroke-width", 16); + svg.append(track); + let offset = 0; + if (total > 0) { + for (const s of segments) { + if (!s.value) continue; + const frac = s.value / total; + const c = document.createElementNS(NS, "circle"); + c.setAttribute("cx", cx); c.setAttribute("cy", cy); c.setAttribute("r", r); + c.setAttribute("fill", "none"); c.setAttribute("stroke", s.color); c.setAttribute("stroke-width", 16); + c.setAttribute("stroke-dasharray", `${C * frac} ${C}`); + c.setAttribute("stroke-dashoffset", -C * offset); + c.setAttribute("transform", `rotate(-90 ${cx} ${cy})`); + c.setAttribute("stroke-linecap", "round"); + svg.append(c); + offset += frac; + } + } + const t1 = document.createElementNS(NS, "text"); + t1.setAttribute("x", cx); t1.setAttribute("y", cy - 2); t1.setAttribute("text-anchor", "middle"); + t1.setAttribute("font-size", "26"); t1.setAttribute("font-weight", "800"); t1.setAttribute("fill", "var(--text)"); + t1.textContent = total; + const t2 = document.createElementNS(NS, "text"); + t2.setAttribute("x", cx); t2.setAttribute("y", cy + 16); t2.setAttribute("text-anchor", "middle"); + t2.setAttribute("font-size", "11"); t2.setAttribute("fill", "var(--text-faint)"); + t2.textContent = "total"; + svg.append(t1, t2); + return svg; + } + + function stackedBars(days) { + const series = [ + { key: "journal", color: "#7c5cff", label: "Journal" }, + { key: "memos", color: "#ff7ac2", label: "Memos" }, + { key: "notes", color: "#5aa9ff", label: "Notes" }, + ]; + const max = Math.max(1, ...days.map((d) => series.reduce((a, s) => a + (d[s.key] || 0), 0))); + const bars = el("div", { class: "bars" }); + for (const d of days) { + const stack = el("div", { class: "bar-stack" }); + for (const s of series) { + const v = d[s.key] || 0; + if (!v) continue; + const seg = el("div", { class: "bar-seg" }); + seg.style.height = `${(v / max) * 130}px`; + seg.style.background = s.color; + seg.title = `${s.label}: ${v}`; + stack.append(seg); + } + const label = new Date(d.date + "T00:00:00").toLocaleDateString(undefined, { weekday: "short" }); + bars.append(el("div", { class: "bar-col" }, stack, el("div", { class: "bar-label", text: label }))); + } + const legend = el("div", { class: "legend" }, series.map((s) => + el("span", {}, el("i", { style: `background:${s.color}` }), s.label))); + return el("div", {}, bars, legend); + } + + // ---------------------------------------------------------------- nav + function renderNav() { + const nav = $("#nav"); + nav.innerHTML = ""; + const counts = state.summary?.counts || {}; + let footerStarted = false; + for (const item of NAV) { + if (item.footer && !footerStarted) { footerStarted = true; nav.append(el("div", { class: "nav-spacer" })); } + const count = item.countKey ? counts[item.countKey] : null; + const node = el("button", { + type: "button", + class: `nav-item ${state.view === item.id ? "active" : ""}`, + "aria-current": state.view === item.id ? "page" : null, + onclick: () => go(item.id), + }, + el("span", { class: "nav-ic", text: item.icon, "aria-hidden": "true" }), + el("span", { text: item.label }), + count ? el("span", { class: "nav-badge", text: String(count) }) : null); + nav.append(node); + } + const chip = $("#storage-chip"); + if (state.summary?.storageRoot) { + chip.textContent = `📁 ${state.summary.storageRoot.split(/[\\/]/).slice(-2).join("/")}`; + chip.title = `${state.summary.storageRoot} · ${state.summary.partition}`; + } + } + + // ---------------------------------------------------------------- router + // Views append asynchronously; a fast A→B navigation must not let A's late + // resolution paint into B's screen. Each navigation gets a token and builds + // into detached staging nodes, committing to the DOM only if it is still the + // current navigation. + let navToken = 0; + let actionsHost = $("#view-actions"); + async function go(view) { + const token = ++navToken; + state.view = view; + location.hash = view; + $("#view-title").textContent = NAV.find((n) => n.id === view)?.label || "CatPilot"; + $("#view-sub").textContent = VIEW_SUB[view] || ""; + renderNav(); + const stage = el("div"); + const actionStage = el("div"); + actionsHost = actionStage; + stage.append(el("div", { class: "spinner" })); + $("#content").innerHTML = ""; + $("#content").append(el("div", { class: "spinner" })); + try { + await VIEWS[view](stage); + } catch (err) { + stage.innerHTML = ""; + stage.append(errorBox(err)); + } + if (token !== navToken) return; // superseded by a newer navigation + const content = $("#content"); + content.innerHTML = ""; + while (stage.firstChild) content.append(stage.firstChild); + const actions = $("#view-actions"); + actions.innerHTML = ""; + while (actionStage.firstChild) actions.append(actionStage.firstChild); + } + + function errorBox(err) { + return el("div", { class: "empty" }, + el("div", { class: "big", text: "😿" }), + el("h3", { text: "Something went wrong" }), + el("p", { class: "muted", text: err.message })); + } + + function emptyState(icon, title, sub, action) { + return el("div", { class: "empty" }, + el("div", { class: "big", text: icon }), + el("h3", { text: title }), + el("p", { class: "muted", text: sub }), + action ? el("div", { style: "margin-top:16px" }, action) : null); + } + + // ---------------------------------------------------------------- refresh + async function refreshSummary() { + try { state.summary = await api("/api/summary"); } + catch { state.summary = null; } + renderNav(); + } + + // ================================================================= + // VIEWS + // ================================================================= + const VIEWS = {}; + + // ---------- Dashboard ---------- + VIEWS.dashboard = async (root) => { + const s = await api("/api/summary"); + state.summary = s; + renderNav(); + root.innerHTML = ""; + + // Hero + root.append(el("div", { class: "hero" }, + el("img", { src: "hero.png", alt: "CatPilot" }), + el("div", { class: "hero-txt" }, + el("h1", { text: greeting() }), + el("p", { text: heroLine(s) })), + el("div", { class: "hero-actions" }, + el("button", { class: "btn btn-primary", onclick: () => taskModal(), html: "+ Task" }), + el("button", { class: "btn", onclick: () => journalModal(), html: "✎ Journal" }), + el("button", { class: "btn", onclick: () => milestoneModal(), html: "⚑ Milestone" })))); + + // Stat cards + const c = s.counts; + const cards = [ + { n: c.tasksOpen, label: "Open tasks", ic: "✓", tone: "tone-accent", sub: `${c.tasksDone} completed` }, + { n: c.tasksOverdue, label: "Overdue", ic: "⏰", tone: c.tasksOverdue ? "tone-danger" : "tone-ok", sub: `${c.tasksDueToday} due today` }, + { n: c.milestones, label: "Milestones", ic: "⚑", tone: "tone-accent", sub: `${s.milestoneStatus.Done || 0} done` }, + { n: c.memos, label: "Memos", ic: "▤", tone: "", sub: `${c.journal} journal entries` }, + { n: c.learning, label: "Learning", ic: "🎓", tone: "", sub: "topics tracked" }, + { n: c.growth, label: "Growth", ic: "↗", tone: "tone-ok", sub: "impact entries" }, + ]; + root.append(el("div", { class: "grid cards", style: "margin-top:16px" }, + cards.map((cd) => el("div", { class: `card stat hoverable ${cd.tone}` }, + el("span", { class: "stat-ic", text: cd.ic }), + el("div", { class: "stat-num", text: String(cd.n) }), + el("div", { class: "stat-label", text: cd.label }), + el("div", { class: "stat-sub muted", text: cd.sub }))))); + + // Charts row + const chartRow = el("div", { class: "chart-row", style: "margin-top:22px" }); + // Priority donut + const pb = s.priorityBuckets; + const pSegs = [ + { label: "P0 / High", value: pb.P0, color: "#ff6b7d" }, + { label: "P1", value: pb.P1, color: "#ffb020" }, + { label: "P2 / Med", value: pb.P2, color: "#7c5cff" }, + { label: "P3 / Low", value: pb.P3, color: "#35c88f" }, + { label: "Unset", value: pb.Other, color: "#5c5c74" }, + ]; + chartRow.append(el("div", { class: "card chart-card" }, + el("h4", { text: "Open tasks by priority" }), + el("div", { style: "display:flex;gap:20px;align-items:center;flex-wrap:wrap" }, + donut(pSegs), + el("div", { class: "legend", style: "flex-direction:column;gap:8px" }, + pSegs.map((seg) => el("span", {}, el("i", { style: `background:${seg.color}` }), `${seg.label} · ${seg.value}`)))))); + // Activity bars + chartRow.append(el("div", { class: "card chart-card" }, + el("h4", { text: "Activity · last 3 days" }), + stackedBars(s.last3))); + root.append(chartRow); + + // Two-column: focus + timeline + const twoCol = el("div", { class: "grid", style: "grid-template-columns:1fr 1fr;margin-top:22px;align-items:start" }); + + // Focus (overdue + due today + upcoming milestones) + const focus = el("div", { class: "card" }); + focus.append(el("h4", { text: "🎯 Focus", style: "margin:0 0 12px" })); + const focusItems = [ + ...s.overdue.map((t) => ({ t, tag: "Overdue", cls: "p0" })), + ...s.dueToday.map((t) => ({ t, tag: "Today", cls: "p1" })), + ]; + if (!focusItems.length && !s.upcomingMilestones.length) { + focus.append(el("p", { class: "muted", text: "You're all caught up. No overdue or due-today items. 🎉" })); + } else { + focusItems.forEach(({ t, tag, cls }) => { + focus.append(el("div", { class: "tl-item", onclick: () => taskDetail(t) }, + el("div", { class: "tl-dot", text: "✓" }), + el("div", { class: "tl-body" }, + el("div", { class: "tl-label", text: t.title }), + el("div", { class: "tl-meta", text: `${tag}${t.dueDate ? " · " + t.dueDate : ""}` })), + el("span", { class: `badge ${cls}`, text: tag }))); + }); + s.upcomingMilestones.forEach((m) => { + focus.append(el("div", { class: "tl-item" }, + el("div", { class: "tl-dot", text: "⚑" }), + el("div", { class: "tl-body" }, + el("div", { class: "tl-label", text: m.name }), + el("div", { class: "tl-meta", text: `Milestone${m.targetDate ? " · " + m.targetDate : ""}` })), + statusBadge(m.status))); + }); + } + twoCol.append(focus); + + // Timeline + const tl = el("div", { class: "card" }); + tl.append(el("h4", { text: "🕑 Recent activity", style: "margin:0 0 12px" })); + if (!s.recentActivity.length) { + tl.append(el("p", { class: "muted", text: "No activity in the last 3 days. Add a journal entry or memo to get started." })); + } else { + const tlIcons = { journal: "✎", memo: "▤", learning: "🎓", growth: "↗", project: "❏" }; + const wrap = el("div", { class: "timeline" }); + s.recentActivity.forEach((a) => { + wrap.append(el("div", { class: "tl-item" }, + el("div", { class: "tl-dot", text: tlIcons[a.type] || "•" }), + el("div", { class: "tl-body" }, + el("div", { class: "tl-label", text: a.label || "(untitled)" }), + el("div", { class: "tl-meta", text: `${a.type} · ${a.date}` })))); + }); + tl.append(wrap); + } + twoCol.append(tl); + root.append(twoCol); + }; + + function greeting() { + const h = new Date().getHours(); + const g = h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening"; + return `${g}! 😺`; + } + function heroLine(s) { + const c = s.counts; + if (c.tasksOverdue) return `You have ${c.tasksOverdue} overdue task${c.tasksOverdue > 1 ? "s" : ""} and ${c.tasksOpen} open. Let's clear the deck.`; + if (c.tasksDueToday) return `${c.tasksDueToday} task${c.tasksDueToday > 1 ? "s" : ""} due today, ${c.tasksOpen} open in total. You've got this.`; + if (c.tasksOpen) return `${c.tasksOpen} open task${c.tasksOpen > 1 ? "s" : ""} and nothing overdue. Nice and steady.`; + return "Everything's clear. Capture something new to get rolling."; + } + + // ---------- Tasks ---------- + let taskViewMode = localStorage.getItem("cp-task-view") || "list"; + VIEWS.tasks = async (root) => { + const { tasks } = await api("/api/tasks?status=all"); + root.innerHTML = ""; + + // view switcher in topbar + const seg = el("div", { class: "segmented" }, + el("button", { class: taskViewMode === "list" ? "active" : "", text: "☰ List", onclick: () => { taskViewMode = "list"; localStorage.setItem("cp-task-view", "list"); go("tasks"); } }), + el("button", { class: taskViewMode === "board" ? "active" : "", text: "▦ Board", onclick: () => { taskViewMode = "board"; localStorage.setItem("cp-task-view", "board"); go("tasks"); } })); + actionsHost.append(seg); + + const toolbar = el("div", { class: "toolbar" }, + el("button", { class: "btn btn-primary", html: "+ Add task", onclick: () => taskModal() }), + el("input", { class: "search", "aria-label": "Search tasks", placeholder: "Search tasks…", oninput: (e) => filterTasks(e.target.value) }), + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${tasks.filter((t) => t.status.toLowerCase() === "open").length} open · ${tasks.filter((t) => t.status.toLowerCase() === "done").length} done` })); + root.append(toolbar); + + const holder = el("div", { id: "task-holder" }); + root.append(holder); + if (!tasks.length) { holder.append(emptyState("🗒️", "No tasks yet", "Capture your first task and it will sync straight to CatPilot.", el("button", { class: "btn btn-primary", html: "+ Add task", onclick: () => taskModal() }))); return; } + if (taskViewMode === "list") renderTaskList(holder, tasks); + else renderTaskBoard(holder, tasks); + root._tasks = tasks; + }; + + function filterTasks(q) { + q = q.toLowerCase().trim(); + document.querySelectorAll("[data-task-title]").forEach((row) => { + const hit = !q || row.dataset.taskTitle.toLowerCase().includes(q); + row.style.display = hit ? "" : "none"; + }); + } + + function renderTaskList(holder, tasks) { + const open = tasks.filter((t) => t.status.toLowerCase() === "open"); + const done = tasks.filter((t) => t.status.toLowerCase() === "done"); + const ordered = [...open, ...done]; + const tbody = el("tbody"); + ordered.forEach((t) => tbody.append(taskRow(t))); + holder.append(el("div", { class: "table-wrap" }, + el("table", { class: "tbl" }, + el("thead", {}, el("tr", {}, + el("th", { text: "" }), + el("th", { text: "Task" }), + el("th", { text: "Due" }), + el("th", { text: "Priority" }), + el("th", { text: "Tags" }), + el("th", { text: "" }))), + tbody))); + } + + function taskRow(t) { + const done = t.status.toLowerCase() === "done"; + const tr = el("tr", { class: done ? "done-row" : "", dataset: { taskTitle: t.title } }); + const check = el("input", { type: "checkbox", style: "width:auto", "aria-label": done ? `Reopen ${t.title}` : `Complete ${t.title}`, ...(done ? { checked: "checked" } : {}) }); + check.addEventListener("change", async () => { + try { + if (check.checked) { await api(`/api/tasks/${t.id}/complete`, { method: "POST" }); toast("Task completed", t.title, "ok"); } + else { await api(`/api/tasks/${t.id}`, { method: "PUT", body: { status: "Open" } }); toast("Reopened", t.title); } + await refreshSummary(); go("tasks"); + } catch (e) { toast("Error", e.message, "err"); } + }); + tr.append( + el("td", { style: "width:34px" }, check), + el("td", {}, el("div", { class: "cell-title", text: t.title, onclick: () => taskDetail(t) }), + t.context ? el("div", { class: "muted small", text: t.context }) : null), + el("td", {}, t.dueDate ? el("span", { class: dueClass(t.dueDate, done), text: t.dueDate }) : el("span", { class: "muted small", text: "—" })), + el("td", {}, priorityBadge(t.priority)), + el("td", {}, tagChips(t.tags)), + el("td", {}, el("div", { class: "row-actions" }, + el("button", { class: "btn btn-sm btn-ghost", text: "Edit", onclick: () => taskModal(t) }), + el("button", { class: "btn btn-sm btn-ghost btn-danger", text: "Delete", onclick: () => removeTask(t) })))); + return tr; + } + + function dueClass(due, done) { + if (done) return "muted small"; + if (due < todayISO()) return "badge p0"; + if (due === todayISO()) return "badge p1"; + return "small"; + } + + function renderTaskBoard(holder, tasks) { + const cols = [ + { key: "overdue", title: "⏰ Overdue", filter: (t) => t.status.toLowerCase() === "open" && t.dueDate && t.dueDate < todayISO() }, + { key: "open", title: "◻ To do", filter: (t) => t.status.toLowerCase() === "open" && !(t.dueDate && t.dueDate < todayISO()) }, + { key: "done", title: "✓ Done", filter: (t) => t.status.toLowerCase() === "done" }, + ]; + const board = el("div", { class: "board" }); + for (const col of cols) { + const items = tasks.filter(col.filter); + const colEl = el("div", { class: "board-col", dataset: { col: col.key } }); + colEl.append(el("h4", {}, col.title, el("span", { class: "count", text: ` ${items.length}` }))); + items.forEach((t) => colEl.append(boardCard(t))); + // drag & drop -> change status + colEl.addEventListener("dragover", (e) => { e.preventDefault(); colEl.classList.add("drop"); }); + colEl.addEventListener("dragleave", () => colEl.classList.remove("drop")); + colEl.addEventListener("drop", async (e) => { + e.preventDefault(); colEl.classList.remove("drop"); + const id = e.dataTransfer.getData("text/plain"); + try { + if (col.key === "done") await api(`/api/tasks/${id}/complete`, { method: "POST" }); + else await api(`/api/tasks/${id}`, { method: "PUT", body: { status: "Open" } }); + await refreshSummary(); go("tasks"); + } catch (err) { toast("Error", err.message, "err"); } + }); + board.append(colEl); + } + holder.append(board); + } + + function boardCard(t) { + const done = t.status.toLowerCase() === "done"; + const card = el("div", { class: "board-card", draggable: "true", dataset: { taskTitle: t.title } }); + card.addEventListener("dragstart", (e) => { e.dataTransfer.setData("text/plain", String(t.id)); card.classList.add("dragging"); }); + card.addEventListener("dragend", () => card.classList.remove("dragging")); + card.append( + el("div", { class: "bc-title", text: t.title, style: done ? "text-decoration:line-through;color:var(--text-faint)" : "", onclick: () => taskDetail(t) }), + el("div", { class: "bc-meta" }, + priorityBadge(t.priority), + t.dueDate ? el("span", { class: dueClass(t.dueDate, done), text: t.dueDate }) : null)); + return card; + } + + async function removeTask(t) { + if (!confirm(`Delete task "${t.title}"?`)) return; + try { await api(`/api/tasks/${t.id}`, { method: "DELETE" }); toast("Deleted", t.title, "ok"); await refreshSummary(); go("tasks"); } + catch (e) { toast("Error", e.message, "err"); } + } + + function taskModal(t) { + const editing = !!t; + const f = {}; + const body = el("div", { class: "form" }, + field("Title", f, "title", { value: t?.title, placeholder: "What needs doing?", required: true }), + el("div", { class: "form-row" }, + field("Due date", f, "due", { value: t?.dueDate, type: "date" }), + selectField("Priority", f, "priority", ["", "P0", "P1", "P2", "P3", "High", "Med", "Low"], t?.priority)), + field("Tags", f, "tags", { value: t?.tags, placeholder: "comma,separated" }), + mdField("Context", f, "context", { value: t?.context, placeholder: "One-line context (markdown supported)" })); + const save = el("button", { class: "btn btn-primary", text: editing ? "Save changes" : "Add task", onclick: async () => { + const payload = { title: f.title.value.trim(), due: f.due.value, priority: f.priority.value, tags: f.tags.value.trim(), context: f.context.value.trim() }; + if (!payload.title) { toast("Title required", "", "err"); return; } + try { + if (editing) { await api(`/api/tasks/${t.id}`, { method: "PUT", body: { title: payload.title, dueDate: payload.due, priority: payload.priority, tags: payload.tags, context: payload.context } }); toast("Task updated", payload.title, "ok"); } + else { await api("/api/tasks", { method: "POST", body: payload }); toast("Task added", payload.title, "ok"); } + closeModal(); await refreshSummary(); go("tasks"); + } catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: editing ? "Edit task" : "New task", body, foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), save] }); + setTimeout(() => f.title.focus(), 50); + } + + function taskDetail(t) { + const done = t.status.toLowerCase() === "done"; + const body = el("div", {}, + detailRow("Status", statusBadge(t.status)), + detailRow("Title", el("span", { text: t.title })), + detailRow("Due date", el("span", { text: t.dueDate || "—" })), + detailRow("Priority", priorityBadge(t.priority)), + detailRow("Tags", tagChips(t.tags)), + detailRow("Context", t.context ? mdRender(t.context) : el("span", { class: "muted small", text: "—" }))); + const foot = [ + el("button", { class: "btn btn-danger", text: "Delete", onclick: () => { closeModal(); removeTask(t); } }), + el("span", { class: "spacer", style: "flex:1" }), + el("button", { class: "btn", text: "Edit", onclick: () => taskModal(t) }), + !done ? el("button", { class: "btn btn-primary", text: "Complete", onclick: async () => { try { await api(`/api/tasks/${t.id}/complete`, { method: "POST" }); toast("Completed", t.title, "ok"); closeModal(); await refreshSummary(); go("tasks"); } catch (e) { toast("Error", e.message, "err"); } } }) : null, + ]; + openModal({ title: `Task #${t.id}`, body, foot }); + } + + // ---------- Journal ---------- + VIEWS.journal = async (root) => { + const { entries } = await api("/api/journal"); + root.innerHTML = ""; + root.append(el("div", { class: "toolbar" }, + el("button", { class: "btn btn-primary", html: "+ Add entry", onclick: () => journalModal() }), + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${entries.length} entr${entries.length === 1 ? "y" : "ies"}` }))); + if (!entries.length) { root.append(emptyState("✎", "No journal entries", "Capture decisions and notes as they happen.", el("button", { class: "btn btn-primary", html: "+ Add entry", onclick: () => journalModal() }))); return; } + const list = el("div", { class: "journal-list" }); + entries.forEach((e) => list.append(el("div", { class: "journal-entry" }, + el("div", { class: "date" }, "📅 ", e.date), + el("div", { class: "body", text: e.text })))); + root.append(list); + }; + + function journalModal() { + const f = {}; + const body = el("div", { class: "form" }, mdField("Entry", f, "text", { placeholder: "What happened today?", required: true })); + const save = el("button", { class: "btn btn-primary", text: "Add entry", onclick: async () => { + const text = f.text.value.trim(); + if (!text) { toast("Entry required", "", "err"); return; } + try { await api("/api/journal", { method: "POST", body: { text } }); toast("Journal saved", todayISO(), "ok"); closeModal(); await refreshSummary(); if (state.view === "journal") go("journal"); } + catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: "New journal entry", body, foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), save] }); + setTimeout(() => f.text.focus(), 50); + } + + // ---------- Milestones ---------- + VIEWS.milestones = async (root) => { + const { milestones } = await api("/api/milestones"); + root.innerHTML = ""; + root.append(el("div", { class: "toolbar" }, + el("button", { class: "btn btn-primary", html: "+ Add milestone", onclick: () => milestoneModal() }), + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${milestones.length} milestone${milestones.length === 1 ? "" : "s"}` }))); + if (!milestones.length) { root.append(emptyState("⚑", "No milestones", "Set goals with target dates and track them to done.", el("button", { class: "btn btn-primary", html: "+ Add milestone", onclick: () => milestoneModal() }))); return; } + const tbody = el("tbody"); + milestones.forEach((m) => { + const sel = el("select", { class: "inline-edit", style: "width:auto", onchange: async (e) => { try { await api(`/api/milestones/${m.id}`, { method: "PUT", body: { status: e.target.value } }); toast("Status updated", m.name, "ok"); await refreshSummary(); } catch (err) { toast("Error", err.message, "err"); } } }); + ["Planned", "In Progress", "Done"].forEach((o) => { const opt = el("option", { value: o, text: o }); if ((m.status || "Planned") === o) opt.selected = true; sel.append(opt); }); + tbody.append(el("tr", { dataset: { taskTitle: m.name } }, + el("td", {}, el("span", { class: "cell-title", text: m.name, onclick: () => noteLikeDetail(`Milestone #${m.id}`, [["Name", m.name], ["Target date", m.targetDate || "—"], ["Status", m.status || "Planned"], ["Notes", m.notes || "—"]]) })), + el("td", {}, m.targetDate ? el("span", { class: dueClass(m.targetDate, (m.status || "").toLowerCase() === "done"), text: m.targetDate }) : el("span", { class: "muted small", text: "—" })), + el("td", {}, sel), + el("td", {}, el("span", { class: "muted small", text: m.notes || "—" })))); + }); + root.append(el("div", { class: "table-wrap" }, el("table", { class: "tbl" }, + el("thead", {}, el("tr", {}, el("th", { text: "Milestone" }), el("th", { text: "Target" }), el("th", { text: "Status" }), el("th", { text: "Notes" }))), + tbody))); + }; + + function milestoneModal() { + const f = {}; + const body = el("div", { class: "form" }, + field("Name", f, "name", { placeholder: "Milestone name", required: true }), + el("div", { class: "form-row" }, + field("Target date", f, "targetDate", { type: "date" }), + selectField("Status", f, "status", ["Planned", "In Progress", "Done"], "Planned")), + mdField("Notes", f, "notes", { placeholder: "Optional notes (markdown supported)" })); + const save = el("button", { class: "btn btn-primary", text: "Add milestone", onclick: async () => { + const name = f.name.value.trim(); + if (!name) { toast("Name required", "", "err"); return; } + try { await api("/api/milestones", { method: "POST", body: { name, targetDate: f.targetDate.value, status: f.status.value, notes: f.notes.value.trim() } }); toast("Milestone added", name, "ok"); closeModal(); await refreshSummary(); if (state.view === "milestones") go("milestones"); } + catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: "New milestone", body, foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), save] }); + setTimeout(() => f.name.focus(), 50); + } + + // ---------- Memos ---------- + VIEWS.memos = async (root) => { + const { memos } = await api("/api/memos"); + root.innerHTML = ""; + root.append(el("div", { class: "toolbar" }, + el("button", { class: "btn btn-primary", html: "+ New memo", onclick: () => memoModal() }), + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${memos.length} memo${memos.length === 1 ? "" : "s"}` }))); + if (!memos.length) { root.append(emptyState("▤", "No memos", "Create handoff notes, retros and summaries as markdown.", el("button", { class: "btn btn-primary", html: "+ New memo", onclick: () => memoModal() }))); return; } + const grid = el("div", { class: "grid note-grid" }); + memos.forEach((m) => grid.append(el("div", { class: "card note-card hoverable", onclick: () => memoDetail(m.filename) }, + el("h4", { text: m.title }), + el("div", { class: "note-foot" }, el("span", { class: "tag", text: m.date || "" }), el("span", { class: "muted small", text: "📄 memo" }))))); + root.append(grid); + }; + + async function memoDetail(filename) { + try { + const { memo } = await api(`/api/memos/${encodeURIComponent(filename)}`); + openModal({ title: memo.title, width: 680, body: mdRender(memo.content || "(empty)"), + foot: [el("button", { class: "btn", text: "Close", onclick: closeModal })] }); + } catch (e) { toast("Error", e.message, "err"); } + } + + function memoModal() { + const f = {}; + const body = el("div", { class: "form" }, + field("Title", f, "title", { placeholder: "Memo title", required: true }), + mdField("Content", f, "content", { placeholder: "Markdown content…", minRows: 8 })); + const save = el("button", { class: "btn btn-primary", text: "Create memo", onclick: async () => { + const title = f.title.value.trim(); + if (!title) { toast("Title required", "", "err"); return; } + try { await api("/api/memos", { method: "POST", body: { title, content: f.content.value } }); toast("Memo created", title, "ok"); closeModal(); await refreshSummary(); if (state.view === "memos") go("memos"); } + catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: "New memo", body, foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), save] }); + setTimeout(() => f.title.focus(), 50); + } + + // ---------- Domain notes: learning / growth / projects ---------- + const DOMAIN_META = { + learning: { icon: "🎓", fields: [["goal", "Goal"], ["target_date", "Target date", "date"], ["next_review", "Next review", "date"], ["status", "Status"]] }, + growth: { icon: "↗", fields: [["area", "Area"], ["impact", "Impact"]] }, + projects: { icon: "❏", fields: [["status", "Status"], ["owner", "Owner"], ["due", "Due", "date"]] }, + }; + for (const domain of ["learning", "growth", "projects"]) { + VIEWS[domain] = async (root) => { + const { notes } = await api(`/api/${domain}`); + root.innerHTML = ""; + root.append(el("div", { class: "toolbar" }, + el("button", { class: "btn btn-primary", html: `+ New ${domain === "projects" ? "project" : domain === "growth" ? "entry" : "topic"}`, onclick: () => noteModal(domain) }), + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${notes.length} item${notes.length === 1 ? "" : "s"}` }))); + if (!notes.length) { root.append(emptyState(DOMAIN_META[domain].icon, `No ${domain} notes`, "Add your first note — it's saved with frontmatter for Obsidian Dataview.", el("button", { class: "btn btn-primary", html: "+ Add", onclick: () => noteModal(domain) }))); return; } + const grid = el("div", { class: "grid note-grid" }); + notes.forEach((n) => { + const fm = n.frontmatter || {}; + grid.append(el("div", { class: "card note-card hoverable", onclick: () => domainNoteDetail(domain, n.filename) }, + el("h4", { text: fm.title || n.filename }), + domainChips(domain, fm), + el("div", { class: "note-foot" }, el("span", { class: "tag", text: fm.date || "" })))); + }); + root.append(grid); + }; + } + + function domainChips(domain, fm) { + const chips = el("div", { class: "tags" }); + if (fm.status) chips.append(statusBadge(fm.status)); + if (fm.area) chips.append(el("span", { class: "tag", text: fm.area })); + if (fm.owner) chips.append(el("span", { class: "tag", text: "👤 " + fm.owner })); + if (fm.goal) chips.append(el("span", { class: "muted small", text: fm.goal })); + if (fm.impact) chips.append(el("span", { class: "muted small", text: fm.impact })); + return chips; + } + + async function domainNoteDetail(domain, filename) { + try { + const { note } = await api(`/api/${domain}/${encodeURIComponent(filename)}`); + const fm = note.frontmatter || {}; + const rows = Object.entries(fm).filter(([k]) => k !== "catpilot").map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v]); + const body = el("div", {}, + ...rows.map(([k, v]) => detailRow(k, el("span", { text: String(v) }))), + note.body ? el("div", { style: "margin-top:14px" }, el("div", { class: "detail-row" }, el("span", { class: "k", text: "Notes" })), mdRender(note.body)) : null); + openModal({ title: fm.title || filename, width: 640, body, foot: [el("button", { class: "btn", text: "Close", onclick: closeModal })] }); + } catch (e) { toast("Error", e.message, "err"); } + } + + function noteModal(domain) { + const meta = DOMAIN_META[domain]; + const f = {}; + const rows = meta.fields.map(([key, label, type]) => field(label, f, key, { type: type || "text" })); + const body = el("div", { class: "form" }, + field("Title", f, "title", { required: true, placeholder: "Title" }), + el("div", { class: "form-row" }, rows), + mdField("Body", f, "body", { placeholder: "Markdown notes…" })); + const save = el("button", { class: "btn btn-primary", text: "Save", onclick: async () => { + const title = f.title.value.trim(); + if (!title) { toast("Title required", "", "err"); return; } + const payload = { title, body: f.body.value }; + meta.fields.forEach(([key]) => { if (f[key].value.trim()) payload[key] = f[key].value.trim(); }); + try { await api(`/api/${domain}`, { method: "POST", body: payload }); toast("Saved", title, "ok"); closeModal(); await refreshSummary(); if (state.view === domain) go(domain); } + catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: `New ${domain} note`, body, foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), save] }); + setTimeout(() => f.title.focus(), 50); + } + + // ---------------------------------------------------------------- fields + function field(label, store, key, opts = {}) { + const input = opts.area + ? el("textarea", { placeholder: opts.placeholder || "" }) + : el("input", { type: opts.type || "text", placeholder: opts.placeholder || "" }); + if (opts.value) input.value = opts.value; + store[key] = input; + return el("label", {}, el("span", {}, label, opts.required ? el("em", { text: " *" }) : null), input); + } + function selectField(label, store, key, options, value) { + const sel = el("select", {}); + options.forEach((o) => { const opt = el("option", { value: o, text: o || "—" }); if (o === value) opt.selected = true; sel.append(opt); }); + store[key] = sel; + return el("label", {}, el("span", { text: label }), sel); + } + function detailRow(k, valueNode) { + return el("div", { class: "detail-row" }, el("span", { class: "k", text: k }), el("div", {}, valueNode)); + } + function noteLikeDetail(title, rows) { + openModal({ title, body: el("div", {}, rows.map(([k, v]) => detailRow(k, el("span", { text: String(v) })))), foot: [el("button", { class: "btn", text: "Close", onclick: closeModal })] }); + } + + // ---------------------------------------------------------------- markdown + function mdInline(s) { + return s + .replace(/`([^`]+)`/g, "<code>$1</code>") + .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") + .replace(/(^|[^*])\*([^*\s][^*]*?)\*/g, "$1<em>$2</em>") + .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>'); + } + function mdTable(rows) { + const cells = (r) => r.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map((c) => c.trim()); + let body = rows.slice(1); + if (body[0] && /^[\s:|-]+$/.test(body[0])) body = body.slice(1); + let h = '<table class="md-table"><thead><tr>' + cells(rows[0]).map((c) => `<th>${mdInline(esc(c))}</th>`).join("") + "</tr></thead><tbody>"; + for (const r of body) h += "<tr>" + cells(r).map((c) => `<td>${mdInline(esc(c))}</td>`).join("") + "</tr>"; + return h + "</tbody></table>"; + } + function mdToHtml(md) { + const lines = String(md || "").replace(/\r\n/g, "\n").split("\n"); + let html = "", inCode = false, codeBuf = [], listType = null, listBuf = [], tableBuf = []; + const flushList = () => { if (listType) { html += `<${listType}>` + listBuf.map((li) => `<li>${mdInline(esc(li))}</li>`).join("") + `</${listType}>`; listType = null; listBuf = []; } }; + const flushTable = () => { if (tableBuf.length) { html += mdTable(tableBuf); tableBuf = []; } }; + const flush = () => { flushList(); flushTable(); }; + for (const raw of lines) { + if (/^```/.test(raw)) { if (inCode) { html += `<pre><code>${esc(codeBuf.join("\n"))}</code></pre>`; codeBuf = []; inCode = false; } else { flush(); inCode = true; } continue; } + if (inCode) { codeBuf.push(raw); continue; } + const line = raw.replace(/\s+$/, ""); + if (!line.trim()) { flush(); continue; } + if (/^\s*\|.*\|\s*$/.test(line)) { flushList(); tableBuf.push(line); continue; } + flushTable(); + let m; + if ((m = line.match(/^(#{1,6})\s+(.*)$/))) { flushList(); const lv = m[1].length; html += `<h${lv}>${mdInline(esc(m[2]))}</h${lv}>`; continue; } + if (/^\s*[-*]\s+\[[ xX]\]\s+/.test(line)) { if (listType !== "ul") { flushList(); listType = "ul"; } const done = /\[[xX]\]/.test(line); listBuf.push((done ? "☑ " : "☐ ") + line.replace(/^\s*[-*]\s+\[[ xX]\]\s+/, "")); continue; } + if (/^\s*[-*]\s+/.test(line)) { if (listType !== "ul") { flushList(); listType = "ul"; } listBuf.push(line.replace(/^\s*[-*]\s+/, "")); continue; } + if (/^\s*\d+\.\s+/.test(line)) { if (listType !== "ol") { flushList(); listType = "ol"; } listBuf.push(line.replace(/^\s*\d+\.\s+/, "")); continue; } + if (/^>\s?/.test(line)) { flushList(); html += `<blockquote>${mdInline(esc(line.replace(/^>\s?/, "")))}</blockquote>`; continue; } + if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { flushList(); html += "<hr/>"; continue; } + flushList(); html += `<p>${mdInline(esc(line))}</p>`; + } + flush(); if (inCode && codeBuf.length) html += `<pre><code>${esc(codeBuf.join("\n"))}</code></pre>`; + return html; + } + function mdRender(md) { const d = el("div", { class: "md" }); d.innerHTML = mdToHtml(md); return d; } + + // ---------------------------------------------------------------- markdown editor + function mdField(label, store, key, opts = {}) { + const ed = mdEditor({ value: opts.value || "", placeholder: opts.placeholder || "", minRows: opts.minRows || 6 }); + store[key] = ed.textarea; + return el("label", { class: "md-label" }, el("span", {}, label, opts.required ? el("em", { text: " *" }) : null), ed.node); + } + function mdEditor({ value = "", placeholder = "", minRows = 6 } = {}) { + const ta = el("textarea", { class: "md-input", placeholder, rows: minRows }); + ta.value = value; + const preview = el("div", { class: "md md-preview hidden" }); + let previewing = false; + const pvBtn = el("button", { type: "button", class: "md-tool md-toggle", text: "👁 Preview" }); + const renderPreview = () => { preview.innerHTML = ta.value.trim() ? mdToHtml(ta.value) : '<p class="muted">Nothing to preview yet.</p>'; }; + const setPreview = (on) => { previewing = on; if (on) renderPreview(); preview.classList.toggle("hidden", !on); ta.classList.toggle("hidden", on); pvBtn.textContent = on ? "✎ Write" : "👁 Preview"; }; + pvBtn.addEventListener("click", () => setPreview(!previewing)); + function surround(before, after = before, ph = "text") { const s = ta.selectionStart, e = ta.selectionEnd, v = ta.value, sel = v.slice(s, e) || ph; ta.value = v.slice(0, s) + before + sel + after + v.slice(e); ta.focus(); ta.selectionStart = s + before.length; ta.selectionEnd = s + before.length + sel.length; } + function linePrefix(pfx) { const s = ta.selectionStart, v = ta.value, ls = v.lastIndexOf("\n", s - 1) + 1; ta.value = v.slice(0, ls) + pfx + v.slice(ls); ta.focus(); ta.selectionStart = ta.selectionEnd = s + pfx.length; } + const tools = [ + { ic: "B", cls: "b", t: "Bold", fn: () => surround("**") }, + { ic: "I", cls: "i", t: "Italic", fn: () => surround("*") }, + { ic: "H", t: "Heading", fn: () => linePrefix("## ") }, + { ic: "”", t: "Quote", fn: () => linePrefix("> ") }, + { ic: "•", t: "Bullet list", fn: () => linePrefix("- ") }, + { ic: "☑", t: "Checkbox", fn: () => linePrefix("- [ ] ") }, + { ic: "</>", t: "Code", fn: () => surround("`", "`", "code") }, + { ic: "🔗", t: "Link", fn: () => surround("[", "](https://)", "label") }, + ]; + const bar = el("div", { class: "md-toolbar" }); + tools.forEach((t) => bar.append(el("button", { type: "button", class: "md-tool" + (t.cls ? " tt-" + t.cls : ""), title: t.t, text: t.ic, onclick: () => { if (previewing) setPreview(false); t.fn(); } }))); + bar.append(el("span", { class: "spacer", style: "flex:1" })); + const genBtn = el("button", { type: "button", class: "md-tool md-gen-btn", title: "Draft with Copilot", html: "✨ Copilot" }); + bar.append(genBtn, pvBtn); + const genInput = el("input", { class: "md-gen-input", placeholder: "Describe what to write — Copilot drafts it…" }); + const genRow = el("div", { class: "md-gen hidden" }); + async function doGen() { + const instr = genInput.value.trim(); + if (!instr) { toast("Describe what to generate", "", "err"); return; } + const existing = ta.value.trim(); + genBtn.disabled = true; genBtn.innerHTML = "⏳ Drafting…"; + const prompt = `You are drafting content inside the CatPilot canvas markdown editor. Write: ${instr}. Respond with ONLY the markdown to insert — no preamble, no surrounding code fences, no explanation.` + (existing ? `\n\nBuild on this existing draft:\n"""\n${existing}\n"""` : ""); + try { + const r = await api("/api/agent/generate", { method: "POST", body: { prompt, timeout: 120000 } }); + if (r && r.content) { ta.value = existing ? existing + "\n\n" + r.content.trim() : r.content.trim(); toast("Copilot drafted content", "", "ok"); if (previewing) renderPreview(); genRow.classList.add("hidden"); genInput.value = ""; } + else toast("No content returned", (r && r.error) || "The agent may be busy", "err"); + } catch (e) { toast("Generate failed", e.message, "err"); } + finally { genBtn.disabled = false; genBtn.innerHTML = "✨ Copilot"; } + } + genRow.append(genInput, + el("button", { type: "button", class: "btn btn-sm btn-primary", text: "Generate", onclick: doGen }), + el("button", { type: "button", class: "btn btn-sm", text: "Cancel", onclick: () => genRow.classList.add("hidden") })); + genBtn.addEventListener("click", () => { genRow.classList.toggle("hidden"); if (!genRow.classList.contains("hidden")) genInput.focus(); }); + genInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); doGen(); } }); + const node = el("div", { class: "md-editor" }, bar, genRow, el("div", { class: "md-surface" }, ta, preview)); + return { node, textarea: ta, getValue: () => ta.value, setValue: (v) => { ta.value = v; } }; + } + + // ---------------------------------------------------------------- agent + function askAgent(prompt, okMsg) { + return api("/api/agent", { method: "POST", body: { prompt } }) + .then((r) => { if (r && r.ok) toast(okMsg || "Sent to Copilot", "Check the chat panel", "ok"); else toast("No active session", (r && r.error) || "", "err"); }) + .catch((e) => toast("Error", e.message, "err")); + } + function agentBar(items) { + const bar = el("div", { class: "agent-bar" }); + bar.append(el("span", { class: "agent-bar-label", html: "✨ Copilot actions" })); + items.forEach((it) => bar.append(el("button", { class: "chip agent-chip", title: it.prompt, onclick: () => askAgent(it.prompt, it.ok) }, (it.icon ? it.icon + " " : "") + it.label))); + return bar; + } + function agentModal() { + const chips = [ + { label: "Summarize my week", prompt: "Summarize what I've worked on in CatPilot over the past week using my tasks, journal and milestones." }, + { label: "Plan my day", prompt: "Look at my CatPilot open and overdue tasks and propose a prioritized plan for today." }, + { label: "Generate weekly report", prompt: "Generate a CatPilot executive report for this week using the report-generator skill and save it with the save_report canvas action." }, + { label: "Triage overdue tasks", prompt: "Review my overdue CatPilot tasks and suggest reschedules or which to drop." }, + { label: "Draft a standup", prompt: "Write a concise standup update from my recent CatPilot activity." }, + ]; + const ta = el("textarea", { class: "md-input", rows: 4, placeholder: "Ask Copilot to do something with your CatPilot data…" }); + const chipRow = el("div", { class: "chip-row" }, chips.map((c) => el("button", { class: "chip", onclick: () => { ta.value = c.prompt; ta.focus(); } }, c.label))); + const send = el("button", { class: "btn btn-primary", text: "Send to Copilot", onclick: async () => { const p = ta.value.trim(); if (!p) { toast("Type a request", "", "err"); return; } await askAgent(p); closeModal(); } }); + openModal({ title: "✨ Ask Copilot", width: 560, body: el("div", { class: "form" }, el("p", { class: "muted small", text: "Sends a message to the Copilot agent in the chat. It reads and updates the same CatPilot data shown here." }), chipRow, ta), foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), send] }); + setTimeout(() => ta.focus(), 50); + } + + // ---------------------------------------------------------------- timeline + const TL_ICON = { task: "✓", "task-done": "✅", journal: "✎", memo: "▤", milestone: "⚑", learning: "🎓", growth: "↗", project: "❏", report: "📊" }; + let tlDays = Number(localStorage.getItem("cp-tl-days")) || 14; + function prettyDate(iso) { + const d = new Date(iso + "T00:00:00"); + if (isNaN(d)) return iso; + const today = new Date(); today.setHours(0, 0, 0, 0); + const diff = Math.round((today - d) / 86400000); + const label = diff === 0 ? "Today" : diff === 1 ? "Yesterday" : d.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" }); + return label; + } + VIEWS.timeline = async (root) => { + const data = await api(`/api/timeline?days=${tlDays}`); + root.innerHTML = ""; + const seg = el("div", { class: "segmented" }, [7, 14, 30].map((d) => el("button", { class: tlDays === d ? "active" : "", text: `${d}d`, onclick: () => { tlDays = d; localStorage.setItem("cp-tl-days", d); go("timeline"); } }))); + actionsHost.append(seg); + root.append(agentBar([ + { icon: "🧭", label: "Summarize this period", prompt: `Summarize what I've worked on in CatPilot over the last ${tlDays} days using my journal, memos, tasks and milestones.` }, + { icon: "🎯", label: "What should I do next?", prompt: "Based on my CatPilot tasks and milestones, what should I focus on next?" }, + { icon: "🗣️", label: "Draft a standup update", prompt: `Write a concise standup update from my CatPilot activity in the last ${tlDays} days.` }, + { icon: "📊", label: "Generate a report", prompt: "Generate a CatPilot executive report for this week using the report-generator skill and save it via the save_report canvas action." }, + ])); + const groups = data.groups || []; + const total = groups.reduce((n, g) => n + g.items.length, 0); + if (data.byType && Object.keys(data.byType).length) { + root.append(el("div", { class: "tl-summary" }, Object.entries(data.byType).map(([k, v]) => el("span", { class: "chip" }, `${TL_ICON[k] || "•"} ${v} ${k}`)))); + } + if (!total) { root.append(emptyState("🕑", "Nothing in this window yet", "Add tasks, journal entries, memos or milestones and they'll show up here as a story.")); return; } + const wrap = el("div", { class: "tl-wrap" }); + groups.forEach((g) => { + const day = el("div", { class: "tl-day" }, + el("div", { class: "tl-day-head" }, el("span", { class: "tl-day-date", text: prettyDate(g.date) }), el("span", { class: "muted small", text: `${g.items.length} item${g.items.length > 1 ? "s" : ""}` }))); + const items = el("div", { class: "timeline" }); + g.items.forEach((a) => items.append(el("div", { class: "tl-item" }, + el("div", { class: `tl-dot tl-${a.type}` }, TL_ICON[a.type] || "•"), + el("div", { class: "tl-body" }, el("div", { class: "tl-label", text: a.label || "(untitled)" }), el("div", { class: "tl-meta", text: `${a.type}${a.detail ? " · " + a.detail : ""}` }))))); + day.append(items); wrap.append(day); + }); + root.append(wrap); + }; + + // ---------------------------------------------------------------- reports + const PERIODS = [["this-week", "This week"], ["last-week", "Last week"], ["this-month", "This month"], ["last-month", "Last month"], ["last-7", "Last 7 days"], ["last-30", "Last 30 days"], ["all", "All time"]]; + let reportPeriod = "this-week"; + VIEWS.reports = async (root) => { + const { reports } = await api("/api/reports"); + root.innerHTML = ""; + const sel = el("select", { class: "inline-edit", style: "width:auto" }, PERIODS.map(([v, l]) => { const o = el("option", { value: v, text: l }); if (v === reportPeriod) o.selected = true; return o; })); + sel.addEventListener("change", (e) => { reportPeriod = e.target.value; }); + const genBtn = el("button", { class: "btn btn-primary", html: "📊 Generate report" }); + genBtn.addEventListener("click", async () => { + genBtn.disabled = true; genBtn.innerHTML = "⏳ Generating…"; + try { const { report } = await api("/api/reports", { method: "POST", body: { period: reportPeriod } }); toast("Report generated", report.title, "ok"); go("reports"); reportDetail(report.filename); } + catch (e) { toast("Error", e.message, "err"); } + finally { genBtn.disabled = false; genBtn.innerHTML = "📊 Generate report"; } + }); + root.append(el("div", { class: "toolbar" }, sel, genBtn, + el("button", { class: "btn", html: "✨ Ask Copilot", onclick: () => askAgent(`Generate a detailed CatPilot executive report for "${reportPeriod}" using the report-generator skill, then save it with the save_report canvas action.`, "Report requested") }), + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${reports.length} report${reports.length === 1 ? "" : "s"}` }))); + if (!reports.length) { root.append(emptyState("📊", "No reports yet", "Generate an executive report from your tasks and milestones, or ask Copilot to write one.", genBtn)); return; } + const grid = el("div", { class: "grid note-grid" }); + reports.forEach((r) => grid.append(el("div", { class: "card report-card hoverable", onclick: () => reportDetail(r.filename) }, + el("div", { class: "report-ic", text: r.format === "html" ? "🌐" : "📄" }), + el("h4", { text: r.title }), + el("div", { class: "note-foot" }, el("span", { class: "tag", text: r.date || "" }), el("span", { class: "tag", text: r.format || "md" }))))); + root.append(grid); + }; + async function reportDetail(filename) { + try { + const { report } = await api(`/api/reports/${encodeURIComponent(filename)}`); + let body; + if (report.format === "html") { body = el("iframe", { class: "report-iframe", sandbox: "", title: report.title || "Report preview" }); body.srcdoc = report.content; } + else body = mdRender(report.content); + const del = el("button", { class: "btn btn-danger", text: "Delete", onclick: async () => { + try { await api(`/api/reports/${encodeURIComponent(filename)}`, { method: "DELETE" }); toast("Report deleted", "", "ok"); closeModal(); if (state.view === "reports") go("reports"); } + catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: report.title, width: 860, body, foot: [del, el("span", { style: "flex:1" }), el("button", { class: "btn", text: "Close", onclick: closeModal })] }); + } catch (e) { toast("Error", e.message, "err"); } + } + + // ---------------------------------------------------------------- settings + function shortPath(p) { const parts = String(p || "").split(/[\\/]/).filter(Boolean); return parts.length > 3 ? "…/" + parts.slice(-3).join("/") : p; } + VIEWS.settings = async (root) => { + const cfg = await api("/api/config"); + root.innerHTML = ""; + if (!cfg.configured) { root.append(emptyState("⚙️", "Not configured", "Complete onboarding first to set your storage root.")); return; } + root.append(el("div", { class: "card settings-card" }, + el("h4", { text: "Storage configuration" }), + detailRow("Storage root", el("code", { text: cfg.root })), + detailRow("Resolved path", el("code", { text: cfg.resolvedRoot || cfg.root })), + detailRow("Partitioning", el("span", { text: cfg.partitioning })), + detailRow("Active partition", el("code", { text: cfg.partition || "—" })), + detailRow("Migration mode", el("span", { text: cfg.migration || "move" })), + detailRow("Config file", el("code", { text: cfg.configPath || "~/.catpilot/config.json" })))); + root.append(el("div", { class: "toolbar", style: "margin-top:16px" }, + el("button", { class: "btn btn-primary", html: "⚙️ Change configuration…", onclick: () => configModal(cfg) }), + el("button", { class: "btn", html: "✨ Ask Copilot about my setup", onclick: () => askAgent("Explain my current CatPilot storage configuration and suggest improvements.") }))); + root.append(el("div", { class: "card settings-card", style: "margin-top:16px" }, + el("h4", { text: "Appearance" }), + el("p", { class: "muted small", text: "Toggle light/dark from the top bar. Your preference is remembered on this machine." }), + el("button", { class: "btn", html: state.theme === "dark" ? "☀️ Switch to light" : "🌙 Switch to dark", onclick: () => { toggleTheme(); go("settings"); } }))); + }; + function configModal(cfg) { + const f = {}; + const form = el("div", { class: "form" }, + field("Storage root", f, "root", { value: cfg.root, placeholder: "Folder path (e.g. C:\\Users\\you\\Vault)" }), + el("div", { class: "form-row" }, + selectField("Partitioning", f, "partitioning", ["month", "week", "day"], cfg.partitioning), + selectField("If data exists", f, "migration", ["move", "copy", "adopt"], "move"))); + const planBox = el("div", { class: "plan-box hidden" }); + const approve = el("label", { class: "approve hidden" }, el("input", { type: "checkbox" }), el("span", { text: "I understand and want to apply these changes" })); + const approveCb = approve.querySelector("input"); + const applyBtn = el("button", { class: "btn btn-primary", text: "Confirm & apply", disabled: true }); + approveCb.addEventListener("change", () => { applyBtn.disabled = !approveCb.checked; }); + function bodyFromForm(withConfirm) { const b = { root: f.root.value.trim(), partitioning: f.partitioning.value, migration: f.migration.value }; if (withConfirm) b.confirm = true; return b; } + // A preview must precede every apply. Editing any field invalidates the + // previewed plan so the user can never apply a stale/mismatched plan. + let previewedBody = null; + function invalidate() { previewedBody = null; planBox.classList.add("hidden"); planBox.innerHTML = ""; approve.classList.add("hidden"); approveCb.checked = false; applyBtn.disabled = true; } + [f.root, f.partitioning, f.migration].forEach((inp) => { if (inp) { inp.addEventListener("input", invalidate); inp.addEventListener("change", invalidate); } }); + function renderPlan(plan) { + planBox.innerHTML = ""; planBox.classList.remove("hidden"); + const changed = plan.rootChanged || plan.partitioningChanged; + planBox.append(el("div", { class: "plan-head" }, el("strong", { text: changed ? "Proposed changes" : "No path change" }))); + planBox.append(el("div", { class: "plan-diff" }, + el("div", {}, el("span", { class: "muted small", text: "From " }), el("code", { text: `${shortPath(plan.current.resolvedRoot)} · ${plan.current.partitioning}` })), + el("div", {}, el("span", { class: "muted small", text: "To " }), el("code", { text: `${shortPath(plan.next.resolvedRoot)} · ${plan.next.partitioning}` })))); + if (plan.needsMigration && plan.totalItems) { + const moving = (plan.items || []).filter((i) => i.willMove); + planBox.append(el("p", { class: "muted small", text: `${plan.next.migration === "copy" ? "Copy" : "Move"} ${plan.totalItems} item(s) across ${moving.length} location(s):` })); + const list = el("div", { class: "plan-list" }); + moving.forEach((i) => list.append(el("div", { class: "plan-item" }, el("span", { class: "tag", text: i.type }), el("span", { class: "muted small", text: `${i.count} → ${shortPath(i.to)}` })))); + planBox.append(list); + approve.classList.remove("hidden"); applyBtn.disabled = !approveCb.checked; + } else { + planBox.append(el("p", { class: "muted small", text: plan.next.migration === "adopt" ? "Adopt mode: config points at the new location; no files are moved." : "Nothing to migrate — paths are unchanged." })); + approve.classList.add("hidden"); applyBtn.disabled = false; + } + if (plan.hasConflicts) { + planBox.append(el("div", { class: "plan-warn small" }, + el("strong", { text: `⚠ ${plan.conflicts.length} destination file(s) already exist` }), + el("div", { class: "muted small", text: "Existing files are kept and those sources are skipped — nothing is overwritten." }))); + } + } + const previewBtn = el("button", { class: "btn", text: "Preview changes", onclick: async () => { + const b = bodyFromForm(false); + if (!b.root) { toast("Root required", "", "err"); return; } + try { const plan = await api("/api/config/plan", { method: "POST", body: b }); previewedBody = b; renderPlan(plan); } + catch (e) { toast("Preview failed", e.message, "err"); } + } }); + applyBtn.addEventListener("click", async () => { + if (!previewedBody) { toast("Preview first", "Preview the changes before applying.", "err"); return; } + applyBtn.disabled = true; const orig = applyBtn.textContent; applyBtn.textContent = "Applying…"; + try { + const r = await api("/api/config/apply", { method: "POST", body: { ...previewedBody, confirm: true } }); + const moved = r.moved != null ? r.moved : r.migrated; + const parts = []; + if (moved) parts.push(`${moved} item(s) ${previewedBody.migration === "copy" ? "copied" : "moved"}`); + if (r.skipped) parts.push(`${r.skipped} skipped (already existed)`); + toast("Configuration updated", parts.join(" · ") || "Saved", "ok"); + closeModal(); await refreshSummary(); go("settings"); + } + catch (e) { toast("Apply failed", e.message, "err"); applyBtn.disabled = false; applyBtn.textContent = orig; } + }); + openModal({ title: "⚙️ CatPilot configuration", width: 660, body: el("div", {}, form, el("div", { class: "toolbar", style: "margin:4px 0 2px" }, previewBtn, el("span", { class: "spacer" }), el("span", { class: "muted small", text: "Preview before applying" })), planBox, approve), foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), applyBtn] }); + } + + // ---------------------------------------------------------------- help + VIEWS.help = async (root) => { + root.innerHTML = ""; + const cards = [ + { icon: "🐱", title: "What is this canvas?", body: "A visual command center for **CatPilot**. Everything you see reads and writes the *same files* CatPilot's CLI, agent and MCP server use — so edits here show up everywhere. Nothing is duplicated." }, + { icon: "🧭", title: "Navigation", body: "The sidebar covers every CatPilot domain: **Tasks, Journal, Milestones, Memos, Learning, Growth, Projects**. Each has add buttons, inline edit and detail popups. **Tasks** offers both a table and a kanban board." }, + { icon: "🕑", title: "Timeline", body: "A day-grouped story of your recent activity across every domain, with a **7/14/30 day** switch. Use the Copilot action chips to summarize the period, plan next steps or draft a standup." }, + { icon: "📊", title: "Reports", body: "Generate an **executive report** for a period from your tasks and milestones — or ask Copilot to write a richer one via the *report-generator* skill. Reports are saved as markdown/HTML in your storage `reports/` folder and open in a reader." }, + { icon: "✨", title: "Ask Copilot", body: "The **✨ button** (top bar) and the action chips send prompts to the Copilot agent in the chat panel. The agent works on the same data, so it can add tasks, write memos, generate reports and more on your behalf." }, + { icon: "✍️", title: "Markdown everywhere", body: "Every notes field is a full **markdown editor**: a formatting toolbar (bold, italic, headings, lists, checkboxes, code, links), a live **Preview** toggle, and **✨ Copilot** to draft the content from a short instruction." }, + { icon: "⚙️", title: "Settings & migration", body: "Change your storage **root** or **partitioning** from Settings. The wizard shows an exact **preview** of what will move, requires your **explicit approval**, then migrates your current data (move/copy/adopt) before switching config." }, + { icon: "🌗", title: "Themes & data", body: "Light/dark toggle lives in the top bar. Files are written in CatPilot's exact formats (markdown tables, `### date` journal headings, YAML frontmatter notes) so they stay **Obsidian/Dataview friendly**." }, + ]; + const grid = el("div", { class: "grid help-grid" }); + cards.forEach((c) => grid.append(el("div", { class: "card help-card" }, + el("div", { class: "help-ic", text: c.icon }), + el("h4", { text: c.title }), + mdRender(c.body)))); + root.append(grid); + root.append(el("div", { class: "toolbar", style: "margin-top:16px" }, + el("button", { class: "btn btn-primary", html: "✨ Ask Copilot what it can do", onclick: () => askAgent("What can you help me do with my CatPilot data? List the most useful things.") }))); + }; + + // ---------------------------------------------------------------- boot + async function boot() { + applyTheme(); + $("#theme-btn").addEventListener("click", toggleTheme); + $("#refresh-btn").addEventListener("click", async () => { await refreshSummary(); go(state.view); toast("Refreshed", "", "ok"); }); + const agentBtn = $("#agent-btn"); if (agentBtn) agentBtn.addEventListener("click", agentModal); + + const status = await api("/api/status").catch(() => ({ configured: false })); + if (!status.configured) { showOnboarding(); return; } + startApp(); + } + + function showOnboarding() { + $("#onboarding").classList.remove("hidden"); + $("#app").classList.add("hidden"); + $("#setup-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const payload = { root: fd.get("root"), partitioning: fd.get("partitioning") }; + if (!payload.root.trim()) { toast("Path required", "", "err"); return; } + try { + await api("/api/setup", { method: "POST", body: payload }); + toast("Workspace ready!", "CatPilot is set up", "ok"); + $("#onboarding").classList.add("hidden"); + startApp(); + } catch (err) { toast("Setup failed", err.message, "err"); } + }, { once: false }); + } + + async function startApp() { + $("#app").classList.remove("hidden"); + await refreshSummary(); + const initial = (location.hash || "").replace("#", ""); + go(NAV.some((n) => n.id === initial) ? initial : "dashboard"); + } + + window.addEventListener("hashchange", () => { + const v = (location.hash || "").replace("#", ""); + if (v && v !== state.view && NAV.some((n) => n.id === v)) go(v); + }); + + boot(); +})(); diff --git a/extensions/catpilot-canvas/ui/hero.png b/extensions/catpilot-canvas/ui/hero.png new file mode 100644 index 000000000..a1264704a Binary files /dev/null and b/extensions/catpilot-canvas/ui/hero.png differ diff --git a/extensions/catpilot-canvas/ui/hero.svg b/extensions/catpilot-canvas/ui/hero.svg new file mode 100644 index 000000000..590a00d12 --- /dev/null +++ b/extensions/catpilot-canvas/ui/hero.svg @@ -0,0 +1,15 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="160" height="160" shape-rendering="crispEdges"> + <rect width="16" height="16" fill="#0f172a"/> + <rect x="3" y="2" width="2" height="2" fill="#f59e0b"/> + <rect x="11" y="2" width="2" height="2" fill="#f59e0b"/> + <rect x="2" y="4" width="12" height="8" fill="#f97316"/> + <rect x="4" y="6" width="2" height="2" fill="#ffffff"/> + <rect x="10" y="6" width="2" height="2" fill="#ffffff"/> + <rect x="5" y="7" width="1" height="1" fill="#111827"/> + <rect x="10" y="7" width="1" height="1" fill="#111827"/> + <rect x="7" y="8" width="2" height="1" fill="#111827"/> + <rect x="6" y="9" width="4" height="1" fill="#111827"/> + <rect x="4" y="12" width="8" height="2" fill="#f59e0b"/> + <rect x="12" y="10" width="2" height="1" fill="#f59e0b"/> + <rect x="13" y="11" width="1" height="2" fill="#f59e0b"/> +</svg> diff --git a/extensions/catpilot-canvas/ui/index.html b/extensions/catpilot-canvas/ui/index.html new file mode 100644 index 000000000..91882d37a --- /dev/null +++ b/extensions/catpilot-canvas/ui/index.html @@ -0,0 +1,75 @@ +<!doctype html> +<html lang="en" data-theme="dark"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>CatPilot + + + + + + + + + + + +
+ + + + diff --git a/extensions/catpilot-canvas/ui/styles.css b/extensions/catpilot-canvas/ui/styles.css new file mode 100644 index 000000000..39201bd7c --- /dev/null +++ b/extensions/catpilot-canvas/ui/styles.css @@ -0,0 +1,460 @@ +/* CatPilot canvas — modern design system. + Uses app theme tokens as defaults, layered with a CatPilot accent palette. + Light/dark controlled by [data-theme] on . */ + +:root { + --accent: #7c5cff; + --accent-2: #ff7ac2; + --accent-grad: linear-gradient(135deg, #7c5cff 0%, #b06cff 45%, #ff7ac2 100%); + --radius: 14px; + --radius-sm: 9px; + --shadow-sm: 0 1px 2px rgba(0,0,0,.08), 0 1px 3px rgba(0,0,0,.06); + --shadow: 0 6px 24px rgba(0,0,0,.12); + --shadow-lg: 0 20px 60px rgba(0,0,0,.28); + --font-sans-ui: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); + --font-mono-ui: var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace); + --sq: 260ms cubic-bezier(.2,.7,.2,1); +} + +html[data-theme="dark"] { + --bg: #0e0e14; + --bg-2: #15151f; + --panel: #1a1a26; + --panel-2: #20202e; + --panel-hover: #262636; + --border: #2b2b3d; + --border-soft: #23233200; + --text: #ececf4; + --text-muted: #9a9ab5; + --text-faint: #6d6d86; + --shadow-lg: 0 20px 60px rgba(0,0,0,.55); +} + +html[data-theme="light"] { + --bg: #f4f4fb; + --bg-2: #ececf6; + --panel: #ffffff; + --panel-2: #f7f7fc; + --panel-hover: #f0f0f8; + --border: #e3e3ef; + --border-soft: #eeeef6; + --text: #1a1a2b; + --text-muted: #64647e; + --text-faint: #9797ad; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans-ui); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +.hidden { display: none !important; } +.muted { color: var(--text-muted); } +.small { font-size: 12px; } +em { font-style: normal; color: var(--text-faint); } + +/* ---------- Layout ---------- */ +.app { display: grid; grid-template-columns: 244px 1fr; height: 100vh; } + +.sidebar { + display: flex; + flex-direction: column; + background: var(--bg-2); + border-right: 1px solid var(--border); + padding: 18px 14px; + gap: 10px; + min-width: 0; +} + +.brand { display: flex; align-items: center; gap: 11px; padding: 4px 6px 12px; } +.brand-logo { + width: 40px; height: 40px; border-radius: 11px; object-fit: cover; + box-shadow: var(--shadow-sm); background: var(--panel); +} +.brand-text { display: flex; flex-direction: column; line-height: 1.15; } +.brand-text strong { font-size: 16px; letter-spacing: .2px; } + +.nav { display: flex; flex-direction: column; gap: 3px; overflow-y: auto; flex: 1; } +.nav-item { + display: flex; align-items: center; gap: 11px; + padding: 9px 11px; border-radius: var(--radius-sm); + color: var(--text-muted); cursor: pointer; border: 1px solid transparent; + transition: background var(--sq), color var(--sq), transform var(--sq); + user-select: none; font-weight: 500; + width: 100%; text-align: left; font-family: inherit; font-size: inherit; + background: transparent; appearance: none; +} +.nav-item:hover { background: var(--panel-hover); color: var(--text); } +.nav-item:focus-visible { outline: 2px solid var(--color-focus-outline, var(--accent)); outline-offset: 2px; } +.nav-item.active { + background: var(--panel); color: var(--text); + border-color: var(--border); + box-shadow: var(--shadow-sm); +} +.nav-item.active .nav-ic { background: var(--accent-grad); -webkit-background-clip: text; background-clip: text; color: transparent; } +.nav-ic { width: 20px; text-align: center; font-size: 15px; } +.nav-badge { + margin-left: auto; font-size: 11px; font-weight: 700; + background: var(--panel-2); color: var(--text-muted); + padding: 1px 7px; border-radius: 999px; min-width: 20px; text-align: center; + border: 1px solid var(--border); +} +.nav-item.active .nav-badge { background: var(--accent); color: #fff; border-color: transparent; } + +.sidebar-foot { margin-top: auto; } +.storage-chip { + font-size: 11px; color: var(--text-faint); + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 8px 10px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-family: var(--font-mono-ui); +} + +.main { display: flex; flex-direction: column; min-width: 0; background: var(--bg); } + +.topbar { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 26px; border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 82%, transparent); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 5; +} +.topbar-left h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: .2px; } +.topbar-right { display: flex; align-items: center; gap: 10px; } + +.icon-btn { + width: 36px; height: 36px; border-radius: 10px; + border: 1px solid var(--border); background: var(--panel); + color: var(--text); cursor: pointer; font-size: 16px; + display: grid; place-items: center; transition: all var(--sq); +} +.icon-btn:hover { background: var(--panel-hover); transform: translateY(-1px); } + +.segmented { display: flex; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 3px; gap: 2px; } +.segmented button { + border: 0; background: transparent; color: var(--text-muted); + padding: 6px 13px; border-radius: 7px; cursor: pointer; font-weight: 600; font-size: 13px; + transition: all var(--sq); +} +.segmented button.active { background: var(--accent-grad); color: #fff; box-shadow: var(--shadow-sm); } + +.content { padding: 24px 26px 60px; overflow-y: auto; flex: 1; } + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; align-items: center; gap: 7px; + border: 1px solid var(--border); background: var(--panel); + color: var(--text); padding: 8px 14px; border-radius: 10px; + cursor: pointer; font-weight: 600; font-size: 13px; transition: all var(--sq); + white-space: nowrap; +} +.btn:hover { background: var(--panel-hover); transform: translateY(-1px); } +.btn-primary { background: var(--accent-grad); border-color: transparent; color: #fff; box-shadow: var(--shadow-sm); } +.btn-primary:hover { filter: brightness(1.06); } +.btn-lg { padding: 12px 18px; font-size: 15px; border-radius: 12px; width: 100%; justify-content: center; } +.btn-ghost { background: transparent; border-color: transparent; } +.btn-ghost:hover { background: var(--panel-hover); } +.btn-danger { color: #ff6b7d; } +.btn-sm { padding: 5px 10px; font-size: 12px; } + +/* ---------- Cards & grid ---------- */ +.grid { display: grid; gap: 16px; } +.cards { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); } +.card { + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow-sm); + transition: transform var(--sq), border-color var(--sq), box-shadow var(--sq); +} +.card.hoverable:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); box-shadow: var(--shadow); } + +.stat { position: relative; overflow: hidden; } +.stat .stat-ic { + position: absolute; right: 12px; top: 12px; font-size: 26px; opacity: .18; +} +.stat .stat-num { font-size: 30px; font-weight: 800; letter-spacing: -.5px; line-height: 1; } +.stat .stat-label { color: var(--text-muted); font-size: 13px; margin-top: 6px; font-weight: 500; } +.stat .stat-sub { font-size: 11px; margin-top: 8px; } +.tone-accent .stat-num { background: var(--accent-grad); -webkit-background-clip: text; background-clip: text; color: transparent; } +.tone-warn .stat-num { color: #ffb020; } +.tone-danger .stat-num { color: #ff6b7d; } +.tone-ok .stat-num { color: #35c88f; } + +.section-title { display: flex; align-items: center; justify-content: space-between; margin: 26px 0 13px; } +.section-title h3 { margin: 0; font-size: 15px; font-weight: 700; } +.section-title .muted { font-weight: 500; } + +/* Dashboard hero */ +.hero { + display: flex; align-items: center; gap: 22px; + background: + radial-gradient(1200px 300px at 90% -40%, color-mix(in srgb, var(--accent-2) 26%, transparent), transparent 60%), + radial-gradient(900px 260px at 5% 130%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 60%), + var(--panel); + border: 1px solid var(--border); border-radius: 20px; padding: 22px 26px; + box-shadow: var(--shadow); overflow: hidden; position: relative; +} +.hero img { width: 92px; height: 92px; border-radius: 20px; box-shadow: var(--shadow); flex: 0 0 auto; } +.hero-txt h1 { margin: 0 0 4px; font-size: 24px; font-weight: 800; letter-spacing: -.3px; } +.hero-txt p { margin: 0; color: var(--text-muted); max-width: 60ch; } +.hero-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap; } + +/* Charts */ +.chart-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; } +.chart-card h4 { margin: 0 0 14px; font-size: 14px; font-weight: 700; } +.legend { display: flex; flex-wrap: wrap; gap: 10px 16px; margin-top: 14px; } +.legend span { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--text-muted); } +.legend i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; } + +.bars { display: flex; align-items: flex-end; gap: 14px; height: 150px; padding-top: 8px; } +.bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 8px; height: 100%; justify-content: flex-end; } +.bar-stack { width: 60%; max-width: 46px; display: flex; flex-direction: column-reverse; border-radius: 7px 7px 3px 3px; overflow: hidden; background: var(--panel-2); min-height: 3px; } +.bar-seg { width: 100%; } +.bar-label { font-size: 11px; color: var(--text-faint); } + +/* Timeline */ +.timeline { display: flex; flex-direction: column; gap: 2px; } +.tl-item { display: flex; gap: 12px; padding: 10px 6px; border-radius: 9px; transition: background var(--sq); } +.tl-item:hover { background: var(--panel-hover); } +.tl-dot { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; font-size: 14px; flex: 0 0 auto; background: var(--panel-2); border: 1px solid var(--border); } +.tl-body { min-width: 0; flex: 1; } +.tl-label { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tl-meta { font-size: 11px; color: var(--text-faint); text-transform: capitalize; } + +/* ---------- Tables ---------- */ +.table-wrap { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); } +table.tbl { width: 100%; border-collapse: collapse; } +table.tbl th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .6px; + color: var(--text-faint); padding: 12px 14px; border-bottom: 1px solid var(--border); font-weight: 700; +} +table.tbl td { padding: 11px 14px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; } +table.tbl tr:last-child td { border-bottom: 0; } +table.tbl tbody tr { transition: background var(--sq); } +table.tbl tbody tr:hover { background: var(--panel-hover); } +.row-actions { display: flex; gap: 4px; opacity: 0; transition: opacity var(--sq); justify-content: flex-end; } +table.tbl tr:hover .row-actions { opacity: 1; } +table.tbl tr:focus-within .row-actions { opacity: 1; } +.cell-title { font-weight: 600; cursor: pointer; } +.cell-title:hover { color: var(--accent); } +.done-row .cell-title { text-decoration: line-through; color: var(--text-faint); } + +/* Badges / chips */ +.badge { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--border); } +.badge.p0, .badge.high { background: rgba(255,107,125,.14); color: #ff6b7d; border-color: transparent; } +.badge.p1 { background: rgba(255,176,32,.15); color: #ffb020; border-color: transparent; } +.badge.p2, .badge.med { background: rgba(124,92,255,.16); color: #a58bff; border-color: transparent; } +.badge.p3, .badge.low { background: rgba(53,200,143,.15); color: #35c88f; border-color: transparent; } +.badge.st-open { background: rgba(124,92,255,.14); color: #a58bff; border-color: transparent; } +.badge.st-done { background: rgba(53,200,143,.15); color: #35c88f; border-color: transparent; } +.badge.st-planned { background: var(--panel-2); color: var(--text-muted); } +.badge.st-inprogress { background: rgba(90,169,255,.16); color: #5aa9ff; border-color: transparent; } +.tag { font-size: 11px; padding: 2px 8px; border-radius: 6px; background: var(--panel-2); color: var(--text-muted); border: 1px solid var(--border); } +.tags { display: flex; flex-wrap: wrap; gap: 5px; } + +/* ---------- Board (kanban) ---------- */ +.board { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: start; } +.board-col { background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; min-height: 120px; transition: border-color var(--sq), background var(--sq); } +.board-col.drop { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--bg-2)); } +.board-col h4 { margin: 4px 6px 12px; font-size: 13px; display: flex; align-items: center; gap: 8px; } +.board-col h4 .count { color: var(--text-faint); font-weight: 600; } +.board-card { + background: var(--panel); border: 1px solid var(--border); border-radius: 11px; + padding: 12px; margin-bottom: 10px; cursor: grab; box-shadow: var(--shadow-sm); + transition: transform var(--sq), box-shadow var(--sq), border-color var(--sq); +} +.board-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); } +.board-card.dragging { opacity: .5; } +.board-card .bc-title { font-weight: 600; margin-bottom: 8px; } +.board-card .bc-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + +/* ---------- Note cards ---------- */ +.note-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } +.note-card { cursor: pointer; display: flex; flex-direction: column; gap: 8px; } +.note-card h4 { margin: 0; font-size: 15px; } +.note-card .note-foot { margin-top: auto; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.note-card .excerpt { color: var(--text-muted); font-size: 13px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } + +/* Journal */ +.journal-list { display: flex; flex-direction: column; gap: 14px; max-width: 820px; } +.journal-entry { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow-sm); } +.journal-entry .date { font-weight: 700; font-size: 13px; color: var(--accent); margin-bottom: 8px; display: flex; align-items: center; gap: 8px; } +.journal-entry .body { white-space: pre-wrap; } + +/* ---------- Forms ---------- */ +.form { display: flex; flex-direction: column; gap: 14px; } +.form label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 600; } +.form label span em { font-weight: 400; } +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +input, textarea, select { + font-family: inherit; font-size: 14px; color: var(--text); + background: var(--panel-2); border: 1px solid var(--border); + border-radius: 10px; padding: 10px 12px; width: 100%; transition: border-color var(--sq), box-shadow var(--sq); +} +input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } +textarea { resize: vertical; min-height: 90px; } +.inline-edit { padding: 6px 8px; border-radius: 7px; font-size: 13px; } + +/* ---------- Modal ---------- */ +.modal-backdrop { + position: fixed; inset: 0; background: rgba(6,6,12,.55); backdrop-filter: blur(3px); + display: grid; place-items: center; z-index: 50; padding: 24px; animation: fade var(--sq); +} +.modal { + background: var(--panel); border: 1px solid var(--border); border-radius: 18px; + box-shadow: var(--shadow-lg); width: min(620px, 100%); max-height: 88vh; overflow: hidden; + display: flex; flex-direction: column; animation: pop var(--sq); +} +.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--border); } +.modal-head h3 { margin: 0; font-size: 17px; } +.modal-body { padding: 20px 22px; overflow-y: auto; } +.modal-foot { padding: 14px 22px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 10px; } +.detail-row { display: grid; grid-template-columns: 130px 1fr; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--border-soft); } +.detail-row:last-child { border-bottom: 0; } +.detail-row .k { color: var(--text-faint); font-size: 12px; text-transform: uppercase; letter-spacing: .5px; padding-top: 2px; } +.markdown-body { white-space: pre-wrap; line-height: 1.6; } + +@keyframes fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes pop { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } } + +/* ---------- Onboarding ---------- */ +.onboarding { position: fixed; inset: 0; display: grid; place-items: center; padding: 24px; + background: radial-gradient(1000px 500px at 50% -10%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 60%), var(--bg); z-index: 40; } +.onboarding-card { background: var(--panel); border: 1px solid var(--border); border-radius: 22px; box-shadow: var(--shadow-lg); padding: 34px; width: min(460px, 100%); text-align: center; } +.onboarding-hero { width: 88px; height: 88px; border-radius: 22px; box-shadow: var(--shadow); margin-bottom: 8px; } +.onboarding h1 { margin: 8px 0 6px; font-size: 24px; } +.onboarding .form { text-align: left; margin-top: 22px; } + +/* ---------- Empty & toasts ---------- */ +.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); } +.empty .big { font-size: 46px; margin-bottom: 12px; opacity: .8; } +.empty h3 { margin: 0 0 6px; color: var(--text); } + +.toasts { position: fixed; bottom: 22px; right: 22px; display: flex; flex-direction: column; gap: 10px; z-index: 60; } +.toast { display: flex; gap: 11px; align-items: flex-start; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 12px 15px; box-shadow: var(--shadow-lg); min-width: 250px; animation: slidein var(--sq); } +.toast .t-ic { width: 22px; height: 22px; border-radius: 7px; display: grid; place-items: center; font-size: 13px; flex: 0 0 auto; background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); } +.toast.ok .t-ic { background: rgba(53,200,143,.16); color: #35c88f; } +.toast.err .t-ic { background: rgba(255,107,125,.16); color: #ff6b7d; } +.toast strong { display: block; font-size: 13px; } +.toast span { font-size: 12px; color: var(--text-muted); } +@keyframes slidein { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: none; } } + +.spinner { width: 34px; height: 34px; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); animation: spin .7s linear infinite; margin: 60px auto; } +@keyframes spin { to { transform: rotate(360deg); } } + +.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } +.toolbar .spacer { flex: 1; } +.search { max-width: 260px; } + +@media (max-width: 720px) { + /* Collapse the sidebar into a horizontal, scrollable top bar so every + destination stays reachable on narrow viewports (no hidden nav). */ + .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } + .sidebar { flex-direction: row; align-items: center; gap: 8px; overflow-x: auto; padding: 8px 10px; } + .sidebar .brand-text, .sidebar .nav-spacer, .sidebar .sidebar-foot { display: none; } + .sidebar .nav { flex-direction: row; gap: 6px; overflow-x: auto; overflow-y: visible; } + .nav-item { width: auto; white-space: nowrap; } + .nav-item .nav-badge { display: none; } +} + +/* ---------- Nav footer separator ---------- */ +.nav-spacer { flex: 1; min-height: 14px; } + +/* ---------- Chips & agent actions ---------- */ +.chip { + display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 999px; + background: var(--panel); border: 1px solid var(--border); color: var(--text); font-size: 12.5px; + cursor: pointer; transition: var(--sq); font-family: inherit; +} +.chip:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); background: var(--panel-hover); transform: translateY(-1px); } +.chip-row { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 12px; } +.agent-bar { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; padding: 12px 14px; + border: 1px solid var(--border); border-radius: 14px; + background: linear-gradient(120deg, color-mix(in srgb, var(--accent) 10%, transparent), color-mix(in srgb, var(--accent-2, var(--accent)) 6%, transparent)); +} +.agent-bar-label { font-size: 12px; font-weight: 600; color: var(--text-muted); margin-right: 4px; letter-spacing: .3px; } +.agent-chip { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); } +.agent-icon { color: var(--accent); font-size: 15px; } + +/* ---------- Timeline groups ---------- */ +.tl-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; } +.tl-wrap { display: flex; flex-direction: column; gap: 22px; } +.tl-day { border-left: 2px solid var(--border); padding-left: 16px; position: relative; } +.tl-day-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 6px; } +.tl-day-date { font-weight: 600; font-size: 14px; } +.tl-dot.tl-journal { color: #8ea2ff; } .tl-dot.tl-memo { color: #35c88f; } +.tl-dot.tl-milestone { color: #ffb454; } .tl-dot.tl-report { color: var(--accent); } +.tl-dot.tl-learning { color: #f78fff; } .tl-dot.tl-growth { color: #35c88f; } .tl-dot.tl-project { color: #7cd3ff; } +.tl-meta { white-space: normal; } + +/* ---------- Rendered markdown ---------- */ +.md { line-height: 1.62; word-wrap: break-word; } +.md > :first-child { margin-top: 0; } +.md > :last-child { margin-bottom: 0; } +.md h1, .md h2, .md h3 { margin: 18px 0 8px; line-height: 1.3; } +.md h1 { font-size: 20px; } .md h2 { font-size: 17px; } .md h3 { font-size: 15px; } +.md p { margin: 8px 0; } +.md ul, .md ol { margin: 8px 0; padding-left: 22px; } +.md li { margin: 3px 0; } +.md code { background: var(--panel-2); padding: 1.5px 5px; border-radius: 5px; font-size: 12.5px; font-family: var(--font-mono, monospace); } +.md pre { background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; overflow-x: auto; } +.md pre code { background: none; padding: 0; } +.md blockquote { margin: 8px 0; padding: 4px 14px; border-left: 3px solid var(--accent); color: var(--text-muted); } +.md hr { border: 0; border-top: 1px solid var(--border); margin: 16px 0; } +.md a { color: var(--accent); } +.md-table, table.md-table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 13px; } +.md-table th, .md-table td { border: 1px solid var(--border); padding: 7px 10px; text-align: left; } +.md-table th { background: var(--panel-2); font-weight: 600; } + +/* ---------- Markdown editor ---------- */ +.md-label { display: flex; flex-direction: column; gap: 6px; } +.md-editor { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--panel); } +.md-toolbar { display: flex; align-items: center; gap: 3px; padding: 6px 8px; border-bottom: 1px solid var(--border); background: var(--panel-2); flex-wrap: wrap; } +.md-tool { + min-width: 30px; height: 28px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; + background: none; color: var(--text-muted); cursor: pointer; font-size: 13px; font-family: inherit; transition: var(--sq); +} +.md-tool:hover { background: var(--panel-hover); color: var(--text); border-color: var(--border); } +.md-tool.tt-b { font-weight: 800; } .md-tool.tt-i { font-style: italic; } +.md-gen-btn { color: var(--accent); font-weight: 600; } +.md-gen { display: flex; gap: 8px; padding: 8px; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--accent) 7%, var(--panel)); } +.md-gen-input { flex: 1; padding: 7px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); color: var(--text); font-family: inherit; font-size: 13px; } +.md-surface { position: relative; } +.md-input { display: block; width: 100%; box-sizing: border-box; border: 0; border-radius: 0; resize: vertical; min-height: 120px; background: var(--panel); color: var(--text); padding: 12px 14px; font-family: inherit; font-size: 13.5px; line-height: 1.6; } +.md-input:focus { outline: none; box-shadow: none; } +.md-preview { padding: 12px 14px; min-height: 120px; } +.hidden { display: none !important; } + +/* ---------- Reports ---------- */ +.report-card { display: flex; flex-direction: column; gap: 8px; } +.report-ic { font-size: 26px; } +.report-iframe { width: 100%; height: 62vh; border: 1px solid var(--border); border-radius: 10px; background: #fff; } + +/* ---------- Settings & migration wizard ---------- */ +.settings-card { max-width: 640px; } +.settings-card h4 { margin: 0 0 12px; font-size: 15px; } +.plan-box { margin-top: 14px; padding: 14px 16px; border: 1px dashed var(--border); border-radius: 12px; background: var(--panel-2); } +.plan-head { margin-bottom: 10px; } +.plan-diff { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; } +.plan-diff code { display: inline-block; word-break: break-all; } +.plan-list { display: flex; flex-direction: column; gap: 6px; } +.plan-item { display: flex; align-items: center; gap: 10px; } +.approve { display: flex; align-items: center; gap: 9px; margin-top: 14px; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); cursor: pointer; } +.approve input { width: 16px; height: 16px; accent-color: var(--accent); } +.plan-warn { margin-top: 12px; padding: 10px 12px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--true-color-red, #d1242f) 40%, var(--border)); background: color-mix(in srgb, var(--true-color-red, #d1242f) 8%, transparent); } +.plan-warn strong { color: var(--true-color-red, #d1242f); } + +/* ---------- Help ---------- */ +.help-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } +.help-card { display: flex; flex-direction: column; gap: 6px; } +.help-ic { font-size: 26px; } +.help-card h4 { margin: 2px 0 2px; font-size: 15px; } +.help-card .md { font-size: 13px; color: var(--text-muted); }