super-backlog 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,6 +52,7 @@ After installation:
52
52
 
53
53
  ```bash
54
54
  npm run board # open the Backlog.md kanban board
55
+ sbl serve # dashboard server + Backlog browser with live reload
55
56
  sbl dashboard --serve # live Project Dashboard on http://localhost:6428
56
57
  ```
57
58
 
@@ -74,7 +75,7 @@ sbl dashboard --serve # live Project Dashboard on http://localhost:6
74
75
  | `.git/hooks/post-commit` | dashboard freshness block — regenerates `dashboard.html` after commits that touch `backlog/` (default; opt out with `--no-refresh-hook`) | appended marker block |
75
76
  | `.git/hooks/pre-commit` | integrity guard hook — only with `--guard` (opt-in) | appended marker block |
76
77
 
77
- Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--serve] [--port <n>] [--no-open] [--out <file>]`. See `sbl help` for every flag.
78
+ Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--serve] [--port <n>] [--no-open] [--out <file>]` · `sbl serve [--port <n>] [--no-open] [--out <file>]`. See `sbl help` for every flag.
78
79
 
79
80
  ## Model router (opt-in)
80
81
 
@@ -98,7 +99,7 @@ The router is fully owned by super-backlog and removed by `sbl uninstall`. See t
98
99
 
99
100
  ## Project Dashboard
100
101
 
101
- `sbl dashboard` generates a single self-contained `dashboard.html`: a dark, HTS-style cockpit rendered from your Backlog data in seven sections — Board & Quick Actions, Status (donut), Milestones, Tasks (sortable/filterable table with a click-in detail panel per task), Feature Cycle (pipeline stepper), Activity (30-day sparkline), and Decisions & Docs. A layered dependency graph maps task `depends-on` relations (cycle- and dangling-ref-tolerant): hover highlights edges, click opens the task. Glossary tooltips explain domain terms inline; extend or override them project-wide via `backlog/docs/glossary.md` (`## Term` heading plus the text below it). No CDNs, no external fonts — works offline, diffs cleanly in git, hostable anywhere. Use `--serve` for live mode: it watches `backlog/`, regenerates on change, and serves on port 6428 by default.
102
+ `sbl dashboard` generates a single self-contained `dashboard.html`: a dark, HTS-style cockpit rendered from your Backlog data in seven sections — Board & Quick Actions, Status (donut), Milestones, Tasks (sortable/filterable table; click a row to open a modal detail view with acceptance criteria and dependencies), Feature Cycle (pipeline stepper plus an Up Next / Blocked flow view from task dependencies), Activity (30-day sparkline), and Decisions & Docs. Glossary tooltips explain domain terms inline; extend or override them project-wide via `backlog/docs/glossary.md` (`## Term` heading plus the text below it). No CDNs, no external fonts — works offline, diffs cleanly in git, hostable anywhere. Use `sbl dashboard --serve` for live mode: it watches `backlog/`, regenerates on change, and serves on port 6428; connected browser tabs reload automatically via Server-Sent Events. `sbl serve` starts the dashboard server and the Backlog browser together.
102
103
 
103
104
  ### Keeping it fresh
104
105
 
package/dist/cli.js CHANGED
@@ -3,9 +3,11 @@
3
3
  import { parseArgs } from 'node:util';
4
4
  import process from 'node:process';
5
5
  import { runDashboard } from './commands/dashboard.js';
6
+ import { runBacklogSubcommand } from './commands/backlog-alias.js';
6
7
  import { runDoctor } from './commands/doctor.js';
7
8
  import { runInit } from './commands/init.js';
8
9
  import { runModels } from './commands/models.js';
10
+ import { runServe } from './commands/serve.js';
9
11
  import { runUninstall } from './commands/uninstall.js';
10
12
  import { runUpdate } from './commands/update.js';
11
13
  import { assertNode20, KIT_VERSION } from './lib/version.js';
@@ -18,6 +20,9 @@ Commands:
18
20
  uninstall Remove kit-managed files (project data kept unless --with-backlog)
19
21
  update Refresh kit-managed files and report upstream versions
20
22
  dashboard Generate the single-file project dashboard (--serve for live mode)
23
+ serve Start dashboard server and the Backlog browser together
24
+ browser Open the Backlog.md browser (delegates to backlog browser)
25
+ board Show the Backlog.md board (delegates to backlog board)
21
26
  models Manage the model router (show, enable, disable, discover)
22
27
  doctor Check the environment (node, PowerShell policy, backlog CLI)
23
28
 
@@ -45,6 +50,10 @@ dashboard options:
45
50
  --no-open With --serve: do not open the browser automatically
46
51
  --out <file> Output file name or path (default: dashboard.html)
47
52
 
53
+ serve options:
54
+ --port <n> Port for the dashboard server (default: 6428)
55
+ --no-open Do not open the dashboard browser automatically
56
+
48
57
  doctor options:
49
58
  (none) Prints one [ok]/[warn]/[skip] line per check; exit 4 on any warn
50
59
 
@@ -120,6 +129,25 @@ async function main(argv) {
120
129
  positionals: parsed.positionals,
121
130
  });
122
131
  }
132
+ case 'serve': {
133
+ const parsed = parseArgs({
134
+ args: rest,
135
+ allowPositionals: true,
136
+ options: {
137
+ port: { type: 'string' },
138
+ 'no-open': { type: 'boolean' },
139
+ out: { type: 'string' },
140
+ },
141
+ });
142
+ return await runServe(process.cwd(), {
143
+ values: parsed.values,
144
+ positionals: parsed.positionals,
145
+ });
146
+ }
147
+ case 'browser':
148
+ return await runBacklogSubcommand(process.cwd(), 'browser', rest);
149
+ case 'board':
150
+ return await runBacklogSubcommand(process.cwd(), 'board', rest);
123
151
  case 'models': {
124
152
  const parsed = parseArgs({ args: rest, allowPositionals: true, options: {} });
125
153
  return await runModels(process.cwd(), {
@@ -0,0 +1,21 @@
1
+ // src/commands/backlog-alias.ts
2
+ import { spawn } from 'node:child_process';
3
+ import process from 'node:process';
4
+ import { resolveBacklogBin } from '../lib/run.js';
5
+ /** Run a backlog.md subcommand by delegating to the resolved backlog binary. */
6
+ export function runBacklogSubcommand(cwd, subcommand, args = []) {
7
+ const bin = resolveBacklogBin(cwd);
8
+ if (!bin) {
9
+ console.error('error: backlog CLI not found; is backlog.md installed?');
10
+ return Promise.resolve(1);
11
+ }
12
+ return new Promise((resolve) => {
13
+ const child = spawn(bin, [subcommand, ...args], {
14
+ cwd,
15
+ stdio: 'inherit',
16
+ shell: process.platform === 'win32',
17
+ });
18
+ child.on('error', () => resolve(1));
19
+ child.on('exit', (code) => resolve(code ?? 1));
20
+ });
21
+ }
@@ -0,0 +1,70 @@
1
+ // src/commands/serve.ts
2
+ import { spawn } from 'node:child_process';
3
+ import { isAbsolute, resolve } from 'node:path';
4
+ import process from 'node:process';
5
+ import { collectDashboardData } from '../dashboard/data.js';
6
+ import { renderDashboard } from '../dashboard/render.js';
7
+ import { DASHBOARD_PORT, startServeServer } from '../dashboard/server.js';
8
+ import { atomicWrite } from '../lib/atomic.js';
9
+ import { resolveBacklogBin } from '../lib/run.js';
10
+ import { KIT_VERSION } from '../lib/version.js';
11
+ async function regenerateInto(outPath, cwd) {
12
+ const data = collectDashboardData(cwd, { kitVersion: KIT_VERSION });
13
+ atomicWrite(outPath, renderDashboard(data));
14
+ }
15
+ function spawnBacklogBrowser(cwd, port) {
16
+ const bin = resolveBacklogBin(cwd);
17
+ if (!bin) {
18
+ console.warn('warning: backlog CLI not found; dashboard will serve without the Backlog browser');
19
+ return;
20
+ }
21
+ const url = `http://127.0.0.1:${port}/`;
22
+ try {
23
+ const child = spawn(bin, ['browser', '--no-open', '--non-interactive'], {
24
+ cwd,
25
+ detached: true,
26
+ stdio: 'ignore',
27
+ shell: process.platform === 'win32',
28
+ });
29
+ child.on('error', () => { });
30
+ child.unref();
31
+ console.log('started Backlog browser (dashboard still serves if browser fails)');
32
+ }
33
+ catch {
34
+ console.warn('warning: failed to start Backlog browser; dashboard still serves');
35
+ }
36
+ }
37
+ /** CLI entry for `sbl serve [--port N] [--no-open]`. */
38
+ export async function runServe(cwd, args) {
39
+ const values = args.values;
40
+ let port = DASHBOARD_PORT;
41
+ if (values['port'] !== undefined) {
42
+ const parsed = Number(values['port']);
43
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
44
+ console.error(`error: invalid --port "${String(values['port'])}" (expected 0-65535)`);
45
+ return 1;
46
+ }
47
+ port = parsed;
48
+ }
49
+ const noOpen = values['no-open'] === true;
50
+ const outFile = values['out'] === undefined ? 'dashboard.html' : String(values['out']);
51
+ const outPath = isAbsolute(outFile) ? outFile : resolve(cwd, outFile);
52
+ try {
53
+ await regenerateInto(outPath, cwd);
54
+ console.log(`dashboard written: ${outPath}`);
55
+ console.log(`serving dashboard at http://127.0.0.1:${port}/ (press Ctrl+C to stop)`);
56
+ // Start backlog browser in parallel; don't await so the dashboard server can listen immediately.
57
+ spawnBacklogBrowser(cwd, port);
58
+ await startServeServer(cwd, {
59
+ port,
60
+ file: outPath,
61
+ regenerate: () => regenerateInto(outPath, cwd),
62
+ openBrowser: !noOpen,
63
+ });
64
+ return 0;
65
+ }
66
+ catch (err) {
67
+ console.error(`error: serve failed (${err instanceof Error ? err.message : String(err)})`);
68
+ return 1;
69
+ }
70
+ }
@@ -1,5 +1,5 @@
1
1
  // src/dashboard/data.ts
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
3
  import { basename, join } from 'node:path';
4
4
  import { resolveBacklogBin, runCapture } from '../lib/run.js';
5
5
  import { readSimpleKeys } from '../lib/yamlmini.js';
@@ -216,6 +216,29 @@ function readProjectGlossary(cwd) {
216
216
  return [];
217
217
  }
218
218
  }
219
+ function readDraftFile(path) {
220
+ const keys = readSimpleKeys(path, ['id', 'title', 'status']);
221
+ const id = asString(keys.id);
222
+ const title = asString(keys.title);
223
+ const status = asString(keys.status);
224
+ if (!id || !title || !status)
225
+ return null;
226
+ return { id, title, status };
227
+ }
228
+ export function readDrafts(cwd) {
229
+ const draftsDir = join(cwd, 'backlog', 'drafts');
230
+ if (!existsSync(draftsDir))
231
+ return [];
232
+ const out = [];
233
+ for (const entry of readdirSync(draftsDir, { withFileTypes: true })) {
234
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
235
+ continue;
236
+ const draft = readDraftFile(join(draftsDir, entry.name));
237
+ if (draft)
238
+ out.push(draft);
239
+ }
240
+ return out.sort((a, b) => a.id.localeCompare(b.id));
241
+ }
219
242
  function readProjectIdentity(cwd) {
220
243
  const cfg = readSimpleKeys(join(cwd, 'backlog', 'config.yml'), [
221
244
  'project_name',
@@ -252,6 +275,7 @@ export function collectDashboardData(cwd, opts) {
252
275
  milestones: [],
253
276
  tasks: [],
254
277
  deps: [],
278
+ drafts: readDrafts(cwd),
255
279
  activity: computeActivity([], today),
256
280
  glossary: mergeGlossary(readProjectGlossary(cwd)),
257
281
  source: 'fallback-empty',
@@ -6,7 +6,177 @@ import { createServer } from 'node:http';
6
6
  import { isAbsolute, join } from 'node:path';
7
7
  import process from 'node:process';
8
8
  import { createModelApiHandler } from '../models/dashboard-api.js';
9
+ import { resolveBacklogBin } from '../lib/run.js';
9
10
  export const DASHBOARD_PORT = 6428;
11
+ const WHITELIST = new Map([
12
+ ['browser', ['browser']],
13
+ ['board', ['board']],
14
+ ]);
15
+ function isRecord(v) {
16
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
17
+ }
18
+ async function readBody(req) {
19
+ const chunks = [];
20
+ for await (const chunk of req) {
21
+ chunks.push(Buffer.from(chunk));
22
+ }
23
+ return Buffer.concat(chunks).toString('utf8');
24
+ }
25
+ /** Safe /api/run handler: only whitelisted backlog subcommands may be spawned. */
26
+ export function createRunApiHandler(cwd) {
27
+ return async (req, res) => {
28
+ if (req.method !== 'POST' || req.url !== '/api/run') {
29
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
30
+ res.end('not found');
31
+ return;
32
+ }
33
+ let body;
34
+ try {
35
+ body = await readBody(req);
36
+ }
37
+ catch {
38
+ res.writeHead(400, { 'content-type': 'application/json' });
39
+ res.end(JSON.stringify({ error: 'failed to read body' }));
40
+ return;
41
+ }
42
+ let payload;
43
+ try {
44
+ payload = JSON.parse(body);
45
+ }
46
+ catch {
47
+ res.writeHead(400, { 'content-type': 'application/json' });
48
+ res.end(JSON.stringify({ error: 'invalid json' }));
49
+ return;
50
+ }
51
+ if (!isRecord(payload) || typeof payload.command !== 'string' || !WHITELIST.has(payload.command)) {
52
+ res.writeHead(400, { 'content-type': 'application/json' });
53
+ res.end(JSON.stringify({ error: 'unknown command' }));
54
+ return;
55
+ }
56
+ const bin = resolveBacklogBin(cwd);
57
+ if (!bin) {
58
+ res.writeHead(503, { 'content-type': 'application/json' });
59
+ res.end(JSON.stringify({ error: 'backlog cli not found' }));
60
+ return;
61
+ }
62
+ const args = WHITELIST.get(payload.command);
63
+ try {
64
+ const child = spawn(bin, args, {
65
+ cwd,
66
+ detached: true,
67
+ stdio: 'ignore',
68
+ shell: process.platform === 'win32',
69
+ });
70
+ child.on('error', () => { });
71
+ child.unref();
72
+ res.writeHead(200, { 'content-type': 'application/json' });
73
+ res.end(JSON.stringify({ ok: true }));
74
+ }
75
+ catch (err) {
76
+ res.writeHead(500, { 'content-type': 'application/json' });
77
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
78
+ }
79
+ };
80
+ }
81
+ /** SSE broker: keeps a set of response objects and broadcasts named events. */
82
+ export function createReloadBroker() {
83
+ const clients = new Set();
84
+ let closed = false;
85
+ function handler(req, res) {
86
+ if (req.url !== '/api/events' || req.method !== 'GET')
87
+ return false;
88
+ res.writeHead(200, {
89
+ 'content-type': 'text/event-stream',
90
+ 'cache-control': 'no-cache',
91
+ connection: 'keep-alive',
92
+ });
93
+ res.write(':ok\n\n');
94
+ clients.add(res);
95
+ const cleanup = () => {
96
+ clients.delete(res);
97
+ };
98
+ req.on('close', cleanup);
99
+ req.on('error', cleanup);
100
+ res.on('close', cleanup);
101
+ res.on('error', cleanup);
102
+ return true;
103
+ }
104
+ function broadcast(event) {
105
+ if (closed)
106
+ return;
107
+ // A data line is mandatory: per the HTML standard, EventSource never
108
+ // dispatches an event whose data buffer is empty, so `event: x\n\n`
109
+ // alone would silently never reach addEventListener('x', ...) clients.
110
+ const message = `event: ${event}\ndata: {}\n\n`;
111
+ for (const client of clients) {
112
+ try {
113
+ client.write(message);
114
+ }
115
+ catch {
116
+ clients.delete(client);
117
+ }
118
+ }
119
+ }
120
+ function clientCount() {
121
+ return clients.size;
122
+ }
123
+ function close() {
124
+ if (closed)
125
+ return;
126
+ closed = true;
127
+ for (const client of clients) {
128
+ try {
129
+ client.end();
130
+ }
131
+ catch {
132
+ // ignore
133
+ }
134
+ }
135
+ clients.clear();
136
+ }
137
+ return { handler, broadcast, clientCount, close };
138
+ }
139
+ /** Debounced wrapper around a regenerate callback; on success invokes onReload. */
140
+ export function createDebouncedReloader(regenerate, onReload, delayMs) {
141
+ let timer = null;
142
+ function trigger() {
143
+ if (!regenerate)
144
+ return;
145
+ if (timer !== null)
146
+ clearTimeout(timer);
147
+ timer = setTimeout(() => {
148
+ timer = null;
149
+ void Promise.resolve()
150
+ .then(regenerate)
151
+ .then(() => {
152
+ onReload();
153
+ })
154
+ .catch(() => { });
155
+ }, delayMs);
156
+ }
157
+ function cancel() {
158
+ if (timer !== null) {
159
+ clearTimeout(timer);
160
+ timer = null;
161
+ }
162
+ }
163
+ return { trigger, cancel };
164
+ }
165
+ function createApiHandler(cwd, broker) {
166
+ const modelApi = createModelApiHandler();
167
+ const runApi = createRunApiHandler(cwd);
168
+ return async (req, res) => {
169
+ const url = req.url ?? '/';
170
+ if (url === '/api/run') {
171
+ await runApi(req, res);
172
+ return;
173
+ }
174
+ if (broker.handler(req, res)) {
175
+ return;
176
+ }
177
+ await modelApi(req, res);
178
+ };
179
+ }
10
180
  export function recursiveWatchSupported(platform, nodeVersion) {
11
181
  // Node 24 on Windows triggers a libuv assertion in recursive fs.watch:
12
182
  // https://github.com/nodejs/node/issues/xxx (fs-event.c line 72)
@@ -43,25 +213,14 @@ export async function startServeServer(cwd, opts = {}) {
43
213
  const file = opts.file ?? 'dashboard.html';
44
214
  const filePath = isAbsolute(file) ? file : join(cwd, file);
45
215
  const regenerate = opts.regenerate;
46
- let timer = null;
47
- const debouncedRegenerate = () => {
48
- if (!regenerate)
49
- return;
50
- if (timer !== null)
51
- clearTimeout(timer);
52
- timer = setTimeout(() => {
53
- timer = null;
54
- void Promise.resolve()
55
- .then(regenerate)
56
- .catch(() => { }); // regeneration failures never kill the server
57
- }, 300);
58
- };
216
+ const broker = createReloadBroker();
217
+ const reloader = createDebouncedReloader(regenerate, () => broker.broadcast('reload'), 300);
59
218
  let watcher = null;
60
219
  const backlogDir = join(cwd, 'backlog');
61
220
  if (recursiveWatchSupported(process.platform, process.versions.node)) {
62
221
  try {
63
222
  // recursive so subdirectory writes (e.g. backlog/tasks/*.md) fire on every platform
64
- watcher = watch(backlogDir, { persistent: true, recursive: true }, debouncedRegenerate);
223
+ watcher = watch(backlogDir, { persistent: true, recursive: true }, () => reloader.trigger());
65
224
  watcher.on('error', () => { }); // e.g. watched dir removed mid-session
66
225
  }
67
226
  catch {
@@ -71,10 +230,10 @@ export async function startServeServer(cwd, opts = {}) {
71
230
  else {
72
231
  console.warn('warning: live reload is disabled because Node 24+ on Windows cannot reliably watch directories recursively (libuv fs-event bug); use Node 22 or Linux/macOS for --serve');
73
232
  }
74
- const modelApi = createModelApiHandler();
233
+ const apiHandler = createApiHandler(cwd, broker);
75
234
  const server = createServer((req, res) => {
76
235
  if (req.url?.startsWith('/api/')) {
77
- void modelApi(req, res);
236
+ void apiHandler(req, res);
78
237
  return;
79
238
  }
80
239
  const url = req.url ?? '/';
@@ -114,9 +273,8 @@ export async function startServeServer(cwd, opts = {}) {
114
273
  server,
115
274
  port,
116
275
  close() {
117
- if (timer !== null)
118
- clearTimeout(timer);
119
- timer = null;
276
+ reloader.cancel();
277
+ broker.close();
120
278
  watcher?.close();
121
279
  watcher = null;
122
280
  return new Promise((resolveClose) => {
@@ -4,6 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>__PROJECT_NAME__ &middot; Project Dashboard</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='42' fill='%235cc8ff'/%3E%3C/svg%3E">
7
8
  <style>
8
9
  :root {
9
10
  --bg:#0a0e16;
@@ -26,10 +27,25 @@
26
27
  --danger-bg:#331718;
27
28
  --mono:"Cascadia Code",Consolas,"Courier New",monospace;
28
29
  }
30
+ @font-face {
31
+ font-family: 'Inter';
32
+ src: local('Inter'), local('Inter-Regular');
33
+ font-weight: 400; font-style: normal;
34
+ }
35
+ @font-face {
36
+ font-family: 'Inter';
37
+ src: local('Inter Medium'), local('Inter-Medium');
38
+ font-weight: 500; font-style: normal;
39
+ }
40
+ @font-face {
41
+ font-family: 'Inter';
42
+ src: local('Inter SemiBold'), local('Inter-SemiBold');
43
+ font-weight: 600; font-style: normal;
44
+ }
29
45
  * { box-sizing: border-box; margin: 0; padding: 0; }
30
46
  html { scroll-behavior: smooth; }
31
47
  body {
32
- font-family: "Segoe UI", system-ui, sans-serif;
48
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
33
49
  background:
34
50
  radial-gradient(1100px 500px at 85% -10%, var(--bg-glow-1), transparent 60%),
35
51
  radial-gradient(900px 500px at -10% 30%, var(--bg-glow-2), transparent 55%),
@@ -91,15 +107,22 @@
91
107
  }
92
108
 
93
109
  /* ---------- Detail panel ---------- */
94
- #sbl-backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(4,7,12,.55); }
95
- #sbl-detail {
96
- position: fixed; top: 0; right: 0; z-index: 50; height: 100vh; width: min(400px, 92vw);
97
- overflow-y: auto; padding: 22px 24px 32px;
98
- background: var(--surface); border-left: 1px solid var(--line-strong);
99
- box-shadow: -18px 0 44px rgba(0,0,0,.45);
100
- animation: sbl-slide-in .18s ease-out;
101
- }
102
- @keyframes sbl-slide-in { from { transform: translateX(26px); opacity: 0; } to { transform: none; opacity: 1; } }
110
+ #task-dialog {
111
+ width: min(640px, 92vw); max-height: 85vh; overflow-y: auto; padding: 0;
112
+ border: 1px solid var(--line-strong); border-radius: 14px;
113
+ background: var(--surface); color: var(--text);
114
+ box-shadow: 0 24px 80px rgba(0,0,0,.55);
115
+ }
116
+ #task-dialog::backdrop {
117
+ background: rgba(4,7,12,.65);
118
+ backdrop-filter: blur(2px);
119
+ }
120
+ #task-dialog[open] { animation: sbl-dialog-in .18s ease-out; }
121
+ @media (prefers-reduced-motion: reduce) {
122
+ #task-dialog[open] { animation: none; }
123
+ }
124
+ @keyframes sbl-dialog-in { from { transform: scale(.96); opacity: 0; } to { transform: none; opacity: 1; } }
125
+ .dialog-content { padding: 22px 24px 28px; }
103
126
  .detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
104
127
  .detail-id { font-family: var(--mono); color: var(--accent); font-weight: 700; }
105
128
  .status-chip { font-family: var(--mono); font-size: .68rem; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface-2); color: var(--muted); white-space: nowrap; }
@@ -109,9 +132,12 @@
109
132
  .status-chip[data-tone="danger"] { color: var(--danger); border-color: #5c2c2c; background: var(--danger-bg); }
110
133
  .detail-close { margin-left: auto; background: none; border: none; color: var(--dim); font-size: 1.3rem; cursor: pointer; line-height: 1; }
111
134
  .detail-close:hover { color: var(--danger); }
112
- .detail-title { font-size: 1.06rem; margin-bottom: 8px; overflow-wrap: anywhere; }
113
- #sbl-detail .detail-desc { color: var(--muted); margin-bottom: 14px; white-space: normal; }
114
- #sbl-detail h4 { color: var(--dim); font-size: .7rem; letter-spacing: 1.3px; text-transform: uppercase; margin: 16px 0 6px; }
135
+ .detail-title { font-size: 1.18rem; font-weight: 600; margin-bottom: 8px; overflow-wrap: anywhere; }
136
+ #task-dialog .detail-desc { color: var(--muted); margin-bottom: 14px; white-space: normal; }
137
+ #task-dialog h4 { color: var(--dim); font-size: .7rem; letter-spacing: 1.3px; text-transform: uppercase; margin: 16px 0 6px; }
138
+ .meta-row { display: flex; flex-wrap: wrap; gap: 14px; font-size: .85rem; color: var(--muted); }
139
+ .meta-row span { display: flex; align-items: center; gap: 4px; }
140
+ .meta-label { color: var(--dim); }
115
141
  .dep-row { display: flex; flex-wrap: wrap; gap: 6px; }
116
142
  .dep-link {
117
143
  font-family: var(--mono); font-size: .74rem; padding: 2px 10px; border-radius: 999px;
@@ -137,12 +163,18 @@
137
163
 
138
164
  /* ---------- Quick actions ---------- */
139
165
  .cmd-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
140
- .cmd {
166
+ .cmd-btn {
141
167
  font-family: var(--mono); font-size: .84rem; padding: 6px 14px; border-radius: 999px;
142
168
  background: var(--surface); border: 1px solid var(--line-strong); color: var(--text);
169
+ cursor: pointer;
143
170
  }
144
- .cmd::before { content: "$ "; color: var(--accent); }
171
+ .cmd-btn:hover { background: var(--surface-2); border-color: var(--accent); color: var(--accent); }
172
+ .cmd-btn:active { transform: translateY(1px); }
173
+ .cmd-btn::before { content: "$ "; color: var(--accent); }
174
+ .cmd-btn[data-copy]::after { content: " copy"; font-size: .7em; color: var(--dim); }
145
175
  .hint { color: var(--dim); font-size: .8rem; margin-top: 12px; }
176
+ .drafts-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
177
+ .drafts-list li { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; font-size: .88rem; }
146
178
  code.inline {
147
179
  font-family: var(--mono); font-size: .85em;
148
180
  background: var(--surface-2); border: 1px solid var(--line);
@@ -170,14 +202,8 @@
170
202
  .task-row { cursor: pointer; }
171
203
  .task-row:hover td { background: var(--surface-2); }
172
204
  .cell-id { color: var(--accent); font-family: var(--mono); font-variant-numeric: tabular-nums; }
173
- .cell-title { font-weight: 550; white-space: normal; min-width: 220px; }
205
+ .cell-title { font-weight: 400; white-space: normal; min-width: 220px; }
174
206
  .cell-updated { font-family: var(--mono); color: var(--muted); }
175
- .task-detail td { white-space: normal; background: var(--surface-2); border-top: none; }
176
- .detail-desc { margin: 0 0 8px; color: var(--muted); }
177
- .detail-empty { margin: 0; color: var(--dim); }
178
- .ac-list { margin: 0; padding: 0; list-style: none; }
179
- .ac { display: flex; align-items: baseline; gap: 8px; padding: 2px 0; }
180
- .ac.done span { text-decoration: line-through; color: var(--dim); }
181
207
 
182
208
  /* ---------- Footer ---------- */
183
209
  .footer {
@@ -234,24 +260,26 @@
234
260
  .step-label b { display: block; color: var(--text); font-size: .71rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
235
261
  .step.gate .step-label b { color: var(--warn); }
236
262
 
237
- /* ---------- Dependency graph ---------- */
263
+ /* ---------- Dependency flow ---------- */
238
264
  .sub-head {
239
265
  font-size: .78rem; letter-spacing: 1.4px; text-transform: uppercase;
240
266
  color: var(--dim); margin: 30px 0 12px; font-weight: 600;
241
267
  }
242
- .depgraph-wrap {
243
- overflow-x: auto; background: var(--surface); border: 1px solid var(--line);
244
- border-radius: 12px; padding: 14px;
245
- }
246
- .depgraph { display: block; min-width: 640px; }
247
- .depgraph .edge { fill: none; stroke: var(--line-strong); stroke-width: 1.4; transition: stroke .15s ease, stroke-width .15s ease; }
248
- .depgraph .edge.hot { stroke: var(--accent); stroke-width: 2.2; }
249
- .depgraph .node { cursor: pointer; outline: none; }
250
- .depgraph .node-box { fill: var(--surface-2); stroke: var(--line-strong); transition: stroke .15s ease, filter .15s ease; }
251
- .depgraph .node:hover .node-box, .depgraph .node:focus .node-box { stroke: var(--accent); filter: drop-shadow(0 0 8px rgba(92,200,255,.35)); }
252
- .depgraph .node-id { fill: var(--accent); font-family: var(--mono); font-size: 11px; font-weight: 700; }
253
- .depgraph .node-title { fill: var(--text); font-size: 11px; }
254
- .depgraph .node-status { font-family: var(--mono); font-size: 9px; letter-spacing: .6px; }
268
+ .flow-wrap { display: grid; gap: 20px; }
269
+ .flow-block { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px 18px; }
270
+ .flow-block h4 { margin: 0 0 10px; color: var(--text); font-size: .82rem; font-weight: 500; letter-spacing: .4px; }
271
+ .flow-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
272
+ .flow-card {
273
+ background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 10px;
274
+ padding: 10px 12px; cursor: pointer; transition: background .12s ease, border-color .12s ease;
275
+ }
276
+ .flow-card:hover { background: var(--surface); border-color: var(--accent); }
277
+ .flow-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; }
278
+ .flow-card-id { font-family: var(--mono); color: var(--accent); font-weight: 700; font-size: .78rem; }
279
+ .flow-card-title { color: var(--text); font-size: .92rem; }
280
+ .flow-card-blockers { margin-top: 7px; font-size: .8rem; color: var(--dim); }
281
+ .flow-card-blockers .dep-link { margin-left: 4px; }
282
+ .flow-empty { color: var(--dim); font-size: .85rem; margin: 0; }
255
283
 
256
284
  @media (max-width:900px) {
257
285
  .layout { grid-template-columns: 1fr; }
@@ -293,13 +321,17 @@
293
321
  <section id="sec-01">
294
322
  <div class="sec-head"><span class="sec-num">01</span><h2>Board &amp; Quick Actions</h2><span class="tagline">commands &middot; board mirror only</span></div>
295
323
  <div id="quickactions" class="mount">
296
- <div class="cmd-row">
297
- <code class="cmd">backlog browser</code>
298
- <code class="cmd">backlog board</code>
299
- <code class="cmd">sbl dashboard --serve</code>
324
+ <div class="cmd-row" id="cmd-buttons">
325
+ <button type="button" class="cmd-btn" data-cmd="browser">backlog browser</button>
326
+ <button type="button" class="cmd-btn" data-cmd="board">backlog board</button>
327
+ <button type="button" class="cmd-btn" data-copy="sbl dashboard --serve">sbl dashboard --serve</button>
300
328
  </div>
301
329
  <p class="hint">The board mirrors Backlog.md &mdash; it never writes execution state.</p>
302
330
  </div>
331
+ <div id="drafts" class="mount">
332
+ <h3 class="sub-head">Drafts</h3>
333
+ <ul id="drafts-list" class="drafts-list"></ul>
334
+ </div>
303
335
  </section>
304
336
 
305
337
  <section id="sec-02">
@@ -313,7 +345,7 @@
313
345
  </section>
314
346
 
315
347
  <section id="sec-04">
316
- <div class="sec-head"><span class="sec-num">04</span><h2>Tasks</h2><span class="tagline">sort &middot; filter &middot; expand &mdash; <span class="term" data-term="AC">AC</span>s inline</span></div>
348
+ <div class="sec-head"><span class="sec-num">04</span><h2>Tasks</h2><span class="tagline">sort &middot; filter &middot; click row for details</span></div>
317
349
  <div id="tasks" class="mount">
318
350
  <div class="toolbar">
319
351
  <input id="taskfilter" type="search" placeholder="Filter tasks&hellip;" aria-label="Filter tasks">
@@ -340,7 +372,7 @@
340
372
  <section id="sec-05">
341
373
  <div class="sec-head"><span class="sec-num">05</span><h2>Feature Cycle</h2><span class="tagline">idea &rarr; merge &middot; every <span class="term" data-term="Review Gate">Review Gate</span> included</span></div>
342
374
  <div id="stepper" class="mount"></div>
343
- <h3 class="sub-head">Dependency flow</h3>
375
+ <h3 class="sub-head">Flow</h3>
344
376
  <div id="depgraph" class="mount"></div>
345
377
  </section>
346
378
 
@@ -363,8 +395,7 @@
363
395
  </main>
364
396
  </div>
365
397
 
366
- <div id="sbl-backdrop" hidden></div>
367
- <aside id="sbl-detail" hidden aria-label="Task details"></aside>
398
+ <dialog id="task-dialog" aria-label="Task details"></dialog>
368
399
  <div id="sbl-tip" role="tooltip" hidden></div>
369
400
 
370
401
  <script type="application/json" id="sbl-data">__SBL_DATA_JSON__</script>
@@ -425,37 +456,12 @@
425
456
  }
426
457
  rows.forEach(function (task) {
427
458
  var tr = el('tr', 'task-row');
459
+ tr.setAttribute('data-task', task.id);
428
460
  KEYS.forEach(function (k) {
429
461
  tr.appendChild(el('td', 'cell-' + k, field(task, k)));
430
462
  });
431
- var detail = el('tr', 'task-detail');
432
- var td = el('td');
433
- td.colSpan = KEYS.length;
434
- if (task.description) td.appendChild(el('p', 'detail-desc', task.description));
435
- if (task.acs.length > 0) {
436
- var list = el('ul', 'ac-list');
437
- task.acs.forEach(function (ac) {
438
- var li = el('li', ac.checked ? 'ac done' : 'ac');
439
- var box = document.createElement('input');
440
- box.type = 'checkbox';
441
- box.checked = ac.checked;
442
- box.disabled = true;
443
- li.appendChild(box);
444
- li.appendChild(document.createTextNode(ac.text));
445
- list.appendChild(li);
446
- });
447
- td.appendChild(list);
448
- } else {
449
- td.appendChild(el('p', 'detail-empty', 'No acceptance criteria.'));
450
- }
451
- detail.style.display = 'none';
452
- tr.addEventListener('click', function () {
453
- var opening = detail.style.display === 'none';
454
- detail.style.display = opening ? '' : 'none';
455
- tr.classList.toggle('expanded', opening);
456
- });
463
+ tr.addEventListener('click', function () { openDetail(task.id); });
457
464
  tbody.appendChild(tr);
458
- tbody.appendChild(detail);
459
465
  });
460
466
  }
461
467
  document.querySelectorAll('#tasks-table th').forEach(function (th) {
@@ -499,6 +505,26 @@
499
505
  });
500
506
  renderTasks();
501
507
 
508
+ /* ---------- Quick action buttons ---------- */
509
+ document.querySelectorAll('#cmd-buttons .cmd-btn').forEach(function (b) {
510
+ b.addEventListener('click', function () {
511
+ var copy = b.getAttribute('data-copy');
512
+ if (copy) { navigator.clipboard && navigator.clipboard.writeText(copy).catch(function(){}); return; }
513
+ fetch('/api/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ command: b.getAttribute('data-cmd') }) })
514
+ .catch(function () { navigator.clipboard && navigator.clipboard.writeText(b.textContent || '').catch(function(){}); });
515
+ });
516
+ });
517
+
518
+ /* ---------- Drafts ---------- */
519
+ function renderDrafts(drafts) {
520
+ var list = document.getElementById('drafts-list');
521
+ if (!list) return;
522
+ list.textContent = '';
523
+ if (!drafts || drafts.length === 0) { list.appendChild(el('li', '', 'No drafts.')); return; }
524
+ drafts.forEach(function (d) { list.appendChild(el('li', '', d.id + ' \u2014 ' + d.title)); });
525
+ }
526
+ renderDrafts(data.drafts);
527
+
502
528
  /* ---------- Shared diagram helpers ---------- */
503
529
  var SVGNS = 'http://www.w3.org/2000/svg';
504
530
  var TONE_VAR = { ok: 'var(--ok)', accent: 'var(--accent)', warn: 'var(--warn)', danger: 'var(--danger)', dim: 'var(--dim)' };
@@ -750,9 +776,8 @@
750
776
  document.addEventListener('focusout', tipHide);
751
777
  }
752
778
 
753
- /* ---------- Detail panel ---------- */
754
- var panel = document.getElementById('sbl-detail');
755
- var backdrop = document.getElementById('sbl-backdrop');
779
+ /* ---------- Detail dialog ---------- */
780
+ var dialog = document.getElementById('task-dialog');
756
781
  var depsOut = {};
757
782
  var depsIn = {};
758
783
  data.deps.forEach(function (d) {
@@ -766,11 +791,13 @@
766
791
  });
767
792
  return found;
768
793
  }
794
+ function isDoneStatus(status) {
795
+ var s = String(status).toLowerCase();
796
+ return s === 'done' || s === 'complete' || s === 'completed';
797
+ }
769
798
  function closeDetail() {
770
- if (panel) panel.hidden = true;
771
- if (backdrop) backdrop.hidden = true;
799
+ if (dialog && dialog.open) dialog.close();
772
800
  }
773
- window.closeDetail = closeDetail;
774
801
  function acList(task) {
775
802
  var list = el('ul', 'ac-list');
776
803
  task.acs.forEach(function (ac) {
@@ -792,7 +819,6 @@
792
819
  ids.filter(function (id) { return findTask(id); }).forEach(function (id) {
793
820
  var btn = el('button', 'dep-link', id);
794
821
  btn.type = 'button';
795
- btn.setAttribute('data-dep', id);
796
822
  btn.addEventListener('click', function () { openDetail(id); });
797
823
  row.appendChild(btn);
798
824
  });
@@ -800,10 +826,24 @@
800
826
  block.appendChild(row);
801
827
  return block;
802
828
  }
829
+ function metaRow(task) {
830
+ var row = el('div', 'meta-row');
831
+ function item(label, value) {
832
+ var s = el('span');
833
+ s.appendChild(el('span', 'meta-label', label + ': '));
834
+ s.appendChild(document.createTextNode(value || '—'));
835
+ return s;
836
+ }
837
+ row.appendChild(item('Milestone', task.milestone));
838
+ row.appendChild(item('Priority', task.priority));
839
+ row.appendChild(item('Assignee', task.assignee));
840
+ row.appendChild(item('Updated', task.updated));
841
+ return row;
842
+ }
803
843
  function openDetail(id) {
804
844
  var t = findTask(id);
805
- if (!t || !panel) return;
806
- panel.textContent = '';
845
+ if (!t || !dialog) return;
846
+ var content = el('div', 'dialog-content');
807
847
  var head = el('div', 'detail-head');
808
848
  head.appendChild(el('span', 'detail-id', t.id));
809
849
  var chip = el('span', 'status-chip', t.status);
@@ -814,21 +854,22 @@
814
854
  closeBtn.setAttribute('aria-label', 'Close details');
815
855
  closeBtn.addEventListener('click', closeDetail);
816
856
  head.appendChild(closeBtn);
817
- panel.appendChild(head);
818
- panel.appendChild(el('h3', 'detail-title', t.title));
819
- panel.appendChild(el('p', 'detail-desc', t.description || 'No description.'));
857
+ content.appendChild(head);
858
+ content.appendChild(el('h3', 'detail-title', t.title));
859
+ content.appendChild(el('p', 'detail-desc', t.description || 'No description.'));
860
+ content.appendChild(metaRow(t));
820
861
  if (t.acs.length > 0) {
821
- panel.appendChild(el('h4', '', 'Acceptance criteria'));
822
- panel.appendChild(acList(t));
862
+ content.appendChild(el('h4', '', 'Acceptance criteria'));
863
+ content.appendChild(acList(t));
823
864
  }
824
- panel.appendChild(depSection('Depends on', depsOut[id] || []));
825
- panel.appendChild(depSection('Needed by', depsIn[id] || []));
826
- panel.scrollTop = 0;
827
- panel.hidden = false;
828
- backdrop.hidden = false;
865
+ content.appendChild(depSection('Depends on', depsOut[id] || []));
866
+ content.appendChild(depSection('Needed by', depsIn[id] || []));
867
+ dialog.textContent = '';
868
+ dialog.appendChild(content);
869
+ dialog.showModal();
829
870
  }
830
871
  window.__sblOpenDetail = openDetail;
831
- if (backdrop) backdrop.addEventListener('click', closeDetail);
872
+ if (dialog) dialog.addEventListener('click', function (ev) { if (ev.target === dialog) closeDetail(); });
832
873
 
833
874
  /* ---------- Keyboard shortcuts ---------- */
834
875
  document.addEventListener('keydown', function (ev) {
@@ -847,154 +888,79 @@
847
888
  }
848
889
  });
849
890
 
850
- /* ---------- Dependency graph (mirrors src/dashboard/layering.ts semantics) ---------- */
851
- function assignLayers(nodes, deps) {
852
- var nodeSet = {};
853
- nodes.forEach(function (n) { nodeSet[n] = true; });
854
- var prereqs = {};
855
- nodes.forEach(function (n) { prereqs[n] = []; });
856
- deps.forEach(function (d) {
857
- if (!nodeSet[d.from] || !nodeSet[d.to]) return; // dangling ref
858
- if (prereqs[d.from].indexOf(d.to) === -1) prereqs[d.from].push(d.to);
859
- });
860
- var layers = {};
861
- var remaining = {};
862
- nodes.forEach(function (n) { remaining[n] = true; });
863
- var left = nodes.length;
864
- var current = 0;
865
- function take(n) {
866
- layers[n] = current;
867
- delete remaining[n];
868
- left -= 1;
869
- }
870
- while (left > 0) {
871
- var ready = nodes.filter(function (n) {
872
- if (!remaining[n]) return false;
873
- return prereqs[n].every(function (p) { return !remaining[p]; });
874
- });
875
- current += 1;
876
- if (ready.length === 0) {
877
- // stalled behind a cycle: snapshot self-reachable members first, then
878
- // append them as one group (mirrors cycleMembers() in layering.ts)
879
- var cycleSnap = [];
880
- nodes.forEach(function (start) {
881
- if (!remaining[start]) return;
882
- var seen = {};
883
- var stack = prereqs[start].filter(function (p) { return remaining[p]; });
884
- var found = false;
885
- while (stack.length > 0 && !found) {
886
- var cur = stack.pop();
887
- if (cur === start) { found = true; break; }
888
- if (seen[cur]) continue;
889
- seen[cur] = true;
890
- stack = stack.concat(prereqs[cur].filter(function (p) { return remaining[p]; }));
891
- }
892
- if (found) cycleSnap.push(start);
893
- });
894
- cycleSnap.forEach(take);
895
- if (cycleSnap.length === 0) break; // unreachable safety net
896
- continue;
897
- }
898
- ready.forEach(take);
899
- }
900
- var out = {};
901
- nodes.forEach(function (n) { out[n] = layers[n] || 1; });
902
- return out;
903
- }
904
-
905
- function hotEdges(id, on) {
906
- document.querySelectorAll('.depgraph .edge').forEach(function (edge) {
907
- var hit = edge.getAttribute('data-from') === id || edge.getAttribute('data-to') === id;
908
- edge.classList.toggle('hot', on && hit);
909
- });
910
- }
911
-
912
- function renderDepGraph(mount, tasks, deps) {
891
+ /* ---------- Dependency flow ---------- */
892
+ function renderFlow(mount, tasks) {
913
893
  mount.textContent = '';
914
- if (tasks.length === 0) {
915
- mount.appendChild(el('p', 'hint', 'No tasks to graph.'));
916
- return;
917
- }
918
- var ids = tasks.map(function (t) { return t.id; });
919
- var layers = assignLayers(ids, deps);
920
894
  var byId = {};
921
895
  tasks.forEach(function (t) { byId[t.id] = t; });
922
- var maxLayer = 1;
923
- ids.forEach(function (id) { if (layers[id] > maxLayer) maxLayer = layers[id]; });
924
- var cols = [];
925
- for (var i = 0; i < maxLayer; i++) cols.push([]);
926
- ids.forEach(function (id) { cols[layers[id] - 1].push(byId[id]); });
927
- var CHIP_W = 172, CHIP_H = 40, GAP_X = 84, GAP_Y = 14, PAD = 18;
928
- var rows = 1;
929
- cols.forEach(function (c) { if (c.length > rows) rows = c.length; });
930
- var width = PAD * 2 + cols.length * CHIP_W + (cols.length - 1) * GAP_X;
931
- var height = PAD * 2 + Math.max(rows, 1) * CHIP_H + (Math.max(rows, 1) - 1) * GAP_Y;
932
- var wrap = el('div', 'depgraph-wrap');
933
- var svg = svgEl('svg', {
934
- viewBox: '0 0 ' + width + ' ' + height, width: width, height: height,
935
- 'class': 'depgraph', role: 'img', 'aria-label': 'Task dependency graph'
936
- });
937
- var defs = svgEl('defs');
938
- var marker = svgEl('marker', { id: 'sbl-arrow', markerWidth: '9', markerHeight: '7', refX: '8.5', refY: '3.5', orient: 'auto' });
939
- marker.appendChild(svgEl('polygon', { points: '0 0, 9 3.5, 0 7', fill: 'var(--line-strong)' }));
940
- defs.appendChild(marker);
941
- svg.appendChild(defs);
942
- var pos = {};
943
- cols.forEach(function (col, ci) {
944
- col.forEach(function (t, ri) {
945
- pos[t.id] = {
946
- x: PAD + ci * (CHIP_W + GAP_X),
947
- y: PAD + ri * (CHIP_H + GAP_Y)
948
- };
949
- });
950
- });
951
- deps.forEach(function (d) {
952
- if (!pos[d.from] || !pos[d.to] || d.from === d.to) return;
953
- var a = pos[d.from];
954
- var b = pos[d.to];
955
- var x1 = a.x + CHIP_W, y1 = a.y + CHIP_H / 2, x2 = b.x, y2 = b.y + CHIP_H / 2;
956
- var mx = (x1 + x2) / 2;
957
- var path = svgEl('path', {
958
- 'class': 'edge', 'data-from': d.from, 'data-to': d.to,
959
- d: 'M ' + x1 + ' ' + y1 + ' C ' + mx + ' ' + y1 + ', ' + mx + ' ' + y2 + ', ' + x2 + ' ' + y2,
960
- 'marker-end': 'url(#sbl-arrow)'
896
+ var pending = tasks.filter(function (t) { return !isDoneStatus(t.status); });
897
+ var upNext = [];
898
+ var blocked = [];
899
+ pending.forEach(function (t) {
900
+ var blockers = (depsOut[t.id] || []).filter(function (id) {
901
+ var d = byId[id];
902
+ return d && !isDoneStatus(d.status);
961
903
  });
962
- svg.appendChild(path);
904
+ if (blockers.length === 0) upNext.push({ task: t, blockers: [] });
905
+ else blocked.push({ task: t, blockers: blockers });
963
906
  });
964
- cols.forEach(function (col) {
965
- col.forEach(function (t) {
966
- var p = pos[t.id];
967
- var tone = toneOf(t.status);
968
- var g = svgEl('g', { 'class': 'node', 'data-id': t.id, tabindex: '0', role: 'button', 'aria-label': t.id + ': ' + t.title });
969
- attachTitle(g, t.id + ' \u2014 ' + t.title + ' (' + t.status + ')' + (t.milestone ? ' [' + t.milestone + ']' : ''));
970
- g.appendChild(svgEl('rect', { x: p.x, y: p.y, width: CHIP_W, height: CHIP_H, rx: 9, 'class': 'node-box' }));
971
- g.appendChild(svgEl('rect', { x: p.x, y: p.y + 6, width: 4, height: CHIP_H - 12, rx: 2, fill: TONE_VAR[tone] }));
972
- var idText = svgEl('text', { x: p.x + 13, y: p.y + 17, 'class': 'node-id' });
973
- idText.textContent = t.id;
974
- g.appendChild(idText);
975
- var st = svgEl('text', { x: p.x + CHIP_W - 10, y: p.y + 16, 'text-anchor': 'end', 'class': 'node-status', fill: TONE_VAR[tone] });
976
- st.textContent = String(t.status).toUpperCase();
977
- g.appendChild(st);
978
- var title = svgEl('text', { x: p.x + 13, y: p.y + 31, 'class': 'node-title' });
979
- title.textContent = String(t.title).length > 26 ? String(t.title).slice(0, 25) + '\u2026' : t.title;
980
- g.appendChild(title);
981
- g.addEventListener('mouseenter', function () { hotEdges(t.id, true); });
982
- g.addEventListener('mouseleave', function () { hotEdges(t.id, false); });
983
- g.addEventListener('click', function () { window.__sblOpenDetail(t.id); });
984
- g.addEventListener('keydown', function (ev) {
985
- if (ev.key === 'Enter' || ev.key === ' ') {
986
- ev.preventDefault();
987
- window.__sblOpenDetail(t.id);
907
+ function section(titleText, items, emptyText) {
908
+ var wrap = el('div', 'flow-block');
909
+ wrap.appendChild(el('h4', '', titleText));
910
+ if (items.length === 0) {
911
+ wrap.appendChild(el('p', 'flow-empty', emptyText));
912
+ } else {
913
+ var list = el('ul', 'flow-list');
914
+ items.forEach(function (item) {
915
+ var li = el('li', 'flow-card');
916
+ var head = el('div', 'flow-card-head');
917
+ head.appendChild(el('span', 'flow-card-id', item.task.id));
918
+ var chip = el('span', 'status-chip', item.task.status);
919
+ chip.setAttribute('data-tone', toneOf(item.task.status));
920
+ head.appendChild(chip);
921
+ li.appendChild(head);
922
+ li.appendChild(el('div', 'flow-card-title', item.task.title));
923
+ if (item.blockers.length > 0) {
924
+ var blockRow = el('div', 'flow-card-blockers');
925
+ blockRow.appendChild(document.createTextNode('Blocked by: '));
926
+ item.blockers.forEach(function (id, i) {
927
+ if (i > 0) blockRow.appendChild(document.createTextNode(', '));
928
+ var btn = el('button', 'dep-link', id);
929
+ btn.type = 'button';
930
+ btn.addEventListener('click', function () { openDetail(id); });
931
+ blockRow.appendChild(btn);
932
+ });
933
+ li.appendChild(blockRow);
988
934
  }
935
+ li.addEventListener('click', function (ev) {
936
+ if (ev.target.closest('.dep-link')) return;
937
+ openDetail(item.task.id);
938
+ });
939
+ list.appendChild(li);
989
940
  });
990
- svg.appendChild(g);
991
- });
992
- });
993
- wrap.appendChild(svg);
941
+ wrap.appendChild(list);
942
+ }
943
+ return wrap;
944
+ }
945
+ if (upNext.length === 0 && blocked.length === 0) {
946
+ mount.appendChild(el('p', 'hint', 'No pending tasks — everything is done.'));
947
+ return;
948
+ }
949
+ var wrap = el('div', 'flow-wrap');
950
+ wrap.appendChild(section('Up Next', upNext, 'No tasks ready to start.'));
951
+ wrap.appendChild(section('Blocked', blocked, 'No blocked tasks.'));
994
952
  mount.appendChild(wrap);
995
953
  }
996
954
 
997
- renderDepGraph($('#depgraph'), data.tasks, data.deps);
955
+ renderFlow($('#depgraph'), data.tasks);
956
+ })();
957
+ </script>
958
+ <script>
959
+ (function () {
960
+ if (!window.EventSource || (location.protocol !== 'http:' && location.protocol !== 'https:')) return;
961
+ var es = new EventSource('/api/events');
962
+ es.addEventListener('reload', function () { location.reload(); });
963
+ es.addEventListener('error', function () {});
998
964
  })();
999
965
  </script>
1000
966
  </body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-backlog",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "One command to equip any project with Backlog.md + Superpowers, plus a Project Dashboard.",
5
5
  "license": "MIT",
6
6
  "repository": {