super-backlog 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard/backlog-browser.js +98 -0
- package/dist/dashboard/data.js +15 -0
- package/dist/dashboard/hub.js +7 -4
- package/dist/dashboard/render.js +7 -7
- package/dist/dashboard/server.js +0 -76
- package/dist/lib/version-check.js +3 -2
- package/dist/templates/dashboard.html +199 -34
- package/package.json +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// src/dashboard/backlog-browser.ts
|
|
2
|
+
import { request } from 'node:http';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import crossSpawn from 'cross-spawn';
|
|
5
|
+
import { resolveBacklogBin } from '../lib/run.js';
|
|
6
|
+
function defaultSpawn(bin, args, cwd) {
|
|
7
|
+
const child = crossSpawn(bin, args, { cwd, stdio: 'ignore' });
|
|
8
|
+
return child;
|
|
9
|
+
}
|
|
10
|
+
function defaultGetFreePort() {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const srv = createServer();
|
|
13
|
+
srv.once('error', reject);
|
|
14
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
15
|
+
const addr = srv.address();
|
|
16
|
+
const port = addr !== null && typeof addr === 'object' ? addr.port : 0;
|
|
17
|
+
srv.close(() => resolve(port));
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function defaultProbe(url) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const r = request(url, { method: 'GET' }, (res) => {
|
|
24
|
+
res.resume();
|
|
25
|
+
resolve((res.statusCode ?? 500) < 500);
|
|
26
|
+
});
|
|
27
|
+
r.on('error', () => resolve(false));
|
|
28
|
+
r.end();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function sleep(ms) {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
|
+
}
|
|
34
|
+
/** Manages one `backlog browser` process for one project cwd. */
|
|
35
|
+
export function createBrowserManager(cwd, deps = {}) {
|
|
36
|
+
const resolveBin = deps.resolveBin ?? resolveBacklogBin;
|
|
37
|
+
const spawnFn = deps.spawnFn ?? defaultSpawn;
|
|
38
|
+
const getFreePort = deps.getFreePort ?? defaultGetFreePort;
|
|
39
|
+
const probe = deps.probe ?? defaultProbe;
|
|
40
|
+
const timeoutMs = deps.timeoutMs ?? 10_000;
|
|
41
|
+
const intervalMs = deps.intervalMs ?? 200;
|
|
42
|
+
let child = null;
|
|
43
|
+
let url = '';
|
|
44
|
+
let starting = null;
|
|
45
|
+
function alive() {
|
|
46
|
+
return child !== null && child.exitCode === null;
|
|
47
|
+
}
|
|
48
|
+
async function start() {
|
|
49
|
+
const bin = resolveBin(cwd);
|
|
50
|
+
if (!bin)
|
|
51
|
+
return { ok: false, code: 503, message: 'backlog cli not found' };
|
|
52
|
+
let port;
|
|
53
|
+
try {
|
|
54
|
+
port = await getFreePort();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { ok: false, code: 500, message: 'no free port for the backlog browser' };
|
|
58
|
+
}
|
|
59
|
+
url = `http://127.0.0.1:${port}/`;
|
|
60
|
+
const spawned = spawnFn(bin, ['browser', '--port', String(port), '--no-open'], cwd);
|
|
61
|
+
let spawnFailed = false;
|
|
62
|
+
spawned.on('error', () => {
|
|
63
|
+
spawnFailed = true;
|
|
64
|
+
if (child === spawned)
|
|
65
|
+
child = null;
|
|
66
|
+
});
|
|
67
|
+
child = spawned;
|
|
68
|
+
const deadline = Date.now() + timeoutMs;
|
|
69
|
+
while (Date.now() < deadline) {
|
|
70
|
+
if (spawnFailed || spawned.exitCode !== null)
|
|
71
|
+
break;
|
|
72
|
+
if (await probe(url))
|
|
73
|
+
return { ok: true, url };
|
|
74
|
+
await sleep(intervalMs);
|
|
75
|
+
}
|
|
76
|
+
spawned.kill();
|
|
77
|
+
if (child === spawned)
|
|
78
|
+
child = null;
|
|
79
|
+
return { ok: false, code: 500, message: 'backlog browser did not start' };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
ensure() {
|
|
83
|
+
if (alive() && url !== '')
|
|
84
|
+
return Promise.resolve({ ok: true, url });
|
|
85
|
+
if (starting === null) {
|
|
86
|
+
starting = start().finally(() => {
|
|
87
|
+
starting = null;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return starting;
|
|
91
|
+
},
|
|
92
|
+
close() {
|
|
93
|
+
if (alive())
|
|
94
|
+
child?.kill();
|
|
95
|
+
child = null;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
package/dist/dashboard/data.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// src/dashboard/data.ts
|
|
2
2
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { basename, join } from 'node:path';
|
|
4
5
|
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
6
|
+
import { isNewerVersion } from '../lib/version-check.js';
|
|
5
7
|
import { readSimpleKeys } from '../lib/yamlmini.js';
|
|
6
8
|
function isRecord(v) {
|
|
7
9
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
@@ -263,6 +265,18 @@ function readProjectIdentity(cwd) {
|
|
|
263
265
|
const description = asString(cfg['description']) ?? asString(pkg?.['description']) ?? '';
|
|
264
266
|
return { name, description };
|
|
265
267
|
}
|
|
268
|
+
function readLatestVersion(home, kitVersion) {
|
|
269
|
+
try {
|
|
270
|
+
const raw = readFileSync(join(home, '.super-backlog', 'version-check.json'), 'utf8');
|
|
271
|
+
const parsed = JSON.parse(raw);
|
|
272
|
+
if (!isRecord(parsed) || typeof parsed.latest !== 'string')
|
|
273
|
+
return null;
|
|
274
|
+
return isNewerVersion(parsed.latest, kitVersion) ? parsed.latest : null;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
266
280
|
export function collectDashboardData(cwd, opts) {
|
|
267
281
|
const today = opts.today && /^\d{4}-\d{2}-\d{2}$/.test(opts.today.trim())
|
|
268
282
|
? opts.today.trim()
|
|
@@ -271,6 +285,7 @@ export function collectDashboardData(cwd, opts) {
|
|
|
271
285
|
project: readProjectIdentity(cwd),
|
|
272
286
|
generatedAt: new Date().toISOString(),
|
|
273
287
|
kitVersion: opts.kitVersion,
|
|
288
|
+
latestVersion: readLatestVersion(opts.home ?? homedir(), opts.kitVersion),
|
|
274
289
|
statuses: [],
|
|
275
290
|
milestones: [],
|
|
276
291
|
tasks: [],
|
package/dist/dashboard/hub.js
CHANGED
|
@@ -4,9 +4,10 @@ import { createServer } from 'node:http';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import process from 'node:process';
|
|
7
|
+
import { createBrowserManager } from './backlog-browser.js';
|
|
7
8
|
import { collectDashboardData } from './data.js';
|
|
8
9
|
import { renderDashboard } from './render.js';
|
|
9
|
-
import { createDebouncedReloader, createReloadBroker,
|
|
10
|
+
import { createDebouncedReloader, createReloadBroker, DASHBOARD_PORT, recursiveWatchSupported, } from './server.js';
|
|
10
11
|
import { atomicWrite } from '../lib/atomic.js';
|
|
11
12
|
import { projectSlug, realpathKey } from '../lib/slug.js';
|
|
12
13
|
import { KIT_VERSION } from '../lib/version.js';
|
|
@@ -86,6 +87,7 @@ export async function startHubServer(opts) {
|
|
|
86
87
|
entry.reloader.cancel();
|
|
87
88
|
entry.broker.close();
|
|
88
89
|
entry.watcher?.close();
|
|
90
|
+
entry.browser.close();
|
|
89
91
|
}
|
|
90
92
|
function register(project) {
|
|
91
93
|
let computed;
|
|
@@ -133,7 +135,7 @@ export async function startHubServer(opts) {
|
|
|
133
135
|
broker,
|
|
134
136
|
reloader,
|
|
135
137
|
watcher: watchBacklog(project.cwd, reloader),
|
|
136
|
-
|
|
138
|
+
browser: createBrowserManager(project.cwd, opts.browserDeps),
|
|
137
139
|
modelApi: createModelApiHandler(project.cwd),
|
|
138
140
|
};
|
|
139
141
|
projects.set(slug, entry);
|
|
@@ -238,8 +240,9 @@ export async function startHubServer(opts) {
|
|
|
238
240
|
}
|
|
239
241
|
if (rest.startsWith('/api/')) {
|
|
240
242
|
req.url = rest;
|
|
241
|
-
if (rest === '/api/
|
|
242
|
-
await entry.
|
|
243
|
+
if (rest === '/api/backlog-browser' && method === 'POST') {
|
|
244
|
+
const result = await entry.browser.ensure();
|
|
245
|
+
sendJson(res, result.ok ? 200 : result.code, result);
|
|
243
246
|
return;
|
|
244
247
|
}
|
|
245
248
|
if (entry.broker.handler(req, res)) {
|
package/dist/dashboard/render.js
CHANGED
|
@@ -5,14 +5,14 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
/** The nine pipeline phases; must stay consistent with src/templates/workflow-block.md. */
|
|
6
6
|
export const PIPELINE_PHASES = [
|
|
7
7
|
{ n: 1, name: 'Idea', gate: 'User states a need; capture it before doing anything else' },
|
|
8
|
-
{ n: 2, name: 'Brainstorming', gate: 'Explore intent, requirements and design before any creative work' },
|
|
8
|
+
{ n: 2, name: 'Brainstorming', gate: 'Explore intent, requirements and design before any creative work', command: '/superpowers:brainstorming' },
|
|
9
9
|
{ n: 3, name: 'Design gate', gate: 'Human approves the design document' },
|
|
10
|
-
{ n: 4, name: 'Spec-to-backlog', gate: 'Decompose the approved design into reviewed tasks with acceptance criteria' },
|
|
11
|
-
{ n: 5, name: 'Review gate', gate: 'Human reviews specs and acceptance criteria before any code exists' },
|
|
12
|
-
{ n: 6, name: 'Plan-before-code', gate: 'A written implementation plan is approved by the human' },
|
|
13
|
-
{ n: 7, name: 'TDD implementation', gate: 'Failing test first, then code; one task per session/PR' },
|
|
14
|
-
{ n: 8, name: 'Verification & final summary', gate: 'Run tests/lint/typecheck; verification evidence before success claims' },
|
|
15
|
-
{ n: 9, name: 'Merge & archive', gate: 'Merge the branch, then close/archive the task via the backlog CLI' },
|
|
10
|
+
{ n: 4, name: 'Spec-to-backlog', gate: 'Decompose the approved design into reviewed tasks with acceptance criteria', command: '/spec-to-backlog' },
|
|
11
|
+
{ n: 5, name: 'Review gate', gate: 'Human reviews specs and acceptance criteria before any code exists', command: '/task-review-gate' },
|
|
12
|
+
{ n: 6, name: 'Plan-before-code', gate: 'A written implementation plan is approved by the human', command: '/superpowers:writing-plans' },
|
|
13
|
+
{ n: 7, name: 'TDD implementation', gate: 'Failing test first, then code; one task per session/PR', command: '/superpowers:subagent-driven-development' },
|
|
14
|
+
{ n: 8, name: 'Verification & final summary', gate: 'Run tests/lint/typecheck; verification evidence before success claims', command: '/superpowers:verification-before-completion' },
|
|
15
|
+
{ n: 9, name: 'Merge & archive', gate: 'Merge the branch, then close/archive the task via the backlog CLI', command: 'backlog task archive <id>' },
|
|
16
16
|
];
|
|
17
17
|
function readTemplate() {
|
|
18
18
|
const here = dirname(fileURLToPath(import.meta.url)); // src/dashboard at dev time, dist/dashboard at runtime
|
package/dist/dashboard/server.js
CHANGED
|
@@ -2,83 +2,7 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { isAbsolute, join } from 'node:path';
|
|
4
4
|
import process from 'node:process';
|
|
5
|
-
import crossSpawn from 'cross-spawn';
|
|
6
|
-
import { resolveBacklogBin } from '../lib/run.js';
|
|
7
5
|
export const DASHBOARD_PORT = 6428;
|
|
8
|
-
const WHITELIST = new Map([
|
|
9
|
-
['browser', ['browser']],
|
|
10
|
-
['board', ['board']],
|
|
11
|
-
]);
|
|
12
|
-
function isRecord(v) {
|
|
13
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
14
|
-
}
|
|
15
|
-
async function readBody(req) {
|
|
16
|
-
const chunks = [];
|
|
17
|
-
for await (const chunk of req) {
|
|
18
|
-
chunks.push(Buffer.from(chunk));
|
|
19
|
-
}
|
|
20
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
21
|
-
}
|
|
22
|
-
/** Safe /api/run handler: only whitelisted backlog subcommands may be spawned. */
|
|
23
|
-
export function createRunApiHandler(cwd) {
|
|
24
|
-
return async (req, res) => {
|
|
25
|
-
if (req.url !== '/api/run') {
|
|
26
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
27
|
-
res.end('not found');
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
if (req.method !== 'POST') {
|
|
31
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
32
|
-
res.end('not found');
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
let body;
|
|
36
|
-
try {
|
|
37
|
-
body = await readBody(req);
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
res.writeHead(400, { 'content-type': 'application/json' });
|
|
41
|
-
res.end(JSON.stringify({ error: 'failed to read body' }));
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
let payload;
|
|
45
|
-
try {
|
|
46
|
-
payload = JSON.parse(body);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
res.writeHead(400, { 'content-type': 'application/json' });
|
|
50
|
-
res.end(JSON.stringify({ error: 'invalid json' }));
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
if (!isRecord(payload) || typeof payload.command !== 'string' || !WHITELIST.has(payload.command)) {
|
|
54
|
-
res.writeHead(400, { 'content-type': 'application/json' });
|
|
55
|
-
res.end(JSON.stringify({ error: 'unknown command' }));
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
const bin = resolveBacklogBin(cwd);
|
|
59
|
-
if (!bin) {
|
|
60
|
-
res.writeHead(503, { 'content-type': 'application/json' });
|
|
61
|
-
res.end(JSON.stringify({ error: 'backlog cli not found' }));
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
const args = WHITELIST.get(payload.command);
|
|
65
|
-
try {
|
|
66
|
-
const child = crossSpawn(bin, args, {
|
|
67
|
-
cwd,
|
|
68
|
-
detached: true,
|
|
69
|
-
stdio: 'ignore',
|
|
70
|
-
});
|
|
71
|
-
child.on('error', () => { });
|
|
72
|
-
child.unref();
|
|
73
|
-
res.writeHead(200, { 'content-type': 'application/json' });
|
|
74
|
-
res.end(JSON.stringify({ ok: true }));
|
|
75
|
-
}
|
|
76
|
-
catch (err) {
|
|
77
|
-
res.writeHead(500, { 'content-type': 'application/json' });
|
|
78
|
-
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
6
|
/** SSE broker: keeps a set of response objects and broadcasts named events. */
|
|
83
7
|
export function createReloadBroker() {
|
|
84
8
|
const clients = new Set();
|
|
@@ -8,7 +8,8 @@ const FETCH_TIMEOUT_MS = 2000;
|
|
|
8
8
|
function cachePath(home) {
|
|
9
9
|
return join(home, '.super-backlog', 'version-check.json');
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
/** Triple-numeric semver compare; non-numeric parts are treated as not newer. */
|
|
12
|
+
export function isNewerVersion(latest, installed) {
|
|
12
13
|
const a = latest.split('.').slice(0, 3).map(Number);
|
|
13
14
|
const b = installed.split('.').slice(0, 3).map(Number);
|
|
14
15
|
if (a.length < 3 || b.length < 3)
|
|
@@ -106,7 +107,7 @@ export async function applyVersionHint(installed, deps) {
|
|
|
106
107
|
if (deps.env.SBL_SKIP_UPDATE_CHECK)
|
|
107
108
|
return;
|
|
108
109
|
const cache = readCache(deps.home);
|
|
109
|
-
if (cache &&
|
|
110
|
+
if (cache && isNewerVersion(cache.latest, installed)) {
|
|
110
111
|
deps.log(`super-backlog ${cache.latest} is available (installed ${installed}). Update: npm i -g super-backlog`);
|
|
111
112
|
}
|
|
112
113
|
if (!cache || isStale(cache.checkedAt, deps.now())) {
|
|
@@ -122,6 +122,38 @@
|
|
|
122
122
|
#task-dialog[open] { animation: none; }
|
|
123
123
|
}
|
|
124
124
|
@keyframes sbl-dialog-in { from { transform: scale(.96); opacity: 0; } to { transform: none; opacity: 1; } }
|
|
125
|
+
|
|
126
|
+
/* ---------- Backlog browser overlay ---------- */
|
|
127
|
+
#backlog-dialog {
|
|
128
|
+
width: 96vw; height: 94vh; max-width: none; max-height: none; padding: 0;
|
|
129
|
+
display: none; flex-direction: column;
|
|
130
|
+
border: 1px solid var(--line-strong); border-radius: 14px;
|
|
131
|
+
background: var(--surface); color: var(--text);
|
|
132
|
+
box-shadow: 0 24px 80px rgba(0,0,0,.55);
|
|
133
|
+
}
|
|
134
|
+
#backlog-dialog[open] { display: flex; animation: sbl-dialog-in .18s ease-out; }
|
|
135
|
+
#backlog-dialog::backdrop { background: rgba(4,7,12,.65); backdrop-filter: blur(2px); }
|
|
136
|
+
@media (prefers-reduced-motion: reduce) {
|
|
137
|
+
#backlog-dialog[open] { animation: none; }
|
|
138
|
+
}
|
|
139
|
+
.backlog-dialog-bar {
|
|
140
|
+
display: flex; align-items: center; gap: 14px; padding: 8px 14px;
|
|
141
|
+
border-bottom: 1px solid var(--line-strong); background: var(--surface-2);
|
|
142
|
+
}
|
|
143
|
+
.backlog-dialog-title { font-weight: 700; font-size: .9rem; }
|
|
144
|
+
.backlog-dialog-bar a {
|
|
145
|
+
margin-left: auto; font-family: var(--mono); font-size: .72rem;
|
|
146
|
+
color: var(--muted); text-decoration: none;
|
|
147
|
+
}
|
|
148
|
+
.backlog-dialog-bar a:hover { color: var(--accent); }
|
|
149
|
+
#backlog-close {
|
|
150
|
+
font: inherit; font-size: 1.1rem; line-height: 1; cursor: pointer;
|
|
151
|
+
color: var(--muted); background: none; border: 1px solid transparent;
|
|
152
|
+
border-radius: 8px; padding: 4px 10px;
|
|
153
|
+
}
|
|
154
|
+
#backlog-close:hover { color: var(--text); border-color: var(--line); }
|
|
155
|
+
#backlog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
156
|
+
#backlog-frame { flex: 1; width: 100%; border: 0; background: var(--bg); }
|
|
125
157
|
.dialog-content { padding: 22px 24px 28px; }
|
|
126
158
|
.detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
|
127
159
|
.detail-id { font-family: var(--mono); color: var(--accent); font-weight: 700; }
|
|
@@ -245,23 +277,75 @@
|
|
|
245
277
|
.spark-axis { fill: var(--dim); font-size: 10px; font-family: var(--mono); }
|
|
246
278
|
|
|
247
279
|
/* ---------- Pipeline stepper ---------- */
|
|
248
|
-
.stepper { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); margin: 4px 0
|
|
249
|
-
.step {
|
|
280
|
+
.stepper { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: 2px; margin: 4px 0 6px; }
|
|
281
|
+
.step {
|
|
282
|
+
position: relative; min-width: 0; padding: 10px 4px 12px; text-align: center;
|
|
283
|
+
background: none; border: 1px solid transparent; border-radius: 10px; cursor: pointer;
|
|
284
|
+
font: inherit; color: inherit; transition: background .15s ease, border-color .15s ease;
|
|
285
|
+
}
|
|
250
286
|
.step::before {
|
|
251
|
-
content: ""; position: absolute; top:
|
|
287
|
+
content: ""; position: absolute; top: 27px; left: -50%; width: 100%; height: 2px;
|
|
252
288
|
background: var(--line-strong); z-index: 0;
|
|
253
289
|
}
|
|
254
290
|
.step:first-child::before { display: none; }
|
|
291
|
+
.step:hover { background: var(--surface-2); border-color: var(--line); }
|
|
292
|
+
.step:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
293
|
+
.step[aria-expanded="true"] { background: var(--accent-dim); border-color: var(--accent); }
|
|
294
|
+
.step[aria-expanded="true"].gate { border-color: var(--warn); background: var(--warn-bg); }
|
|
255
295
|
.step-num {
|
|
256
|
-
position: relative; z-index: 1; width:
|
|
296
|
+
position: relative; z-index: 1; width: 34px; height: 34px; margin: 0 auto 10px;
|
|
257
297
|
display: flex; align-items: center; justify-content: center;
|
|
258
|
-
border-radius: 50%; font-family: var(--mono); font-size: .
|
|
298
|
+
border-radius: 50%; font-family: var(--mono); font-size: .9rem; font-weight: 700;
|
|
259
299
|
background: var(--surface-2); border: 2px solid var(--line-strong); color: var(--muted);
|
|
260
300
|
}
|
|
261
301
|
.step.gate .step-num { border-color: var(--warn); color: var(--warn); background: var(--warn-bg); box-shadow: 0 0 14px rgba(255,180,84,.25); }
|
|
262
|
-
.step-label {
|
|
263
|
-
|
|
264
|
-
|
|
302
|
+
.step-label {
|
|
303
|
+
display: block; font-size: .8rem; font-weight: 600; color: var(--text); line-height: 1.3;
|
|
304
|
+
overflow-wrap: break-word;
|
|
305
|
+
}
|
|
306
|
+
.step.gate .step-label { color: var(--warn); }
|
|
307
|
+
|
|
308
|
+
/* ---------- Phase detail panel ---------- */
|
|
309
|
+
#phase-detail {
|
|
310
|
+
margin: 2px 0 12px; padding: 14px 18px; border: 1px solid var(--line-strong);
|
|
311
|
+
border-left: 3px solid var(--accent); border-radius: 10px; background: var(--surface);
|
|
312
|
+
}
|
|
313
|
+
#phase-detail.open { animation: phasein .18s ease; }
|
|
314
|
+
#phase-detail.gate { border-left-color: var(--warn); }
|
|
315
|
+
@keyframes phasein { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
316
|
+
.phase-detail-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
|
|
317
|
+
.phase-detail-num { font-family: var(--mono); font-size: .85rem; font-weight: 700; color: var(--accent); }
|
|
318
|
+
#phase-detail.gate .phase-detail-num { color: var(--warn); }
|
|
319
|
+
.phase-detail-name { font-size: 1rem; font-weight: 700; }
|
|
320
|
+
.phase-detail-gate { margin: 0 0 10px; font-size: .88rem; line-height: 1.55; color: var(--muted); }
|
|
321
|
+
.phase-cmd {
|
|
322
|
+
display: inline-flex; align-items: center; gap: 10px; padding: 7px 12px;
|
|
323
|
+
font: inherit; cursor: pointer; border-radius: 8px;
|
|
324
|
+
background: var(--surface-2); border: 1px solid var(--line-strong); color: var(--text);
|
|
325
|
+
transition: border-color .15s ease;
|
|
326
|
+
}
|
|
327
|
+
.phase-cmd:hover { border-color: var(--accent); }
|
|
328
|
+
.phase-cmd:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
329
|
+
.phase-cmd .cmd-line { font-family: var(--mono); font-size: .8rem; color: var(--accent); }
|
|
330
|
+
.phase-cmd .cmd-title { font-size: .72rem; color: var(--dim); text-transform: uppercase; letter-spacing: .06em; }
|
|
331
|
+
@media (prefers-reduced-motion: reduce) {
|
|
332
|
+
.step, .phase-cmd { transition: none; }
|
|
333
|
+
#phase-detail.open { animation: none; }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* ---------- Sidebar version + update badge ---------- */
|
|
337
|
+
.side-version {
|
|
338
|
+
display: flex; align-items: center; gap: 8px; margin: 6px 0 2px;
|
|
339
|
+
font-family: var(--mono); font-size: .78rem; color: var(--dim);
|
|
340
|
+
}
|
|
341
|
+
.update-badge {
|
|
342
|
+
font: inherit; font-family: var(--mono); font-size: .68rem; cursor: pointer;
|
|
343
|
+
color: var(--warn); background: var(--warn-bg); border: 1px solid var(--warn);
|
|
344
|
+
border-radius: 999px; padding: 2px 9px; transition: box-shadow .15s ease;
|
|
345
|
+
}
|
|
346
|
+
.update-badge:hover { box-shadow: 0 0 10px rgba(255,180,84,.3); }
|
|
347
|
+
.update-badge:focus-visible { outline: 2px solid var(--warn); outline-offset: 2px; }
|
|
348
|
+
.update-badge .cmd-title { font-size: inherit; font-weight: 600; color: inherit; }
|
|
265
349
|
|
|
266
350
|
/* ---------- Dependency flow ---------- */
|
|
267
351
|
.sub-head {
|
|
@@ -302,6 +386,7 @@
|
|
|
302
386
|
<aside class="sbl-side">
|
|
303
387
|
<div class="brand"><span class="brand-glyph">●</span><b>__PROJECT_NAME__</b></div>
|
|
304
388
|
<div class="kicker">SUPERPOWERS × BACKLOG.MD</div>
|
|
389
|
+
<div class="side-version" id="side-version"><span>v__KIT_VERSION__</span></div>
|
|
305
390
|
<nav aria-label="Dashboard sections">
|
|
306
391
|
<a href="#sec-01"><span class="n">01</span>Board & Quick Actions</a>
|
|
307
392
|
<a href="#sec-02"><span class="n">02</span>Status</a>
|
|
@@ -322,14 +407,12 @@
|
|
|
322
407
|
<main>
|
|
323
408
|
|
|
324
409
|
<section id="sec-01">
|
|
325
|
-
<div class="sec-head"><span class="sec-num">01</span><h2>Board & Quick Actions</h2><span class="tagline">
|
|
410
|
+
<div class="sec-head"><span class="sec-num">01</span><h2>Board & Quick Actions</h2><span class="tagline">the full Backlog.md UI · one click away</span></div>
|
|
326
411
|
<div id="quickactions" class="mount">
|
|
327
412
|
<div class="cmd-row" id="cmd-buttons">
|
|
328
|
-
<button type="button" class="cmd-btn"
|
|
329
|
-
<button type="button" class="cmd-btn" data-cmd="board"><span class="cmd-title">Backlog Board</span><span class="cmd-line">backlog board</span></button>
|
|
330
|
-
<button type="button" class="cmd-btn" data-copy="sbl dashboard"><span class="cmd-title">Live Dashboard</span><span class="cmd-line">sbl dashboard</span></button>
|
|
413
|
+
<button type="button" class="cmd-btn" id="backlog-btn"><span class="cmd-title">Backlog</span><span class="cmd-line">board · tasks · docs · decisions</span></button>
|
|
331
414
|
</div>
|
|
332
|
-
<p class="hint">
|
|
415
|
+
<p class="hint">Opens the Backlog.md browser in an overlay — served locally per project, started on demand.</p>
|
|
333
416
|
</div>
|
|
334
417
|
<div id="drafts" class="mount">
|
|
335
418
|
<h3 class="sub-head">Drafts</h3>
|
|
@@ -373,8 +456,9 @@
|
|
|
373
456
|
</section>
|
|
374
457
|
|
|
375
458
|
<section id="sec-05">
|
|
376
|
-
<div class="sec-head"><span class="sec-num">05</span><h2>Feature Cycle</h2><span class="tagline">idea → merge · every <span class="term" data-term="Review Gate">Review Gate</span> included</span></div>
|
|
459
|
+
<div class="sec-head"><span class="sec-num">05</span><h2>Feature Cycle</h2><span class="tagline">idea → merge · every <span class="term" data-term="Review Gate">Review Gate</span> included · click a step for details</span></div>
|
|
377
460
|
<div id="stepper" class="mount"></div>
|
|
461
|
+
<div id="phase-detail" hidden></div>
|
|
378
462
|
<h3 class="sub-head">Flow</h3>
|
|
379
463
|
<div id="depgraph" class="mount"></div>
|
|
380
464
|
</section>
|
|
@@ -399,6 +483,15 @@
|
|
|
399
483
|
</div>
|
|
400
484
|
|
|
401
485
|
<dialog id="task-dialog" aria-label="Task details"></dialog>
|
|
486
|
+
|
|
487
|
+
<dialog id="backlog-dialog" aria-label="Backlog browser">
|
|
488
|
+
<div class="backlog-dialog-bar">
|
|
489
|
+
<span class="backlog-dialog-title">Backlog</span>
|
|
490
|
+
<a id="backlog-open-tab" href="#" target="_blank" rel="noopener">open in new tab</a>
|
|
491
|
+
<button type="button" id="backlog-close" aria-label="Close">×</button>
|
|
492
|
+
</div>
|
|
493
|
+
<iframe id="backlog-frame" title="Backlog.md browser"></iframe>
|
|
494
|
+
</dialog>
|
|
402
495
|
<div id="sbl-tip" role="tooltip" hidden></div>
|
|
403
496
|
|
|
404
497
|
<script type="application/json" id="sbl-data">__SBL_DATA_JSON__</script>
|
|
@@ -421,6 +514,16 @@
|
|
|
421
514
|
badge.textContent = data.source === 'fallback-empty' ? 'no live data' : 'live backlog data';
|
|
422
515
|
}
|
|
423
516
|
|
|
517
|
+
var sideVersion = $('#side-version');
|
|
518
|
+
if (sideVersion && data.latestVersion) {
|
|
519
|
+
var upd = el('button', 'update-badge');
|
|
520
|
+
upd.type = 'button';
|
|
521
|
+
upd.appendChild(el('span', 'cmd-title', 'v' + data.latestVersion + ' available'));
|
|
522
|
+
upd.setAttribute('data-tip', 'Update: npm i -g super-backlog (click to copy)');
|
|
523
|
+
upd.addEventListener('click', function () { copyCommand(upd, 'npm i -g super-backlog'); });
|
|
524
|
+
sideVersion.appendChild(upd);
|
|
525
|
+
}
|
|
526
|
+
|
|
424
527
|
var KEYS = ['id', 'title', 'status', 'milestone', 'priority', 'assignee', 'updated'];
|
|
425
528
|
var state = { key: 'id', dir: 1, query: '', status: null, hoverStatus: null };
|
|
426
529
|
function field(task, key) {
|
|
@@ -511,26 +614,51 @@
|
|
|
511
614
|
/* ---------- Quick action buttons ---------- */
|
|
512
615
|
function cmdFeedback(btn, text) {
|
|
513
616
|
var title = btn.querySelector('.cmd-title');
|
|
514
|
-
if (!title
|
|
515
|
-
btn.
|
|
516
|
-
|
|
617
|
+
if (!title) return;
|
|
618
|
+
if (btn.__sblFbTimer) {
|
|
619
|
+
clearTimeout(btn.__sblFbTimer); /* newer feedback overrides the pending one */
|
|
620
|
+
} else {
|
|
621
|
+
btn.__sblFbOrig = title.textContent;
|
|
622
|
+
}
|
|
517
623
|
title.textContent = text;
|
|
518
|
-
setTimeout(function () {
|
|
624
|
+
btn.__sblFbTimer = setTimeout(function () {
|
|
625
|
+
title.textContent = btn.__sblFbOrig;
|
|
626
|
+
btn.__sblFbTimer = null;
|
|
627
|
+
}, 1200);
|
|
519
628
|
}
|
|
520
629
|
function copyCommand(btn, command) {
|
|
521
630
|
if (navigator.clipboard) navigator.clipboard.writeText(command).catch(function () {});
|
|
522
631
|
cmdFeedback(btn, 'copied \u2713');
|
|
523
632
|
}
|
|
524
|
-
document.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
633
|
+
var backlogBtn = document.getElementById('backlog-btn');
|
|
634
|
+
var backlogDialog = document.getElementById('backlog-dialog');
|
|
635
|
+
var backlogFrame = document.getElementById('backlog-frame');
|
|
636
|
+
var backlogTab = document.getElementById('backlog-open-tab');
|
|
637
|
+
function openBacklog(url) {
|
|
638
|
+
if (!backlogDialog || !backlogFrame) return;
|
|
639
|
+
if (backlogFrame.getAttribute('src') !== url) backlogFrame.setAttribute('src', url);
|
|
640
|
+
if (backlogTab) backlogTab.setAttribute('href', url);
|
|
641
|
+
backlogDialog.showModal();
|
|
642
|
+
}
|
|
643
|
+
if (backlogBtn) {
|
|
644
|
+
backlogBtn.addEventListener('click', function () {
|
|
645
|
+
cmdFeedback(backlogBtn, 'starting\u2026');
|
|
646
|
+
fetch('api/backlog-browser', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
647
|
+
.then(function (res) { return res.json().then(function (data) { return { ok: res.ok, data: data }; }); })
|
|
648
|
+
.then(function (r) {
|
|
649
|
+
if (!r.ok || !r.data || !r.data.url) throw new Error('start failed');
|
|
650
|
+
openBacklog(r.data.url);
|
|
651
|
+
})
|
|
652
|
+
.catch(function () { cmdFeedback(backlogBtn, 'failed \u2717'); });
|
|
532
653
|
});
|
|
533
|
-
}
|
|
654
|
+
}
|
|
655
|
+
var backlogClose = document.getElementById('backlog-close');
|
|
656
|
+
if (backlogClose && backlogDialog) {
|
|
657
|
+
backlogClose.addEventListener('click', function () { backlogDialog.close(); });
|
|
658
|
+
backlogDialog.addEventListener('click', function (e) {
|
|
659
|
+
if (e.target === backlogDialog) backlogDialog.close();
|
|
660
|
+
});
|
|
661
|
+
}
|
|
534
662
|
|
|
535
663
|
/* ---------- Drafts ---------- */
|
|
536
664
|
function renderDrafts(drafts) {
|
|
@@ -702,23 +830,60 @@
|
|
|
702
830
|
mount.appendChild(svg);
|
|
703
831
|
}
|
|
704
832
|
|
|
833
|
+
function renderPhaseDetail(panel, p, isGate) {
|
|
834
|
+
panel.textContent = '';
|
|
835
|
+
panel.classList.toggle('gate', isGate);
|
|
836
|
+
var head = el('div', 'phase-detail-head');
|
|
837
|
+
head.appendChild(el('span', 'phase-detail-num', String(p.n).padStart(2, '0')));
|
|
838
|
+
head.appendChild(el('span', 'phase-detail-name', p.name));
|
|
839
|
+
panel.appendChild(head);
|
|
840
|
+
panel.appendChild(el('p', 'phase-detail-gate', p.gate));
|
|
841
|
+
if (p.command) {
|
|
842
|
+
var cmd = el('button', 'phase-cmd');
|
|
843
|
+
cmd.type = 'button';
|
|
844
|
+
cmd.appendChild(el('span', 'cmd-line', p.command));
|
|
845
|
+
cmd.appendChild(el('span', 'cmd-title', 'copy'));
|
|
846
|
+
cmd.addEventListener('click', function () { copyCommand(cmd, p.command); });
|
|
847
|
+
panel.appendChild(cmd);
|
|
848
|
+
}
|
|
849
|
+
panel.hidden = false;
|
|
850
|
+
panel.classList.remove('open');
|
|
851
|
+
void panel.offsetWidth; /* restart the open animation on re-render */
|
|
852
|
+
panel.classList.add('open');
|
|
853
|
+
}
|
|
854
|
+
|
|
705
855
|
function renderStepper(mount, phases) {
|
|
706
856
|
mount.textContent = '';
|
|
707
857
|
if (!phases || phases.length === 0) {
|
|
708
858
|
mount.appendChild(el('p', 'hint', 'Pipeline unavailable.'));
|
|
709
859
|
return;
|
|
710
860
|
}
|
|
861
|
+
var panel = document.getElementById('phase-detail');
|
|
711
862
|
var wrap = el('div', 'stepper');
|
|
863
|
+
var current = null;
|
|
712
864
|
phases.forEach(function (p) {
|
|
713
|
-
var
|
|
865
|
+
var isGate = /gate/i.test(String(p.name));
|
|
866
|
+
var step = el('button', 'step' + (isGate ? ' gate' : ''));
|
|
867
|
+
step.type = 'button';
|
|
714
868
|
step.setAttribute('data-phase', p.n);
|
|
869
|
+
step.setAttribute('aria-expanded', 'false');
|
|
870
|
+
step.setAttribute('aria-controls', 'phase-detail');
|
|
715
871
|
step.appendChild(el('div', 'step-num', String(p.n)));
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
872
|
+
step.appendChild(el('span', 'step-label', p.name));
|
|
873
|
+
step.addEventListener('click', function () {
|
|
874
|
+
if (!panel) return;
|
|
875
|
+
var wasOpen = current === step;
|
|
876
|
+
wrap.querySelectorAll('.step').forEach(function (s) { s.setAttribute('aria-expanded', 'false'); });
|
|
877
|
+
if (wasOpen) {
|
|
878
|
+
panel.hidden = true;
|
|
879
|
+
panel.classList.remove('open');
|
|
880
|
+
current = null;
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
step.setAttribute('aria-expanded', 'true');
|
|
884
|
+
current = step;
|
|
885
|
+
renderPhaseDetail(panel, p, isGate);
|
|
886
|
+
});
|
|
722
887
|
wrap.appendChild(step);
|
|
723
888
|
});
|
|
724
889
|
mount.appendChild(wrap);
|