gcf-board 0.2.12 → 0.2.16
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 +3 -1
- package/dist/server/app.js +16 -3
- package/dist/server/moves.js +4 -2
- package/dist/server/runs.js +66 -14
- package/dist/web/assets/index-CjG8ddKQ.css +1 -0
- package/dist/web/assets/index-H8tGnjFM.js +33 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CGFJHQdg.js +0 -31
- package/dist/web/assets/index-CJpKQHTx.css +0 -1
package/README.md
CHANGED
|
@@ -42,12 +42,14 @@ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console s
|
|
|
42
42
|
- `run_mode: worktree`: one worktree and one agent session per item, all at once, then the integrator;
|
|
43
43
|
- `run_mode: main`: one item at a time in the project folder. A run asked for meanwhile waits in the board's **queue**; **remove from queue** takes it out.
|
|
44
44
|
|
|
45
|
-
A run asks first: it spends the subscription. **Stop** on a running item ends its session; the item stays ready and its work is kept. A queued main run that the next one would repeat (the harness did not start, or another run works the checkout) pauses the queue: the header shows **queue paused — resume**; fix the cause, then resume. A card marked **interrupted** had a run that stopped (a timeout, a stop, a crash, the usage limit): **resume** continues its agent session with all its context, **start fresh** drops it. After the usage limit the card reads **paused: limit** and the board resumes it by itself at the reset time; the header shows **resumes HH:MM** with **cancel
|
|
45
|
+
A run asks first: it spends the subscription. **Stop** on a running item ends its session; the item stays ready and its work is kept. A queued main run that the next one would repeat (the harness did not start, or another run works the checkout) pauses the queue: the header shows **queue paused — resume**; fix the cause, then resume. A card marked **interrupted** had a run that stopped (a timeout, a stop, a crash, the usage limit): **resume** continues its agent session with all its context, **start fresh** drops it. After the usage limit the card reads **paused: limit** and the board resumes it by itself at the reset time; the header shows **resumes HH:MM** with **cancel**, and a queue the limit paused resumes only by that clock until the operator cancels it; a pause after a failure keeps its **resume** button. When a run ends, a report card lists each item's result and stays until closed, across page reloads. **What the board did by itself** lists its automatic acts with their times: a usage limit, a context handoff, the queue paused or started, a resume run or cancelled. A run confirmation names what each interrupted item picks up. The queue, the reports and that list live in the board's memory: a board restart forgets them.
|
|
46
|
+
- **Who runs it** (core 0.7.13): a running card tells a console run and **your session** (a `gcf claim` mark) from the board's own run; a session offers **release mark** instead of **stop**. A ready card whose dependencies are not built yet shows **waits for <dep>**; the drawer shows when an item began to wait (`since_at`).
|
|
46
47
|
- **Run next N independent**: the framework's gate picks the ready items that can run together now, by priority, and the board runs them after asking.
|
|
47
48
|
- **Priority**: drop a ready card on another to put it before; the column's ranks are committed (`gcf prioritize`).
|
|
48
49
|
- **Questions**: each D1 shows its question and proposed answer; **Answer** writes yours into its record (`gcf question answer`), and the next agent on its units puts it in place.
|
|
49
50
|
- **New item**: `gcf item new … --commit`. The item's `model` and `effort` go to its run.
|
|
50
51
|
- **Inbox**: add, erase, or turn an entry into an item. Open questions are listed there too.
|
|
52
|
+
- **Lessons** (core 0.7.10, harness 0.9.2): what got in an agent's way, asked at the end of each session, lands in the inbox with its kind (`tool`, `rules`, `framework`, `template`, `other`) and its item. The inbox filters them; a card shows `N lessons`, and the drawer lists the item's lessons with the inbox's buttons. A run's report line ends with `, N lessons`, and with `— code written outside aif-implement` when a session skipped that skill.
|
|
51
53
|
- **Playtest**: `gcf playtest session` with the checklist grouped by subsystem; keep, tune or kill each item from the list. **Build** runs `dod.build`. A note goes to the inbox as `--source playtest`.
|
|
52
54
|
- **Ended**: history (accepted) and graveyard (killed, dropped, won't-fix).
|
|
53
55
|
|
package/dist/server/app.js
CHANGED
|
@@ -14,7 +14,7 @@ import { followLog, readShown, shownShape } from './files.js';
|
|
|
14
14
|
import { HttpError } from './http-error.js';
|
|
15
15
|
import { moveArgs, runIds } from './moves.js';
|
|
16
16
|
import { addProject, listProjects, projectOf, removeProject } from './registry.js';
|
|
17
|
-
import { cancelResume, forgetProject, resumeQueue, runsOf, startRun, unqueue } from './runs.js';
|
|
17
|
+
import { cancelResume, closeReport, forgetProject, resumeQueue, runsOf, startRun, unqueue, } from './runs.js';
|
|
18
18
|
const send = (res, status, body) => {
|
|
19
19
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
20
20
|
res.end(JSON.stringify(body));
|
|
@@ -140,10 +140,15 @@ export function createApp(o) {
|
|
|
140
140
|
// count of plan writes, which tells the page an open plan's text changed.
|
|
141
141
|
const own = { runs: runsOf(p.name), plans_rev: planRev(p.name) };
|
|
142
142
|
const board = await boardOf(p);
|
|
143
|
-
// A running item's last log write tells a working session from a stuck one
|
|
144
|
-
|
|
143
|
+
// A running item's last log write tells a working session from a stuck one; a run's
|
|
144
|
+
// lock is this board's run or a console's.
|
|
145
|
+
const mine = new Set(own.runs.active.flatMap((r) => r.ids));
|
|
146
|
+
for (const i of board.items) {
|
|
145
147
|
if (i.running)
|
|
146
148
|
i.active_at = i.log ? activeAt(path.join(p.path, i.log)) : null;
|
|
149
|
+
if (i.running_kind === 'run')
|
|
150
|
+
i.running_kind = mine.has(i.id) ? 'board' : 'console';
|
|
151
|
+
}
|
|
147
152
|
send(res, 200, { ...board, ...own });
|
|
148
153
|
},
|
|
149
154
|
],
|
|
@@ -249,6 +254,14 @@ export function createApp(o) {
|
|
|
249
254
|
send(res, 200, { ok: true });
|
|
250
255
|
},
|
|
251
256
|
],
|
|
257
|
+
[
|
|
258
|
+
'DELETE',
|
|
259
|
+
/^\/api\/p\/([^/]+)\/reports\/(r\d+)$/,
|
|
260
|
+
async (_req, res, m) => {
|
|
261
|
+
closeReport((await projectOf(m[1])).name, m[2]);
|
|
262
|
+
send(res, 200, { ok: true });
|
|
263
|
+
},
|
|
264
|
+
],
|
|
252
265
|
[
|
|
253
266
|
'DELETE',
|
|
254
267
|
/^\/api\/p\/([^/]+)\/queue\/([a-z0-9][a-z0-9-]*)$/,
|
package/dist/server/moves.js
CHANGED
|
@@ -60,8 +60,8 @@ const NEW_ITEM_OPTIONS = [
|
|
|
60
60
|
'effort',
|
|
61
61
|
];
|
|
62
62
|
const MOVES = {
|
|
63
|
-
submit: (b) => ['submit', id(b)],
|
|
64
|
-
close: (b) => ['close', id(b), ...many(b, 'constraint')],
|
|
63
|
+
submit: (b) => ['submit', id(b), ...opt(b, 'unverified')],
|
|
64
|
+
close: (b) => ['close', id(b), ...many(b, 'constraint'), ...opt(b, 'unverified')],
|
|
65
65
|
verdict: (b) => [
|
|
66
66
|
'verdict',
|
|
67
67
|
id(b),
|
|
@@ -80,6 +80,8 @@ const MOVES = {
|
|
|
80
80
|
],
|
|
81
81
|
block: (b) => ['block', id(b), `--reason=${required(b, 'reason')}`],
|
|
82
82
|
unblock: (b) => ['unblock', id(b), ...opt(b, 'note')],
|
|
83
|
+
// A session's mark taken off: the item no longer shows running (Board-PRD §3.2, kind c).
|
|
84
|
+
release: (b) => ['release', id(b)],
|
|
83
85
|
drop: (b) => [
|
|
84
86
|
'drop',
|
|
85
87
|
id(b),
|
package/dist/server/runs.js
CHANGED
|
@@ -19,6 +19,22 @@ const scheduled = new Map();
|
|
|
19
19
|
const limitPaused = new Set();
|
|
20
20
|
// Runs going on for a project the operator removed: their end touches no project state.
|
|
21
21
|
const forgotten = new WeakSet();
|
|
22
|
+
// shortcut: reports and acts live in memory, like the queue: a board restart forgets them;
|
|
23
|
+
// persist them with the queue if restarts become common.
|
|
24
|
+
const reports = new Map();
|
|
25
|
+
const acts = new Map();
|
|
26
|
+
const KEEP_REPORTS = 20;
|
|
27
|
+
const KEEP_ACTS = 50;
|
|
28
|
+
let reportSeq = 0;
|
|
29
|
+
function trace(project, act, text) {
|
|
30
|
+
const list = [...(acts.get(project) ?? []), { at: new Date().toISOString(), act, text }];
|
|
31
|
+
acts.set(project, list.slice(-KEEP_ACTS));
|
|
32
|
+
}
|
|
33
|
+
/** The operator closed a run report. */
|
|
34
|
+
export function closeReport(project, id) {
|
|
35
|
+
reports.set(project, (reports.get(project) ?? []).filter((r) => r.id !== id));
|
|
36
|
+
broadcast({ type: 'board', project });
|
|
37
|
+
}
|
|
22
38
|
export const activeRuns = (project) => [...active].filter((r) => r.project === project);
|
|
23
39
|
/** What runs and what waits, for a project; a paused queue waits for `resumeQueue`. */
|
|
24
40
|
export function runsOf(project) {
|
|
@@ -27,8 +43,12 @@ export function runsOf(project) {
|
|
|
27
43
|
active: activeRuns(project),
|
|
28
44
|
queued: (queues.get(project) ?? []).flatMap((q) => q.ids),
|
|
29
45
|
paused: paused.has(project),
|
|
46
|
+
// Why it waits: the usage limit (its reset may lift it) or a failure (only the operator).
|
|
47
|
+
paused_by: paused.has(project) ? (limitPaused.has(project) ? 'limit' : 'failure') : null,
|
|
30
48
|
// When the items a usage limit stopped run again by themselves, and the queue with them.
|
|
31
49
|
resume: s ? { at: s.at.toISOString(), ids: s.run.ids } : null,
|
|
50
|
+
reports: reports.get(project) ?? [],
|
|
51
|
+
acts: acts.get(project) ?? [],
|
|
32
52
|
};
|
|
33
53
|
}
|
|
34
54
|
/**
|
|
@@ -59,7 +79,10 @@ export function resetAt(text, now = new Date()) {
|
|
|
59
79
|
}
|
|
60
80
|
/** Drop the scheduled resume; the queue stays as it is. */
|
|
61
81
|
export function cancelResume(project) {
|
|
62
|
-
|
|
82
|
+
const s = scheduled.get(project);
|
|
83
|
+
if (s?.run.ids.length)
|
|
84
|
+
trace(project, 'resume-cancelled', `the resume of ${s.run.ids.join(', ')} is cancelled`);
|
|
85
|
+
clearTimeout(s?.timer);
|
|
63
86
|
scheduled.delete(project);
|
|
64
87
|
broadcast({ type: 'board', project });
|
|
65
88
|
}
|
|
@@ -77,6 +100,8 @@ export function forgetProject(project) {
|
|
|
77
100
|
queues.delete(project);
|
|
78
101
|
paused.delete(project);
|
|
79
102
|
limitPaused.delete(project);
|
|
103
|
+
reports.delete(project);
|
|
104
|
+
acts.delete(project);
|
|
80
105
|
}
|
|
81
106
|
/**
|
|
82
107
|
* At the reset: run the stopped items again (the harness resumes their sessions), then the queue.
|
|
@@ -93,11 +118,17 @@ function schedule(run, at) {
|
|
|
93
118
|
scheduled.delete(project);
|
|
94
119
|
liftLimitPause(project);
|
|
95
120
|
try {
|
|
96
|
-
if (s)
|
|
97
|
-
startRun(s.run.entry, s.run.p, s.run.ids, s.run.mode);
|
|
121
|
+
if (s) {
|
|
122
|
+
const r = startRun(s.run.entry, s.run.p, s.run.ids, s.run.mode);
|
|
123
|
+
const how = 'queued' in r ? 'wait in the queue' : 'run again';
|
|
124
|
+
trace(project, 'resume', `the usage limit reset: ${s.run.ids.join(', ')} ${how}`);
|
|
125
|
+
}
|
|
98
126
|
}
|
|
99
|
-
catch {
|
|
100
|
-
//
|
|
127
|
+
catch (e) {
|
|
128
|
+
// The board's own run of an item takes it out of the schedule first, so this is rare;
|
|
129
|
+
// the queue still goes on.
|
|
130
|
+
if (s)
|
|
131
|
+
trace(project, 'resume-failed', `${s.run.ids.join(', ')}: ${e.message}`);
|
|
101
132
|
advance(project);
|
|
102
133
|
}
|
|
103
134
|
broadcast({ type: 'board', project });
|
|
@@ -108,7 +139,7 @@ function schedule(run, at) {
|
|
|
108
139
|
/**
|
|
109
140
|
* A run some of whose items hit the usage limit: the main queue would hit it too, so it pauses,
|
|
110
141
|
* and those items run again at the reset when the message names a time. The reason, for its
|
|
111
|
-
*
|
|
142
|
+
* report.
|
|
112
143
|
*/
|
|
113
144
|
function limited(run, result) {
|
|
114
145
|
const items = result
|
|
@@ -127,18 +158,28 @@ function limited(run, result) {
|
|
|
127
158
|
schedule({ ...run, ids: hit.map((i) => i.id), fresh: false }, at);
|
|
128
159
|
const when = at ? `they resume at ${at.toTimeString().slice(0, 5)}` : 'run them after the reset';
|
|
129
160
|
const queue = run.mode === 'main' ? ', the queue is paused' : '';
|
|
130
|
-
|
|
161
|
+
const reason = `usage limit${resets ? `, resets ${resets}` : ''}${queue}: ${when}`;
|
|
162
|
+
trace(project, 'limit', `${hit.map((i) => i.id).join(', ')}: ${reason}`);
|
|
163
|
+
return reason;
|
|
131
164
|
}
|
|
132
165
|
/** Start the next queued batch when the checkout is free: after a run, or on the operator's resume. */
|
|
133
166
|
function advance(project) {
|
|
134
167
|
if (paused.has(project) || activeRuns(project).some((r) => r.mode === 'main'))
|
|
135
168
|
return;
|
|
136
169
|
const next = queues.get(project)?.shift();
|
|
137
|
-
if (next)
|
|
138
|
-
|
|
170
|
+
if (!next)
|
|
171
|
+
return;
|
|
172
|
+
trace(project, 'queue-run', `the queue starts ${next.ids.join(', ')}`);
|
|
173
|
+
spawnRun(next, next.mode);
|
|
139
174
|
}
|
|
140
175
|
/** The operator resumes a paused queue (after fixing what stopped it). */
|
|
141
176
|
export function resumeQueue(project) {
|
|
177
|
+
// The limit still holds until its reset: the next run would hit it again.
|
|
178
|
+
const s = scheduled.get(project);
|
|
179
|
+
if (s && limitPaused.has(project))
|
|
180
|
+
throw new HttpError(409, `the queue resumes by itself at ${s.at.toTimeString().slice(0, 5)}, after the usage limit; cancel that first to resume now`);
|
|
181
|
+
if (paused.has(project))
|
|
182
|
+
trace(project, 'queue-resumed', 'the operator resumed the queue');
|
|
142
183
|
paused.delete(project);
|
|
143
184
|
limitPaused.delete(project);
|
|
144
185
|
advance(project);
|
|
@@ -201,16 +242,25 @@ function spawnRun(q, mode) {
|
|
|
201
242
|
? ''
|
|
202
243
|
: limited(q, result) ||
|
|
203
244
|
(stop ? 'the queue is paused: fix the cause, then resume it on the board' : '');
|
|
245
|
+
if (stop)
|
|
246
|
+
trace(p.name, 'queue-paused', `after ${ids.join(', ')}: ${note}`);
|
|
247
|
+
const handed = result?.items;
|
|
248
|
+
for (const i of handed ?? [])
|
|
249
|
+
if (kept && i.handoffs)
|
|
250
|
+
trace(p.name, 'handoff', `${i.id}: ${i.handoffs} context handoff(s) to a fresh session`);
|
|
204
251
|
const brief = err && briefError(err);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
252
|
+
const report = {
|
|
253
|
+
id: `r${++reportSeq}`,
|
|
254
|
+
at: new Date().toISOString(),
|
|
208
255
|
ids,
|
|
209
256
|
ok,
|
|
210
257
|
result,
|
|
211
258
|
err: (brief + message).trim(),
|
|
212
259
|
note,
|
|
213
|
-
}
|
|
260
|
+
};
|
|
261
|
+
// A project removed meanwhile has no board to show it on.
|
|
262
|
+
if (kept)
|
|
263
|
+
reports.set(p.name, [...(reports.get(p.name) ?? []), report].slice(-KEEP_REPORTS));
|
|
214
264
|
// The checkout is free now, for whatever queue the name has: a project added back meanwhile.
|
|
215
265
|
if (mode === 'main')
|
|
216
266
|
advance(p.name);
|
|
@@ -222,7 +272,7 @@ function spawnRun(q, mode) {
|
|
|
222
272
|
}
|
|
223
273
|
/**
|
|
224
274
|
* Start a batch in the project's run mode, or queue it behind the main run going on. Its end
|
|
225
|
-
* is a
|
|
275
|
+
* is a run report with the harness report, or what went wrong. `fresh` starts new agent
|
|
226
276
|
* sessions where the harness would resume the interrupted ones.
|
|
227
277
|
*/
|
|
228
278
|
export function startRun(entry, p, ids, mode = 'worktree', fresh = false) {
|
|
@@ -235,6 +285,8 @@ export function startRun(entry, p, ids, mode = 'worktree', fresh = false) {
|
|
|
235
285
|
// now, and the limit's pause with it; the rest of the schedule stays.
|
|
236
286
|
const s = scheduled.get(p.name);
|
|
237
287
|
if (s?.run.ids.some((id) => ids.includes(id))) {
|
|
288
|
+
const early = s.run.ids.filter((id) => ids.includes(id));
|
|
289
|
+
trace(p.name, 'resume-cancelled', `${early.join(', ')}: the operator runs them before the reset`);
|
|
238
290
|
s.run = { ...s.run, ids: s.run.ids.filter((id) => !ids.includes(id)) };
|
|
239
291
|
if (!s.run.ids.length)
|
|
240
292
|
cancelResume(p.name);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg:#14161a;--panel:#1d2026;--card:#262a32;--line:#343a45;--text:#e6e8ec;--muted:#8b93a1;--accent:#5b9cff;--ok:#46c08a;--warn:#e8a33d;--bad:#e5534b;--verdict:#b48cff;--auto:#46c08a;--decision:#e8a33d;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;font:14px/1.4 system-ui,-apple-system,Segoe UI,sans-serif}*{box-sizing:border-box}body{background:var(--bg);color:var(--text);margin:0}button,input,select,textarea{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 10px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button.primary{background:var(--accent);border-color:var(--accent);color:#fff}button.danger{border-color:var(--bad);color:var(--bad)}code,pre{font-family:ui-monospace,Cascadia Code,Consolas,monospace}.muted{color:var(--muted)}.error,.warn{color:var(--bad)}.ok{color:var(--ok)}.badge{background:var(--line);border-radius:4px;padding:1px 6px;font-size:12px;display:inline-block}.topbar{background:var(--panel);border-bottom:1px solid var(--line);z-index:5;align-items:center;gap:12px;padding:8px 16px;display:flex;position:sticky;top:0}.projects-menu{position:relative}.projects-menu>:not(summary){margin-top:8px}.projects-menu[open]{background:var(--panel);border:1px solid var(--line);z-index:20;border-radius:8px;width:min(520px,92vw);padding:10px;position:absolute;top:8px;right:16px}.add-project{flex-wrap:wrap;gap:6px;display:flex}.add-project input:first-child{flex:260px}.empty{padding:32px}.project{padding:12px 16px 48px}.banner{border:1px solid var(--bad);border-radius:6px;margin-bottom:8px;padding:8px 12px}.header{flex-wrap:wrap;align-items:center;gap:12px;margin-bottom:8px;display:flex}.phase{flex-wrap:wrap;flex:1;align-items:center;gap:8px;display:flex}.criterion{color:var(--muted);font-size:12px}.criterion.met-true{color:var(--ok)}.criterion.met-false{color:var(--warn)}.wip.over{color:var(--bad);font-weight:600}.tabs{gap:4px;margin:8px 0;display:flex;overflow-x:auto}.tabs button{background:0 0;border-color:#0000}.tabs button.active{border-color:var(--line);background:var(--panel)}.board-actions{align-items:center;gap:12px;margin-bottom:8px;display:flex}.graveyard-zone{border:1px dashed var(--line);color:var(--muted);text-align:center;border-radius:6px;flex:1;padding:6px 10px}.graveyard-zone.over{border-color:var(--bad);color:var(--bad)}.columns{scroll-snap-type:x mandatory;grid-auto-columns:minmax(230px,1fr);grid-auto-flow:column;gap:10px;padding-bottom:8px;display:grid;overflow-x:auto}.column{background:var(--panel);border:1px solid var(--line);scroll-snap-align:start;border-radius:8px;min-height:200px;padding:8px}.column.over{border-color:var(--accent)}.column h3{text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px;font-size:13px}.count{color:var(--text)}.card{background:var(--card);border:1px solid var(--line);border-left:3px solid var(--line);cursor:pointer;touch-action:manipulation;border-radius:6px;margin-bottom:8px;padding:8px}.card.gate-verdict{border-left-color:var(--verdict)}.card.gate-auto{border-left-color:var(--auto)}.card.gate-decision{border-left-color:var(--decision)}.card.dragging{opacity:.7;z-index:10;position:relative}.card-top,.card-bottom{flex-wrap:wrap;align-items:center;gap:6px;font-size:12px;display:flex}.card .id{font-size:13px;font-weight:600}.card .type,.card .model,.card .tune{color:var(--muted)}.card .lessons{color:var(--warn)}button.link{cursor:pointer;background:0 0;border:none;padding:0;text-decoration:underline}.drawer .lessons{margin:8px 0}.gate{margin-left:auto;font-size:11px}.gate-verdict.gate{color:var(--verdict)}.gate-auto.gate{color:var(--auto)}.gate-decision.gate{color:var(--decision)}.title{margin:4px 0}.plan{background:var(--line);border-radius:4px;padding:0 5px}.verified{color:var(--ok)}.queued{color:var(--warn)}.activity{color:var(--muted)}.quiet{color:var(--warn)}.rank{color:var(--muted);font-size:11px}.card.drop-before{box-shadow:0 -3px 0 var(--accent)}.run-next{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.run-next input{width:56px}.sev-critical,.sev-high{color:var(--bad)}.blocked-why{color:var(--warn);white-space:pre-wrap;margin-top:4px;font-size:12px}.drawer{background:var(--panel);border-left:1px solid var(--line);z-index:30;flex-direction:column;width:min(640px,100vw);padding:12px 16px;display:flex;position:fixed;top:0;bottom:0;right:0;box-shadow:-8px 0 24px #0006}.drawer>header{justify-content:space-between;align-items:center;display:flex}.drawer h2{margin:0}.facts{grid-template-columns:max-content 1fr;gap:2px 12px;margin:8px 0;display:grid}.facts dt{color:var(--muted)}.facts dd{margin:0}.moves{flex-wrap:wrap;align-items:center;gap:6px;display:flex}.moves details{gap:6px;display:inline-flex}.moves details[open]{flex-wrap:wrap;gap:6px;display:flex}.drawer-body{border-top:1px solid var(--line);flex:1;padding-top:8px;overflow:auto}.frontmatter{color:var(--muted);background:var(--card);white-space:pre-wrap;border-radius:6px;padding:8px;font-size:12px}.log{white-space:pre-wrap;word-break:break-word;margin:0;font-size:12px}.modal{z-index:40;background:#0000008c;place-items:center;padding:12px;display:grid;position:fixed;inset:0}.dialog{background:var(--panel);border:1px solid var(--line);border-radius:10px;flex-direction:column;gap:10px;width:min(480px,100%);max-height:90vh;padding:16px;display:flex;overflow:auto}.dialog h2{margin:0;font-size:16px}.dialog label{color:var(--muted);flex-direction:column;gap:4px;font-size:13px;display:flex}.dialog label>*{color:var(--text)}.dialog-actions{justify-content:flex-end;gap:8px;display:flex}.list-view ul{padding:0;list-style:none}.list-view li{border-bottom:1px solid var(--line);align-items:center;gap:8px;padding:6px 0;display:flex}.row-actions{gap:6px;margin-left:auto;display:flex}.inline-form{gap:6px;margin:8px 0;display:flex}.inline-form input{flex:1}.playtest .build{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.check{background:var(--card);border:1px solid var(--line);border-radius:8px;margin-bottom:8px;padding:10px}.check p{margin:4px 0}.ended{grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;display:grid}.ended table{border-collapse:collapse;width:100%}.ended td{border-bottom:1px solid var(--line);vertical-align:top;padding:4px 6px}.toasts{z-index:50;flex-direction:column;gap:6px;max-width:min(480px,92vw);display:flex;position:fixed;bottom:12px;right:12px}.toast{background:var(--card);border:1px solid var(--ok);white-space:pre-wrap;border-radius:6px;padding:8px 12px}.toast-close{float:right;background:0 0;border:none;margin:-4px -6px 0 8px;padding:0 6px}.toast.error{border-color:var(--bad)}.run-reports{flex-direction:column;gap:6px;margin:6px 0;display:flex}.acts ul{margin:4px 0;padding-left:18px}@media (width<=700px){.project{padding:8px}.columns{grid-auto-columns:85vw}}
|