feat(KB-168): remove legacy public/ directory and server fallback
- Delete public/index.html, public/style.css, and public/board.js legacy assets - Remove public/ fallback from server.ts clientDir resolution chain - Simplify clientDir to fall through to client/ instead of public/ - Add no-legacy-public test asserting public/ files and server references are gone
This commit is contained in:
31
packages/dashboard/app/__tests__/no-legacy-public.test.ts
Normal file
31
packages/dashboard/app/__tests__/no-legacy-public.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { existsSync, readFileSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
describe("legacy public/ directory removal", () => {
|
||||||
|
const publicDir = resolve(__dirname, "../../public");
|
||||||
|
|
||||||
|
it("public/style.css does not exist", () => {
|
||||||
|
expect(existsSync(resolve(publicDir, "style.css"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("public/board.js does not exist", () => {
|
||||||
|
expect(existsSync(resolve(publicDir, "board.js"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("public/index.html does not exist", () => {
|
||||||
|
expect(existsSync(resolve(publicDir, "index.html"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("server.ts clientDir resolution does not reference 'public'", () => {
|
||||||
|
const serverSrc = readFileSync(
|
||||||
|
resolve(__dirname, "../../src/server.ts"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
// Extract the clientDir resolution block (from "const clientDir" to the semicolon)
|
||||||
|
const match = serverSrc.match(/const clientDir[\s\S]*?;/);
|
||||||
|
expect(match).toBeTruthy();
|
||||||
|
expect(match![0]).not.toContain('"public"');
|
||||||
|
expect(match![0]).not.toContain("'public'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
// hai board — client
|
|
||||||
(() => {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"];
|
|
||||||
const COLUMN_LABELS = {
|
|
||||||
triage: "Triage",
|
|
||||||
todo: "Todo",
|
|
||||||
"in-progress": "In Progress",
|
|
||||||
"in-review": "In Review",
|
|
||||||
done: "Done",
|
|
||||||
};
|
|
||||||
const TRANSITIONS = {
|
|
||||||
triage: ["todo"],
|
|
||||||
todo: ["in-progress", "triage"],
|
|
||||||
"in-progress": ["in-review"],
|
|
||||||
"in-review": ["done", "in-progress"],
|
|
||||||
done: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
let tasks = [];
|
|
||||||
let eventSource = null;
|
|
||||||
|
|
||||||
// ── API ──
|
|
||||||
async function api(path, opts = {}) {
|
|
||||||
const res = await fetch(`/api${path}`, {
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
...opts,
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ──
|
|
||||||
function render() {
|
|
||||||
for (const col of COLUMNS) {
|
|
||||||
const body = document.querySelector(`[data-drop="${col}"]`);
|
|
||||||
const colTasks = tasks.filter((t) => t.column === col);
|
|
||||||
|
|
||||||
document.querySelector(`[data-count="${col}"]`).textContent =
|
|
||||||
colTasks.length;
|
|
||||||
|
|
||||||
if (colTasks.length === 0) {
|
|
||||||
body.innerHTML = '<div class="empty-column">No tasks</div>';
|
|
||||||
} else {
|
|
||||||
body.innerHTML = colTasks.map(cardHTML).join("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach drag handlers
|
|
||||||
document.querySelectorAll(".card").forEach((card) => {
|
|
||||||
card.addEventListener("dragstart", onDragStart);
|
|
||||||
card.addEventListener("dragend", onDragEnd);
|
|
||||||
card.addEventListener("click", () => showDetail(card.dataset.id));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function cardHTML(task) {
|
|
||||||
const deps =
|
|
||||||
task.dependencies && task.dependencies.length
|
|
||||||
? `<div class="card-meta"><span class="card-dep-badge">${task.dependencies.length} dep${task.dependencies.length > 1 ? "s" : ""}</span></div>`
|
|
||||||
: "";
|
|
||||||
return `<div class="card" data-id="${task.id}" draggable="true">
|
|
||||||
<span class="card-id">${task.id}</span>
|
|
||||||
<div class="card-title">${escapeHtml(task.title)}</div>
|
|
||||||
${deps}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
const d = document.createElement("div");
|
|
||||||
d.textContent = str;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Drag & Drop ──
|
|
||||||
function onDragStart(e) {
|
|
||||||
e.dataTransfer.setData("text/plain", e.currentTarget.dataset.id);
|
|
||||||
e.dataTransfer.effectAllowed = "move";
|
|
||||||
e.currentTarget.classList.add("dragging");
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragEnd(e) {
|
|
||||||
e.currentTarget.classList.remove("dragging");
|
|
||||||
document
|
|
||||||
.querySelectorAll(".column")
|
|
||||||
.forEach((c) => c.classList.remove("drag-over"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupDropZones() {
|
|
||||||
document.querySelectorAll(".column").forEach((column) => {
|
|
||||||
column.addEventListener("dragover", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = "move";
|
|
||||||
column.classList.add("drag-over");
|
|
||||||
});
|
|
||||||
|
|
||||||
column.addEventListener("dragleave", (e) => {
|
|
||||||
// Only remove if actually leaving the column
|
|
||||||
if (!column.contains(e.relatedTarget)) {
|
|
||||||
column.classList.remove("drag-over");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
column.addEventListener("drop", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
column.classList.remove("drag-over");
|
|
||||||
const taskId = e.dataTransfer.getData("text/plain");
|
|
||||||
const toColumn = column.dataset.column;
|
|
||||||
const task = tasks.find((t) => t.id === taskId);
|
|
||||||
|
|
||||||
if (!task || task.column === toColumn) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api(`/tasks/${taskId}/move`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ column: toColumn }),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
toast(err.message, "error");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Task Detail ──
|
|
||||||
async function showDetail(id) {
|
|
||||||
try {
|
|
||||||
const task = await api(`/tasks/${id}`);
|
|
||||||
const modal = document.getElementById("detail-modal");
|
|
||||||
|
|
||||||
document.getElementById("detail-id").textContent = task.id;
|
|
||||||
document.getElementById("detail-title").textContent = task.title;
|
|
||||||
|
|
||||||
const badge = document.getElementById("detail-column");
|
|
||||||
badge.textContent = COLUMN_LABELS[task.column];
|
|
||||||
badge.className = `detail-column-badge badge-${task.column}`;
|
|
||||||
|
|
||||||
document.getElementById("detail-meta").textContent =
|
|
||||||
`Created ${new Date(task.createdAt).toLocaleDateString()} · ` +
|
|
||||||
`Updated ${new Date(task.updatedAt).toLocaleDateString()}`;
|
|
||||||
|
|
||||||
document.getElementById("detail-prompt").textContent =
|
|
||||||
task.prompt || "(no prompt)";
|
|
||||||
|
|
||||||
// Dependencies
|
|
||||||
const depsEl = document.getElementById("detail-deps");
|
|
||||||
if (task.dependencies && task.dependencies.length) {
|
|
||||||
depsEl.innerHTML =
|
|
||||||
"<h4>Dependencies</h4><ul class='detail-dep-list'>" +
|
|
||||||
task.dependencies.map((d) => `<li>${d}</li>`).join("") +
|
|
||||||
"</ul>";
|
|
||||||
} else {
|
|
||||||
depsEl.innerHTML = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions: move buttons for valid transitions + merge for in-review
|
|
||||||
const actionsEl = document.getElementById("detail-actions");
|
|
||||||
const transitions = TRANSITIONS[task.column] || [];
|
|
||||||
const buttons = [
|
|
||||||
`<button class="btn btn-danger btn-sm" onclick="window.__deleteTask('${task.id}')">Delete</button>`,
|
|
||||||
'<div style="flex:1"></div>',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (task.column === "in-review") {
|
|
||||||
buttons.push(
|
|
||||||
`<button class="btn btn-sm" onclick="window.__moveTask('${task.id}','in-progress')">Back to In Progress</button>`,
|
|
||||||
`<button class="btn btn-primary btn-sm" onclick="window.__mergeTask('${task.id}')">Merge & Close</button>`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
transitions.forEach((col) => {
|
|
||||||
buttons.push(
|
|
||||||
`<button class="btn btn-sm" onclick="window.__moveTask('${task.id}','${col}')">Move to ${COLUMN_LABELS[col]}</button>`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
actionsEl.innerHTML = buttons.join("");
|
|
||||||
|
|
||||||
openModal("detail-modal");
|
|
||||||
} catch (err) {
|
|
||||||
toast(err.message, "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global action handlers (used by onclick in dynamic HTML)
|
|
||||||
window.__moveTask = async (id, column) => {
|
|
||||||
try {
|
|
||||||
await api(`/tasks/${id}/move`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ column }),
|
|
||||||
});
|
|
||||||
closeModal("detail-modal");
|
|
||||||
toast(`Moved to ${COLUMN_LABELS[column]}`, "success");
|
|
||||||
} catch (err) {
|
|
||||||
toast(err.message, "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.__mergeTask = async (id) => {
|
|
||||||
if (!confirm(`Merge ${id} into the current branch?`)) return;
|
|
||||||
try {
|
|
||||||
const result = await api(`/tasks/${id}/merge`, { method: "POST" });
|
|
||||||
closeModal("detail-modal");
|
|
||||||
const msg = result.merged
|
|
||||||
? `Merged ${id} (branch: ${result.branch})`
|
|
||||||
: `Closed ${id} (${result.error || "no branch to merge"})`;
|
|
||||||
toast(msg, "success");
|
|
||||||
} catch (err) {
|
|
||||||
toast(err.message, "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.__deleteTask = async (id) => {
|
|
||||||
if (!confirm(`Delete ${id}?`)) return;
|
|
||||||
try {
|
|
||||||
await api(`/tasks/${id}`, { method: "DELETE" });
|
|
||||||
closeModal("detail-modal");
|
|
||||||
toast(`Deleted ${id}`, "info");
|
|
||||||
} catch (err) {
|
|
||||||
toast(err.message, "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Create Task ──
|
|
||||||
function setupCreateForm() {
|
|
||||||
const form = document.getElementById("create-form");
|
|
||||||
form.addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const title = document.getElementById("task-title").value.trim();
|
|
||||||
const description = document.getElementById("task-desc").value.trim();
|
|
||||||
const depsRaw = document.getElementById("task-deps").value.trim();
|
|
||||||
const dependencies = depsRaw
|
|
||||||
? depsRaw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (!title) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const task = await api("/tasks", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ title, description, dependencies }),
|
|
||||||
});
|
|
||||||
closeModal("create-modal");
|
|
||||||
form.reset();
|
|
||||||
toast(`Created ${task.id}`, "success");
|
|
||||||
} catch (err) {
|
|
||||||
toast(err.message, "error");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SSE ──
|
|
||||||
function connectSSE() {
|
|
||||||
eventSource = new EventSource("/api/events");
|
|
||||||
|
|
||||||
eventSource.addEventListener("task:created", (e) => {
|
|
||||||
const task = JSON.parse(e.data);
|
|
||||||
tasks.push(task);
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("task:moved", (e) => {
|
|
||||||
const { task } = JSON.parse(e.data);
|
|
||||||
const idx = tasks.findIndex((t) => t.id === task.id);
|
|
||||||
if (idx !== -1) tasks[idx] = task;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("task:updated", (e) => {
|
|
||||||
const task = JSON.parse(e.data);
|
|
||||||
const idx = tasks.findIndex((t) => t.id === task.id);
|
|
||||||
if (idx !== -1) tasks[idx] = task;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("task:deleted", (e) => {
|
|
||||||
const task = JSON.parse(e.data);
|
|
||||||
tasks = tasks.filter((t) => t.id !== task.id);
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("task:merged", (e) => {
|
|
||||||
const { task } = JSON.parse(e.data);
|
|
||||||
const idx = tasks.findIndex((t) => t.id === task.id);
|
|
||||||
if (idx !== -1) tasks[idx] = task;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("error", () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (eventSource.readyState === EventSource.CLOSED) connectSSE();
|
|
||||||
}, 3000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Modals ──
|
|
||||||
function openModal(id) {
|
|
||||||
document.getElementById(id).classList.add("open");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal(id) {
|
|
||||||
document.getElementById(id).classList.remove("open");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupModals() {
|
|
||||||
// Close buttons
|
|
||||||
document.querySelectorAll("[data-close]").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", () => closeModal(btn.dataset.close));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click overlay to close
|
|
||||||
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
|
|
||||||
overlay.addEventListener("click", (e) => {
|
|
||||||
if (e.target === overlay) overlay.classList.remove("open");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Escape key
|
|
||||||
document.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
document.querySelectorAll(".modal-overlay.open").forEach((m) => {
|
|
||||||
m.classList.remove("open");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add task button
|
|
||||||
document.getElementById("add-task-btn").addEventListener("click", () => {
|
|
||||||
openModal("create-modal");
|
|
||||||
setTimeout(() => document.getElementById("task-title").focus(), 100);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toasts ──
|
|
||||||
function toast(message, type = "info") {
|
|
||||||
const container = document.getElementById("toasts");
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = `toast toast-${type}`;
|
|
||||||
el.textContent = message;
|
|
||||||
container.appendChild(el);
|
|
||||||
setTimeout(() => el.remove(), 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Init ──
|
|
||||||
async function init() {
|
|
||||||
try {
|
|
||||||
tasks = await api("/tasks");
|
|
||||||
} catch {
|
|
||||||
tasks = [];
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
setupDropZones();
|
|
||||||
setupModals();
|
|
||||||
setupCreateForm();
|
|
||||||
connectSSE();
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
})();
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>kb | board</title>
|
|
||||||
<link rel="stylesheet" href="/style.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h1 class="logo">kb</h1>
|
|
||||||
<span class="logo-sub">board</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary" id="add-task-btn">+ New Task</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="board" id="board">
|
|
||||||
<div class="column" data-column="triage">
|
|
||||||
<div class="column-header">
|
|
||||||
<div class="column-dot dot-triage"></div>
|
|
||||||
<h2>Triage</h2>
|
|
||||||
<span class="column-count" data-count="triage">0</span>
|
|
||||||
</div>
|
|
||||||
<p class="column-desc">Raw ideas — AI will specify these</p>
|
|
||||||
<div class="column-body" data-drop="triage"></div>
|
|
||||||
</div>
|
|
||||||
<div class="column" data-column="todo">
|
|
||||||
<div class="column-header">
|
|
||||||
<div class="column-dot dot-todo"></div>
|
|
||||||
<h2>Todo</h2>
|
|
||||||
<span class="column-count" data-count="todo">0</span>
|
|
||||||
</div>
|
|
||||||
<p class="column-desc">Specified and ready to start</p>
|
|
||||||
<div class="column-body" data-drop="todo"></div>
|
|
||||||
</div>
|
|
||||||
<div class="column" data-column="in-progress">
|
|
||||||
<div class="column-header">
|
|
||||||
<div class="column-dot dot-in-progress"></div>
|
|
||||||
<h2>In Progress</h2>
|
|
||||||
<span class="column-count" data-count="in-progress">0</span>
|
|
||||||
</div>
|
|
||||||
<p class="column-desc">AI is working in a worktree</p>
|
|
||||||
<div class="column-body" data-drop="in-progress"></div>
|
|
||||||
</div>
|
|
||||||
<div class="column" data-column="in-review">
|
|
||||||
<div class="column-header">
|
|
||||||
<div class="column-dot dot-in-review"></div>
|
|
||||||
<h2>In Review</h2>
|
|
||||||
<span class="column-count" data-count="in-review">0</span>
|
|
||||||
</div>
|
|
||||||
<p class="column-desc">Complete — ready to merge</p>
|
|
||||||
<div class="column-body" data-drop="in-review"></div>
|
|
||||||
</div>
|
|
||||||
<div class="column" data-column="done">
|
|
||||||
<div class="column-header">
|
|
||||||
<div class="column-dot dot-done"></div>
|
|
||||||
<h2>Done</h2>
|
|
||||||
<span class="column-count" data-count="done">0</span>
|
|
||||||
</div>
|
|
||||||
<p class="column-desc">Merged and closed</p>
|
|
||||||
<div class="column-body" data-drop="done"></div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Create Task Modal -->
|
|
||||||
<div class="modal-overlay" id="create-modal">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>New Task</h3>
|
|
||||||
<button class="modal-close" data-close="create-modal">×</button>
|
|
||||||
</div>
|
|
||||||
<form id="create-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="task-title">Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="task-title"
|
|
||||||
placeholder="What needs to be done?"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="task-desc"
|
|
||||||
>Description <span class="optional">(optional)</span></label
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
id="task-desc"
|
|
||||||
rows="4"
|
|
||||||
placeholder="Add context, requirements, or rough notes..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="task-deps"
|
|
||||||
>Dependencies
|
|
||||||
<span class="optional">(comma-separated IDs)</span></label
|
|
||||||
>
|
|
||||||
<input type="text" id="task-deps" placeholder="KB-001, KB-002" />
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="btn" data-close="create-modal">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
Create in Triage
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Task Detail Modal -->
|
|
||||||
<div class="modal-overlay" id="detail-modal">
|
|
||||||
<div class="modal modal-lg">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="detail-title-row">
|
|
||||||
<span class="detail-id" id="detail-id"></span>
|
|
||||||
<span class="detail-column-badge" id="detail-column"></span>
|
|
||||||
</div>
|
|
||||||
<button class="modal-close" data-close="detail-modal">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="detail-body">
|
|
||||||
<h2 id="detail-title" class="detail-title"></h2>
|
|
||||||
<div class="detail-meta" id="detail-meta"></div>
|
|
||||||
<div class="detail-section">
|
|
||||||
<h4>PROMPT.md</h4>
|
|
||||||
<pre class="detail-prompt" id="detail-prompt"></pre>
|
|
||||||
</div>
|
|
||||||
<div class="detail-deps" id="detail-deps"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions" id="detail-actions"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toast notifications -->
|
|
||||||
<div class="toast-container" id="toasts"></div>
|
|
||||||
|
|
||||||
<script src="/board.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
/* === Reset & Tokens === */
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #0d1117;
|
|
||||||
--surface: #161b22;
|
|
||||||
--card: #21262d;
|
|
||||||
--card-hover: #282e36;
|
|
||||||
--border: #30363d;
|
|
||||||
--text: #e6edf3;
|
|
||||||
--text-muted: #8b949e;
|
|
||||||
--text-dim: #484f58;
|
|
||||||
|
|
||||||
--triage: #d29922;
|
|
||||||
--todo: #58a6ff;
|
|
||||||
--in-progress: #bc8cff;
|
|
||||||
--in-review: #3fb950;
|
|
||||||
--done: #8b949e;
|
|
||||||
|
|
||||||
--radius: 8px;
|
|
||||||
--radius-lg: 12px;
|
|
||||||
--shadow: 0 4px 24px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
height: 100%;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Header === */
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left { display: flex; align-items: baseline; gap: 8px; }
|
|
||||||
.logo { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
|
||||||
.logo-sub { font-size: 13px; color: var(--text-muted); font-weight: 400; }
|
|
||||||
|
|
||||||
/* === Buttons === */
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--card);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.btn:hover { background: var(--card-hover); border-color: var(--text-muted); }
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #238636;
|
|
||||||
border-color: #2ea043;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.btn-primary:hover { background: #2ea043; }
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #da3633;
|
|
||||||
border-color: #f85149;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.btn-danger:hover { background: #f85149; }
|
|
||||||
|
|
||||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
||||||
|
|
||||||
/* === Board === */
|
|
||||||
.board {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, minmax(260px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 24px;
|
|
||||||
height: calc(100vh - 57px);
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
scrollbar-color: var(--border) transparent;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
|
|
||||||
.board::-webkit-scrollbar { height: 6px; }
|
|
||||||
.board::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.board::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
||||||
|
|
||||||
.column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--surface);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 260px;
|
|
||||||
min-height: 0;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column.drag-over {
|
|
||||||
border-color: var(--todo);
|
|
||||||
box-shadow: inset 0 0 0 1px var(--todo);
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 14px 14px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
.dot-triage { background: var(--triage); }
|
|
||||||
.dot-todo { background: var(--todo); }
|
|
||||||
.dot-in-progress { background: var(--in-progress); }
|
|
||||||
.dot-in-review { background: var(--in-review); }
|
|
||||||
.dot-done { background: var(--done); }
|
|
||||||
|
|
||||||
.column-header h2 {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-count {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--card);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
min-width: 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-desc {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
padding: 4px 14px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 8px 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-body::-webkit-scrollbar { width: 4px; }
|
|
||||||
.column-body::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.column-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
||||||
|
|
||||||
/* === Cards === */
|
|
||||||
.card {
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 10px 12px;
|
|
||||||
cursor: grab;
|
|
||||||
transition: background 0.15s, border-color 0.15s, transform 0.15s, opacity 0.2s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.card:hover {
|
|
||||||
background: var(--card-hover);
|
|
||||||
border-color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.card:active { cursor: grabbing; }
|
|
||||||
.card.dragging { opacity: 0.4; transform: scale(0.98); }
|
|
||||||
|
|
||||||
.card-id {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-dep-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--triage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Modals === */
|
|
||||||
.modal-overlay {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.6);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
z-index: 100;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding-top: 10vh;
|
|
||||||
}
|
|
||||||
.modal-overlay.open { display: flex; }
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
width: 480px;
|
|
||||||
max-height: 80vh;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.modal-lg { width: 640px; }
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.modal-header h3 { font-size: 15px; font-weight: 600; }
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 22px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.modal-close:hover { color: var(--text); }
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Forms === */
|
|
||||||
.form-group { padding: 0 20px; margin-top: 16px; }
|
|
||||||
.form-group:last-of-type { margin-bottom: 8px; }
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.optional { font-weight: 400; text-transform: none; letter-spacing: 0; }
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group textarea:focus {
|
|
||||||
border-color: var(--todo);
|
|
||||||
}
|
|
||||||
.form-group textarea { resize: vertical; }
|
|
||||||
|
|
||||||
/* === Detail Modal === */
|
|
||||||
.detail-title-row { display: flex; align-items: center; gap: 10px; }
|
|
||||||
|
|
||||||
.detail-id {
|
|
||||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-column-badge {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.badge-triage { background: rgba(210,153,34,0.15); color: var(--triage); }
|
|
||||||
.badge-todo { background: rgba(88,166,255,0.15); color: var(--todo); }
|
|
||||||
.badge-in-progress { background: rgba(188,140,255,0.15); color: var(--in-progress); }
|
|
||||||
.badge-in-review { background: rgba(63,185,80,0.15); color: var(--in-review); }
|
|
||||||
.badge-done { background: rgba(139,148,158,0.15); color: var(--done); }
|
|
||||||
|
|
||||||
.detail-body {
|
|
||||||
padding: 20px;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-section { margin-top: 16px; }
|
|
||||||
.detail-section h4 {
|
|
||||||
font-size: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-prompt {
|
|
||||||
padding: 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-deps { margin-top: 16px; }
|
|
||||||
.detail-deps h4 {
|
|
||||||
font-size: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
.detail-dep-list {
|
|
||||||
list-style: none;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.detail-dep-list li {
|
|
||||||
padding: 4px 0;
|
|
||||||
color: var(--todo);
|
|
||||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Toasts === */
|
|
||||||
.toast-container {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 13px;
|
|
||||||
color: #fff;
|
|
||||||
animation: toast-in 0.25s ease-out;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.toast-success { background: #238636; }
|
|
||||||
.toast-error { background: #da3633; }
|
|
||||||
.toast-info { background: #1f6feb; }
|
|
||||||
|
|
||||||
@keyframes toast-in {
|
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Empty state === */
|
|
||||||
.empty-column {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 80px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
margin: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Mobile: horizontal scroll for board columns ===
|
|
||||||
On narrow viewports (≤768px) the board switches from a 5-column grid to a
|
|
||||||
horizontally-scrollable flex layout. Each column gets a minimum width so
|
|
||||||
content remains readable, and snap-scrolling gives a polished swipe feel. */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
html, body {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.board {
|
|
||||||
display: flex;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scroll-snap-type: x mandatory;
|
|
||||||
padding: 12px 12px;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.board > .column {
|
|
||||||
min-width: 280px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
scroll-snap-align: start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,6 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
// 2. Next to process.execPath (bun-compiled binary: dist/kb + dist/client/)
|
// 2. Next to process.execPath (bun-compiled binary: dist/kb + dist/client/)
|
||||||
// 3. __dirname/../dist/client (running from src/ via tsx/ts-node)
|
// 3. __dirname/../dist/client (running from src/ via tsx/ts-node)
|
||||||
// 4. __dirname/../client (running from dist/ after tsc)
|
// 4. __dirname/../client (running from dist/ after tsc)
|
||||||
// 5. __dirname/../public (fallback for dev)
|
|
||||||
const execDir = dirname(process.execPath);
|
const execDir = dirname(process.execPath);
|
||||||
const clientDir = process.env.KB_CLIENT_DIR
|
const clientDir = process.env.KB_CLIENT_DIR
|
||||||
? process.env.KB_CLIENT_DIR
|
? process.env.KB_CLIENT_DIR
|
||||||
@@ -39,9 +38,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
? join(execDir, "client")
|
? join(execDir, "client")
|
||||||
: existsSync(join(__dirname, "..", "dist", "client"))
|
: existsSync(join(__dirname, "..", "dist", "client"))
|
||||||
? join(__dirname, "..", "dist", "client")
|
? join(__dirname, "..", "dist", "client")
|
||||||
: existsSync(join(__dirname, "..", "client"))
|
: join(__dirname, "..", "client");
|
||||||
? join(__dirname, "..", "client")
|
|
||||||
: join(__dirname, "..", "public");
|
|
||||||
|
|
||||||
app.use(express.static(clientDir));
|
app.use(express.static(clientDir));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user