changeledger 0.7.0 → 0.9.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.
Files changed (35) hide show
  1. package/AGENTS.md +11 -5
  2. package/README.md +5 -1
  3. package/bin/changeledger.mjs +82 -6
  4. package/package.json +1 -1
  5. package/src/check.mjs +37 -1
  6. package/src/commands/agent-context.mjs +64 -0
  7. package/src/commands/agent-prompt.mjs +22 -0
  8. package/src/commands/agent.mjs +50 -7
  9. package/src/commands/context.mjs +8 -9
  10. package/src/commands/graduate.mjs +20 -8
  11. package/src/commands/view.mjs +6 -2
  12. package/src/contract.mjs +11 -5
  13. package/src/framing.mjs +22 -0
  14. package/src/lifecycle.mjs +4 -3
  15. package/src/metrics.mjs +3 -3
  16. package/src/viewer/domain.mjs +17 -7
  17. package/src/viewer/public/app-state.js +77 -6
  18. package/src/viewer/public/app.js +193 -42
  19. package/src/viewer/public/index.html +2 -2
  20. package/src/viewer/public/state.js +6 -2
  21. package/src/viewer/public/styles.css +205 -14
  22. package/src/viewer/public/view-parts.js +52 -0
  23. package/templates/contract/agent-contexts/implementation.md +18 -0
  24. package/templates/contract/agent-contexts/investigation.md +14 -0
  25. package/templates/contract/agent-contexts/review.md +22 -0
  26. package/templates/contract/agent-prompts/implementation.md +38 -0
  27. package/templates/contract/agent-prompts/investigation.md +41 -0
  28. package/templates/contract/agent-prompts/review.md +36 -0
  29. package/templates/contract/budgets.yml +16 -0
  30. package/templates/contract/close.md +25 -14
  31. package/templates/contract/core.md +43 -35
  32. package/templates/contract/delegation.md +2 -1
  33. package/templates/contract/implement.md +27 -11
  34. package/templates/contract/review.md +12 -9
  35. package/templates/contract/validation.md +16 -2
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // The change lifecycle as an explicit, testable graph — the single authority on
2
2
  // which status moves are legal. Shared by the CLI (`changeledger status`) and the viewer
3
3
  // so both decide validity the same way. The viewer layers an extra human-only
4
- // policy on top (approval plus final acceptance/rejection); it never relaxes
4
+ // policy on top (approval plus final acceptance); it never relaxes
5
5
  // this graph.
6
6
 
7
7
  export const CANONICAL_STATUSES = [
@@ -20,7 +20,8 @@ export const CANONICAL_STATUSES = [
20
20
  // absent and therefore rejected. `in-review` is optional by type;
21
21
  // `in-validation` is the universal human gate before done. Review or validation
22
22
  // may route back to in-progress, while review may also block. `discarded` is a
23
- // terminal tombstone reachable only before either closing gate.
23
+ // terminal tombstone reachable only before either closing gate. `done` has one
24
+ // policy-gated provisional reopen edge; generic status commands do not own it.
24
25
  const TRANSITIONS = {
25
26
  draft: ['approved', 'discarded'],
26
27
  approved: ['in-progress', 'discarded'],
@@ -28,7 +29,7 @@ const TRANSITIONS = {
28
29
  'in-review': ['in-validation', 'in-progress', 'blocked'],
29
30
  'in-validation': ['done', 'in-progress'],
30
31
  blocked: ['in-progress', 'discarded'],
31
- done: [],
32
+ done: ['in-progress'],
32
33
  discarded: [],
33
34
  };
34
35
 
package/src/metrics.mjs CHANGED
@@ -42,8 +42,8 @@ function transitions(change) {
42
42
  return events;
43
43
  }
44
44
 
45
- // Time spent in each state: [{ state, ms }]. The final state runs until `now`
46
- // (or until it reached `done`).
45
+ // Time spent in each state: [{ state, ms }]. A provisional `done` interval is
46
+ // retained in the event stream but excluded from active-state duration.
47
47
  export function statusTimeline(change, now) {
48
48
  const events = transitions(change);
49
49
  if (!events.length) return [];
@@ -53,7 +53,7 @@ export function statusTimeline(change, now) {
53
53
  const endIso = events[i + 1]?.at ?? now;
54
54
  const end = Date.parse(endIso);
55
55
  if (Number.isNaN(start) || Number.isNaN(end)) continue;
56
- // `done` is terminal there is no meaningful dwell time in it.
56
+ // `done` is outside active work, including a provisional interval before reopen.
57
57
  if (events[i].state === 'done') continue;
58
58
  segs.push({ state: events[i].state, ms: Math.max(0, end - start) });
59
59
  }
@@ -3,8 +3,13 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { parseDocument } from 'yaml';
5
5
  import { mutateFileAtomic } from '../atomic-write.mjs';
6
+ import { parseChange } from '../change.mjs';
6
7
  import { checkRepo } from '../check.mjs';
7
- import { status as applyStatusCmd, validation as applyValidation } from '../commands/agent.mjs';
8
+ import {
9
+ reopen as applyReopen,
10
+ status as applyStatusCmd,
11
+ validation as applyValidation,
12
+ } from '../commands/agent.mjs';
8
13
  import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
9
14
  import {
10
15
  buildMigration,
@@ -14,7 +19,7 @@ import {
14
19
  import { computeMetrics } from '../metrics.mjs';
15
20
  import { nowUtc } from '../paths.mjs';
16
21
  import { listProjects, remove, update } from '../registry.mjs';
17
- import { loadRepo, loadRepoWithConfig } from '../repo.mjs';
22
+ import { loadRepo, loadRepoWithConfig, resolveChange } from '../repo.mjs';
18
23
  import { parseYaml } from '../yaml.mjs';
19
24
 
20
25
  // Serializes a loaded repo into the flat shape the UI consumes.
@@ -115,24 +120,29 @@ export function changeStatus(projects, { project, id, status, reason }) {
115
120
  // the UI is bypassable.
116
121
  let current;
117
122
  try {
118
- const change = loadRepo(proj.path).changes.find((c) => String(c.frontmatter.id) === String(id));
119
- if (!change) return { code: 404, body: { error: `no change with id "${id}"` } };
120
- current = change.frontmatter.status;
123
+ const { file } = resolveChange(proj.path, id);
124
+ current = parseChange(fs.readFileSync(file, 'utf8')).frontmatter.status;
121
125
  } catch (e) {
126
+ if (/^No change with id /.test(e.message)) {
127
+ return { code: 404, body: { error: `no change with id "${id}"` } };
128
+ }
122
129
  return { code: 400, body: { error: e.message } };
123
130
  }
124
131
  try {
125
132
  if (current === 'draft' && status === 'approved') {
126
- applyStatusCmd(id, status, proj.path);
133
+ applyStatusCmd(id, status, proj.path, { actor: 'human' });
127
134
  } else if (current === 'in-validation' && status === 'done') {
128
135
  applyValidation(id, 'pass', {}, proj.path);
129
136
  } else if (current === 'in-validation' && status === 'in-progress') {
130
137
  applyValidation(id, 'fail', { reason }, proj.path);
138
+ } else if (current === 'done' && status === 'in-progress') {
139
+ applyReopen(id, reason, proj.path);
131
140
  } else {
132
141
  return {
133
142
  code: 403,
134
143
  body: {
135
- error: 'the viewer only allows draft → approved and in-validation → done|in-progress',
144
+ error:
145
+ 'the viewer only allows draft → approved, in-validation → done|in-progress, and eligible done → in-progress',
136
146
  },
137
147
  };
138
148
  }
@@ -2,11 +2,14 @@ export const VIEWER_STATE_KEY = 'changeledger.viewer-state.v1';
2
2
 
3
3
  const VALID_VIEWS = new Set(['board', 'table', 'graph', 'specs', 'metrics', 'projects']);
4
4
  const VALID_SORT_KEYS = new Set(['id', 'title', 'type', 'status', 'progress', 'deps']);
5
+ const VALID_DETAIL_MODES = new Set(['side', 'floating']);
6
+ const VALID_DETAIL_SIZES = new Set(['compact', 'wide', 'full']);
5
7
  let storage = null;
6
8
 
7
9
  const emptyProjectFilters = () => ({
8
- type: 'all',
9
- owner: 'all',
10
+ types: [],
11
+ owners: [],
12
+ includeUnassigned: false,
10
13
  statuses: [],
11
14
  showArchived: false,
12
15
  showDiscarded: false,
@@ -19,6 +22,9 @@ export const state = {
19
22
  text: '',
20
23
  type: 'all',
21
24
  owner: 'all',
25
+ types: new Set(),
26
+ owners: new Set(),
27
+ includeUnassigned: false,
22
28
  statuses: new Set(),
23
29
  showArchived: false,
24
30
  showDiscarded: false,
@@ -31,12 +37,15 @@ export const state = {
31
37
  projectsList: [],
32
38
  localOnly: false,
33
39
  globalMode: false,
40
+ detailMode: 'side',
41
+ detailSize: 'wide',
34
42
  };
35
43
 
36
44
  function currentProjectFilters() {
37
45
  return {
38
- type: state.filters.type,
39
- owner: state.filters.owner,
46
+ types: [...state.filters.types],
47
+ owners: [...state.filters.owners],
48
+ includeUnassigned: state.filters.includeUnassigned,
40
49
  statuses: [...state.filters.statuses],
41
50
  showArchived: state.filters.showArchived,
42
51
  showDiscarded: state.filters.showDiscarded,
@@ -49,8 +58,23 @@ function saveCurrentProjectFilters() {
49
58
 
50
59
  function applyProjectFilters(id) {
51
60
  const filters = state.projectFilters[id] ?? emptyProjectFilters();
52
- state.filters.type = typeof filters.type === 'string' ? filters.type : 'all';
53
- state.filters.owner = typeof filters.owner === 'string' ? filters.owner : 'all';
61
+ state.filters.types = new Set(
62
+ Array.isArray(filters.types)
63
+ ? filters.types.filter((value) => typeof value === 'string')
64
+ : typeof filters.type === 'string' && filters.type !== 'all'
65
+ ? [filters.type]
66
+ : [],
67
+ );
68
+ state.filters.type = state.filters.types.size === 1 ? [...state.filters.types][0] : 'all';
69
+ state.filters.owners = new Set(
70
+ Array.isArray(filters.owners)
71
+ ? filters.owners.filter((value) => typeof value === 'string')
72
+ : typeof filters.owner === 'string' && filters.owner !== 'all'
73
+ ? [filters.owner]
74
+ : [],
75
+ );
76
+ state.filters.owner = state.filters.owners.size === 1 ? [...state.filters.owners][0] : 'all';
77
+ state.filters.includeUnassigned = filters.includeUnassigned === true;
54
78
  state.filters.statuses = new Set(
55
79
  Array.isArray(filters.statuses)
56
80
  ? filters.statuses.filter((value) => typeof value === 'string')
@@ -71,6 +95,8 @@ export function serializeViewerState() {
71
95
  sortKey: state.sortKey,
72
96
  sortDir: state.sortDir,
73
97
  projects: state.projectFilters,
98
+ detailMode: state.detailMode,
99
+ detailSize: state.detailSize,
74
100
  };
75
101
  }
76
102
 
@@ -99,6 +125,8 @@ export function restoreViewerState(storageLike) {
99
125
  if (typeof snapshot.text === 'string') state.filters.text = snapshot.text;
100
126
  if (typeof snapshot.sortKey === 'string') state.sortKey = snapshot.sortKey;
101
127
  if (snapshot.sortDir === 1 || snapshot.sortDir === -1) state.sortDir = snapshot.sortDir;
128
+ state.detailMode = VALID_DETAIL_MODES.has(snapshot.detailMode) ? snapshot.detailMode : 'side';
129
+ state.detailSize = VALID_DETAIL_SIZES.has(snapshot.detailSize) ? snapshot.detailSize : 'wide';
102
130
  if (
103
131
  snapshot.projects &&
104
132
  typeof snapshot.projects === 'object' &&
@@ -136,9 +164,13 @@ export function normalizeRepoState(repo) {
136
164
  }
137
165
  if (state.sortDir !== 1 && state.sortDir !== -1) state.sortDir = 1;
138
166
  if (!repo.types.includes(state.filters.type)) state.filters.type = 'all';
167
+ state.filters.types = new Set(
168
+ [...state.filters.types].filter((type) => repo.types.includes(type)),
169
+ );
139
170
  const owners = new Set(repo.changes.map((change) => change.owner).filter(Boolean));
140
171
  if (state.filters.owner !== 'all' && !owners.has(state.filters.owner))
141
172
  state.filters.owner = 'all';
173
+ state.filters.owners = new Set([...state.filters.owners].filter((owner) => owners.has(owner)));
142
174
  const statuses = new Set(repo.statuses);
143
175
  state.filters.statuses = new Set(
144
176
  [...state.filters.statuses].filter((status) => statuses.has(status)),
@@ -160,16 +192,48 @@ export function setTextFilter(text) {
160
192
  persistViewerState();
161
193
  }
162
194
 
195
+ export function toggleTypeFilter(type) {
196
+ if (state.filters.types.has(type)) state.filters.types.delete(type);
197
+ else state.filters.types.add(type);
198
+ state.filters.type = state.filters.types.size === 1 ? [...state.filters.types][0] : 'all';
199
+ persistViewerState();
200
+ }
201
+
163
202
  export function setTypeFilter(type) {
203
+ state.filters.types = new Set(type === 'all' ? [] : [type]);
164
204
  state.filters.type = type;
165
205
  persistViewerState();
166
206
  }
167
207
 
208
+ export function toggleOwnerFilter(owner) {
209
+ if (state.filters.owners.has(owner)) state.filters.owners.delete(owner);
210
+ else state.filters.owners.add(owner);
211
+ state.filters.owner = state.filters.owners.size === 1 ? [...state.filters.owners][0] : 'all';
212
+ persistViewerState();
213
+ }
214
+
168
215
  export function setOwnerFilter(owner) {
216
+ state.filters.owners = new Set(owner === 'all' ? [] : [owner]);
169
217
  state.filters.owner = owner;
170
218
  persistViewerState();
171
219
  }
172
220
 
221
+ export function toggleUnassignedOwner() {
222
+ state.filters.includeUnassigned = !state.filters.includeUnassigned;
223
+ persistViewerState();
224
+ }
225
+
226
+ export function clearTypeFilters() {
227
+ state.filters.types.clear();
228
+ persistViewerState();
229
+ }
230
+
231
+ export function clearOwnerFilters() {
232
+ state.filters.owners.clear();
233
+ state.filters.includeUnassigned = false;
234
+ persistViewerState();
235
+ }
236
+
173
237
  export function toggleStatusFilter(status) {
174
238
  if (state.filters.statuses.has(status)) state.filters.statuses.delete(status);
175
239
  else state.filters.statuses.add(status);
@@ -224,3 +288,10 @@ export function toggleGlobalMode() {
224
288
  persistViewerState();
225
289
  return state.globalMode;
226
290
  }
291
+
292
+ export function setDetailPresentation(mode, size) {
293
+ if (VALID_DETAIL_MODES.has(mode)) state.detailMode = mode;
294
+ if (VALID_DETAIL_SIZES.has(size)) state.detailSize = size;
295
+ persistViewerState();
296
+ return { mode: state.detailMode, size: state.detailSize };
297
+ }
@@ -13,30 +13,34 @@ import {
13
13
  searchAllProjects,
14
14
  } from './api.js';
15
15
  import {
16
+ clearOwnerFilters,
16
17
  clearStatusFilters,
18
+ clearTypeFilters,
17
19
  initializeProjects,
18
20
  invalidateCache,
19
21
  normalizeRepoState,
20
22
  restoreViewerState,
21
23
  selectProject,
22
- setOwnerFilter,
24
+ setDetailPresentation,
23
25
  setRepo,
24
26
  setSortKey,
25
27
  setTextFilter,
26
- setTypeFilter,
27
28
  setView,
28
29
  state,
29
30
  toggleGlobalMode,
31
+ toggleOwnerFilter,
30
32
  toggleShowArchived,
31
33
  toggleShowDiscarded,
32
34
  toggleStatusFilter,
35
+ toggleTypeFilter,
36
+ toggleUnassignedOwner,
33
37
  } from './app-state.js';
34
38
  import { cssIdent, initMermaid, makeMermaidExpandable, renderMermaid } from './security.js';
35
39
  import { boardStatuses, isVisible, passesTombstones } from './state.js';
36
40
  import { html, render as litRender, nothing } from './templates.js';
37
41
  import {
38
42
  card,
39
- closeButton,
43
+ detailToolbar,
40
44
  sortIndicator,
41
45
  specBody,
42
46
  stageBlock,
@@ -50,6 +54,8 @@ export { cssIdent, esc, makeMermaidExpandable, safeHtml } from './security.js';
50
54
  export { boardStatuses, isVisible, passesTombstones } from './state.js';
51
55
  export {
52
56
  card,
57
+ detailPresentationControls,
58
+ detailToolbar,
53
59
  sortIndicator,
54
60
  stageBlock,
55
61
  statusSummary,
@@ -112,27 +118,74 @@ export function showNoProjects(root = document) {
112
118
 
113
119
  // Rebuilt on each project load (types/statuses can differ per project).
114
120
  function hydrateFilters() {
115
- litRender(
116
- html`<option value="all">All types</option>
117
- ${state.repo.types.map((t) => html`<option value=${t}>${t}</option>`)}`,
118
- $('#type-filter'),
119
- );
120
- $('#type-filter').value = state.filters.type;
121
121
  $('#lang').textContent = state.repo.language;
122
-
123
122
  const owners = [...new Set(state.repo.changes.map((c) => c.owner).filter(Boolean))].sort();
124
- litRender(
125
- html`<option value="all">All owners</option>
126
- ${owners.map((o) => html`<option value=${o}>${o}</option>`)}`,
123
+ renderChoiceFilter(
124
+ $('#type-filter'),
125
+ 'Type',
126
+ state.repo.types,
127
+ state.filters.types,
128
+ toggleTypeFilter,
129
+ clearTypeFilters,
130
+ );
131
+ renderChoiceFilter(
127
132
  $('#owner-filter'),
133
+ 'Owner',
134
+ owners,
135
+ state.filters.owners,
136
+ toggleOwnerFilter,
137
+ clearOwnerFilters,
138
+ true,
128
139
  );
129
- if (state.filters.owner !== 'all' && !owners.includes(state.filters.owner)) setOwnerFilter('all');
130
- $('#owner-filter').value = state.filters.owner;
131
- $('#owner-filter').style.display = owners.length ? '' : 'none';
132
-
133
140
  renderStatusFilter();
134
141
  }
135
142
 
143
+ export function choiceFilterSummary(label, selected, includeUnassigned = false) {
144
+ const count = selected.size + Number(includeUnassigned);
145
+ if (count === 1 && includeUnassigned) return 'Unassigned';
146
+ return count
147
+ ? count === 1
148
+ ? [...selected][0]
149
+ : `${count} selected`
150
+ : `All ${label.toLowerCase()}s`;
151
+ }
152
+
153
+ function renderChoiceFilter(host, label, choices, selected, toggle, clear, owners = false) {
154
+ const summary = choiceFilterSummary(label, selected, owners && state.filters.includeUnassigned);
155
+ litRender(
156
+ html`<details class="filter-menu">
157
+ <summary class="filter-trigger">
158
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.25h12M4.25 8h7.5M6.5 12.75h3"></path></svg>
159
+ <span>${summary}</span>
160
+ <svg class="filter-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6.25 3.5 3.5 3.5-3.5"></path></svg>
161
+ </summary>
162
+ <div class="filter-popover">
163
+ <div class="filter-heading"><span>${label}</span><button type="button" data-clear>Clear</button></div>
164
+ <div class="filter-options">
165
+ ${choices.map((choice) => html`<label class="check-option"><input type="checkbox" data-choice=${choice} .checked=${selected.has(choice)} /><span class="check-box" aria-hidden="true"></span><span>${choice}</span></label>`)}
166
+ ${owners ? html`<label class="check-option"><input type="checkbox" data-unassigned .checked=${state.filters.includeUnassigned} /><span class="check-box" aria-hidden="true"></span><span>Unassigned</span></label>` : nothing}
167
+ </div>
168
+ </div>
169
+ </details>`,
170
+ host,
171
+ );
172
+ host.querySelectorAll('[data-choice]').forEach((input) => {
173
+ input.onchange = () => {
174
+ toggle(input.dataset.choice);
175
+ render();
176
+ };
177
+ });
178
+ if (owners)
179
+ host.querySelector('[data-unassigned]').onchange = () => {
180
+ toggleUnassignedOwner();
181
+ render();
182
+ };
183
+ host.querySelector('[data-clear]').onclick = () => {
184
+ clear();
185
+ render();
186
+ };
187
+ }
188
+
136
189
  function renderStatusFilter() {
137
190
  const sf = $('#status-filter');
138
191
  litRender(
@@ -312,6 +365,73 @@ export async function runValidationSubmission({ root, request, onSuccess }) {
312
365
  return true;
313
366
  }
314
367
 
368
+ export function reopenPanel(status) {
369
+ if (status !== 'done') return nothing;
370
+ return html`<section class="validation-actions" aria-labelledby="reopen-title">
371
+ <div class="validation-copy">
372
+ <span class="validation-kicker">Lifecycle correction</span>
373
+ <h2 id="reopen-title">Reopen completed change</h2>
374
+ <p>Return this change to active work while preserving why its completion was reconsidered.</p>
375
+ </div>
376
+ <div class="reopen-controls">
377
+ <div class="rejection-field">
378
+ <label for="reopen-reason">Reason for reopening</label>
379
+ <input
380
+ id="reopen-reason"
381
+ data-reopen-reason
382
+ type="text"
383
+ placeholder="What requires more work?"
384
+ />
385
+ <p class="validation-error" role="alert" hidden></p>
386
+ </div>
387
+ <button type="button" class="button button-danger" data-reopen>Reopen change</button>
388
+ </div>
389
+ </section>`;
390
+ }
391
+
392
+ export function bindReopenAction({ root, request, onSuccess }) {
393
+ const button = root.querySelector('[data-reopen]');
394
+ if (!button) return;
395
+ button.onclick = async () => {
396
+ const input = root.querySelector('[data-reopen-reason]');
397
+ const reason = input?.value.trim();
398
+ if (!reason) {
399
+ showValidationError(root, 'A reopening reason is required.');
400
+ input?.focus();
401
+ return false;
402
+ }
403
+ return runValidationSubmission({
404
+ root,
405
+ request: () => request(reason),
406
+ onSuccess,
407
+ });
408
+ };
409
+ }
410
+
411
+ export function applyDetailPresentation(root = document) {
412
+ const overlay = root.querySelector('#overlay');
413
+ const detail = root.querySelector('#detail');
414
+ if (!overlay || !detail) return;
415
+ overlay.dataset.detailMode = state.detailMode;
416
+ detail.dataset.detailSize = state.detailSize;
417
+ }
418
+
419
+ export function bindDetailPresentation(root = document) {
420
+ root.querySelectorAll('[data-detail-setting]').forEach((button) => {
421
+ button.onclick = () => {
422
+ const setting = button.dataset.detailSetting;
423
+ setDetailPresentation(
424
+ setting === 'mode' ? button.dataset.detailValue : state.detailMode,
425
+ setting === 'size' ? button.dataset.detailValue : state.detailSize,
426
+ );
427
+ applyDetailPresentation(root);
428
+ root.querySelectorAll(`[data-detail-setting="${setting}"]`).forEach((option) => {
429
+ option.setAttribute('aria-pressed', String(option === button));
430
+ });
431
+ };
432
+ });
433
+ }
434
+
315
435
  async function submitValidation(id, status, reason) {
316
436
  const root = $('#detail');
317
437
  await runValidationSubmission({
@@ -325,6 +445,28 @@ async function submitValidation(id, status, reason) {
325
445
  });
326
446
  }
327
447
 
448
+ export function resetDetailScroll(
449
+ detail,
450
+ schedule = globalThis.requestAnimationFrame?.bind(globalThis),
451
+ ) {
452
+ const reset = () => {
453
+ detail.scrollTo?.({ top: 0, left: 0, behavior: 'instant' });
454
+ detail.scrollTop = 0;
455
+ };
456
+ reset();
457
+ schedule?.(reset);
458
+ }
459
+
460
+ export function scrollToStage(stage) {
461
+ stage.scrollIntoView({ behavior: 'auto', block: 'start' });
462
+ }
463
+
464
+ function renderOpenedDetail(content) {
465
+ const detail = $('#detail');
466
+ litRender(content, detail);
467
+ resetDetailScroll(detail);
468
+ }
469
+
328
470
  function openDetail(id) {
329
471
  const c = state.repo.changes.find((x) => String(x.id) === String(id));
330
472
  if (!c) return;
@@ -334,14 +476,11 @@ function openDetail(id) {
334
476
  ? html`<span class="pill ext" data-extdep=${d} style="cursor:pointer">depends on ${d}</span>`
335
477
  : html`<span class="pill" data-dep=${d} style="cursor:pointer">depends on #${d}</span>`;
336
478
  });
337
- const pipeline = c.stages.map(
338
- (s) => html`<span class="stage-chip" data-go=${`stage-${s.key}`}>${s.heading}</span>`,
339
- );
340
479
  const stages = c.stages.map((s) => stageBlock(c, s));
341
480
 
342
- litRender(
481
+ renderOpenedDetail(
343
482
  html`
344
- ${closeButton()}
483
+ ${detailToolbar(state.detailMode, state.detailSize, c.stages)}
345
484
  <h1>${c.title}</h1>
346
485
  <div class="detail-meta">
347
486
  <span class="pill">#${c.id}</span>
@@ -352,16 +491,18 @@ function openDetail(id) {
352
491
  ${deps}
353
492
  </div>
354
493
  ${c.status === 'in-validation' ? validationPanel() : nothing}
355
- <div class="pipeline">${pipeline}</div>
494
+ ${reopenPanel(c.status)}
356
495
  ${stages}
357
496
  <div id="git-section"></div>`,
358
- $('#detail'),
359
497
  );
360
498
 
361
499
  resetValidationState($('#detail'));
362
500
 
363
501
  const overlay = $('#overlay');
364
502
  overlay.classList.remove('hidden');
503
+ document.documentElement.classList.add('detail-open');
504
+ applyDetailPresentation();
505
+ bindDetailPresentation();
365
506
  $('#detail').querySelector('.close').onclick = closeDetail;
366
507
  const accept = $('#detail').querySelector('[data-validation="pass"]');
367
508
  if (accept) accept.onclick = () => submitValidation(c.id, 'done');
@@ -378,14 +519,22 @@ function openDetail(id) {
378
519
  submitValidation(c.id, 'in-progress', reason);
379
520
  };
380
521
  }
522
+ bindReopenAction({
523
+ root: $('#detail'),
524
+ request: (reason) => postStatus(state.currentProject, c.id, 'in-progress', reason),
525
+ onSuccess: async () => {
526
+ invalidateCache();
527
+ await load();
528
+ openDetail(c.id);
529
+ },
530
+ });
381
531
  overlay.onclick = (e) => {
382
532
  if (e.target === overlay) closeDetail();
383
533
  };
384
534
  $('#detail')
385
535
  .querySelectorAll('[data-go]')
386
536
  .forEach((el) => {
387
- el.onclick = () =>
388
- $(`#${el.dataset.go}`).scrollIntoView({ behavior: 'smooth', block: 'start' });
537
+ el.onclick = () => scrollToStage($(`#${el.dataset.go}`));
389
538
  });
390
539
  $('#detail')
391
540
  .querySelectorAll('[data-dep]')
@@ -441,6 +590,7 @@ async function loadGitRefs(id) {
441
590
 
442
591
  function closeDetail() {
443
592
  $('#overlay').classList.add('hidden');
593
+ document.documentElement.classList.remove('detail-open');
444
594
  }
445
595
 
446
596
  let diagramLightbox = null;
@@ -584,9 +734,9 @@ function renderSpecs() {
584
734
  }
585
735
 
586
736
  function openSpec(s) {
587
- litRender(
737
+ renderOpenedDetail(
588
738
  html`
589
- ${closeButton()}
739
+ ${detailToolbar(state.detailMode, state.detailSize)}
590
740
  <h1>${s.title}</h1>
591
741
  <div class="detail-meta">
592
742
  <span class="pill">spec</span>
@@ -594,10 +744,12 @@ function openSpec(s) {
594
744
  ${(s.tags || []).map((t) => html`<span class="pill">${t}</span>`)}
595
745
  </div>
596
746
  ${specBody(s.body)}`,
597
- $('#detail'),
598
747
  );
599
748
  const overlay = $('#overlay');
600
749
  overlay.classList.remove('hidden');
750
+ document.documentElement.classList.add('detail-open');
751
+ applyDetailPresentation();
752
+ bindDetailPresentation();
601
753
  const detail = $('#detail');
602
754
  detail.querySelector('.close').onclick = closeDetail;
603
755
  overlay.onclick = (e) => {
@@ -1454,16 +1606,13 @@ function bootstrap() {
1454
1606
  if (active) enterGlobal();
1455
1607
  else activateView(state.currentView);
1456
1608
  };
1457
- $('#type-filter').onchange = (e) => {
1458
- setTypeFilter(e.target.value);
1459
- render();
1460
- };
1461
- $('#owner-filter').onchange = (e) => {
1462
- setOwnerFilter(e.target.value);
1463
- render();
1464
- };
1465
1609
  document.addEventListener('pointerdown', (event) => {
1466
- closeStatusMenuOnOutsideClick($('#status-filter .filter-menu'), event.target);
1610
+ closeFilterMenusOnOutsideClick(
1611
+ ['#type-filter', '#owner-filter', '#status-filter'].map((selector) =>
1612
+ $(`${selector} .filter-menu`),
1613
+ ),
1614
+ event.target,
1615
+ );
1467
1616
  });
1468
1617
  $('#view-board').onclick = () => activateView('board');
1469
1618
  $('#view-table').onclick = () => activateView('table');
@@ -1492,10 +1641,12 @@ function bootstrap() {
1492
1641
  setInterval(load, 5000);
1493
1642
  }
1494
1643
 
1495
- export function closeStatusMenuOnOutsideClick(menu, target) {
1496
- if (!menu?.open || menu.contains(target)) return false;
1497
- menu.open = false;
1498
- return true;
1644
+ export function closeFilterMenusOnOutsideClick(menus, target) {
1645
+ return menus.reduce((closed, menu) => {
1646
+ if (!menu?.open || menu.contains(target)) return closed;
1647
+ menu.open = false;
1648
+ return true;
1649
+ }, false);
1499
1650
  }
1500
1651
 
1501
1652
  // Only a real browser page with the app shell bootstraps; importing the module
@@ -26,8 +26,8 @@
26
26
  <select id="project" class="filter" title="Project"></select>
27
27
  <input id="search" class="search" type="search" placeholder="Search anything…" />
28
28
  <button type="button" id="toggle-global" class="chip" title="Search across all projects">Global</button>
29
- <select id="type-filter" class="filter"></select>
30
- <select id="owner-filter" class="filter"></select>
29
+ <div id="type-filter"></div>
30
+ <div id="owner-filter"></div>
31
31
  <div id="status-filter"></div>
32
32
  <div class="spacer"></div>
33
33
  <div class="tabs">
@@ -22,8 +22,12 @@ function haystack(c) {
22
22
  // predicate so the rule is testable.
23
23
  export function isVisible(c, f) {
24
24
  if (!passesTombstones(c, f)) return false;
25
- if (f.type !== 'all' && c.type !== f.type) return false;
26
- if (f.owner !== 'all' && c.owner !== f.owner) return false;
25
+ const types = f.types ?? new Set(f.type && f.type !== 'all' ? [f.type] : []);
26
+ const owners = f.owners ?? new Set(f.owner && f.owner !== 'all' ? [f.owner] : []);
27
+ if (types.size && !types.has(c.type)) return false;
28
+ if (owners.size || f.includeUnassigned) {
29
+ if (c.owner ? !owners.has(c.owner) : !f.includeUnassigned) return false;
30
+ }
27
31
  if (f.statuses.size && !f.statuses.has(c.status)) return false;
28
32
  const q = f.text.toLowerCase();
29
33
  if (!q) return true;