@goodbones/browser 0.1.0-beta.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/index.d.ts +12 -0
  3. package/build/dts/index.d.ts.map +1 -0
  4. package/build/dts/main.d.ts +3 -0
  5. package/build/dts/main.d.ts.map +1 -0
  6. package/build/dts/model/atlas.d.ts +157 -0
  7. package/build/dts/model/atlas.d.ts.map +1 -0
  8. package/build/dts/model/campaigns.d.ts +141 -0
  9. package/build/dts/model/campaigns.d.ts.map +1 -0
  10. package/build/dts/server/collect.d.ts +16 -0
  11. package/build/dts/server/collect.d.ts.map +1 -0
  12. package/build/dts/server/compose.d.ts +13 -0
  13. package/build/dts/server/compose.d.ts.map +1 -0
  14. package/build/dts/server/export.d.ts +3 -0
  15. package/build/dts/server/export.d.ts.map +1 -0
  16. package/build/dts/server/handler.d.ts +6 -0
  17. package/build/dts/server/handler.d.ts.map +1 -0
  18. package/build/dts/server/serve.d.ts +15 -0
  19. package/build/dts/server/serve.d.ts.map +1 -0
  20. package/build/dts/server/site.d.ts +2 -0
  21. package/build/dts/server/site.d.ts.map +1 -0
  22. package/build/dts/server/source.d.ts +19 -0
  23. package/build/dts/server/source.d.ts.map +1 -0
  24. package/build/dts/server/vite-plugin.d.ts +8 -0
  25. package/build/dts/server/vite-plugin.d.ts.map +1 -0
  26. package/build/dts/server/watch.d.ts +10 -0
  27. package/build/dts/server/watch.d.ts.map +1 -0
  28. package/build/esm/index.js +14 -0
  29. package/build/esm/index.js.map +1 -0
  30. package/build/esm/main.js +134 -0
  31. package/build/esm/main.js.map +1 -0
  32. package/build/esm/model/atlas.js +431 -0
  33. package/build/esm/model/atlas.js.map +1 -0
  34. package/build/esm/model/campaigns.js +181 -0
  35. package/build/esm/model/campaigns.js.map +1 -0
  36. package/build/esm/server/collect.js +83 -0
  37. package/build/esm/server/collect.js.map +1 -0
  38. package/build/esm/server/compose.js +50 -0
  39. package/build/esm/server/compose.js.map +1 -0
  40. package/build/esm/server/export.js +19 -0
  41. package/build/esm/server/export.js.map +1 -0
  42. package/build/esm/server/handler.js +67 -0
  43. package/build/esm/server/handler.js.map +1 -0
  44. package/build/esm/server/serve.js +80 -0
  45. package/build/esm/server/serve.js.map +1 -0
  46. package/build/esm/server/site.js +6 -0
  47. package/build/esm/server/site.js.map +1 -0
  48. package/build/esm/server/source.js +67 -0
  49. package/build/esm/server/source.js.map +1 -0
  50. package/build/esm/server/vite-plugin.js +21 -0
  51. package/build/esm/server/vite-plugin.js.map +1 -0
  52. package/build/esm/server/watch.js +56 -0
  53. package/build/esm/server/watch.js.map +1 -0
  54. package/build/site/_astro/Browser.yxldcnQ3.js +3 -0
  55. package/build/site/_astro/client.Buyw3Q1S.js +9 -0
  56. package/build/site/_astro/index.DQrWs1cb.css +1 -0
  57. package/build/site/_astro/react.DJY1zw8Z.js +1 -0
  58. package/build/site/favicon.svg +1 -0
  59. package/build/site/index.html +8 -0
  60. package/package.json +78 -0
  61. package/src/app/components/Browser.tsx +153 -0
  62. package/src/app/components/architecture/ArchitectureBrowser.tsx +240 -0
  63. package/src/app/components/architecture/Detail.tsx +655 -0
  64. package/src/app/components/architecture/ManifestPane.tsx +178 -0
  65. package/src/app/components/architecture/Tree.tsx +205 -0
  66. package/src/app/components/architecture/rows.ts +181 -0
  67. package/src/app/components/architecture/selection.ts +6 -0
  68. package/src/app/components/campaigns/CampaignBrowser.tsx +146 -0
  69. package/src/app/components/campaigns/Detail.tsx +653 -0
  70. package/src/app/components/campaigns/Ladder.tsx +326 -0
  71. package/src/app/components/campaigns/pick.ts +7 -0
  72. package/src/app/components/lib/data.ts +132 -0
  73. package/src/app/pages/index.astro +34 -0
  74. package/src/app/public/favicon.svg +1 -0
  75. package/src/app/styles/theme.css +993 -0
  76. package/src/index.ts +37 -0
  77. package/src/main.ts +155 -0
  78. package/src/model/atlas.ts +751 -0
  79. package/src/model/campaigns.ts +376 -0
  80. package/src/server/collect.ts +132 -0
  81. package/src/server/compose.ts +79 -0
  82. package/src/server/export.ts +24 -0
  83. package/src/server/handler.ts +77 -0
  84. package/src/server/serve.ts +100 -0
  85. package/src/server/site.ts +6 -0
  86. package/src/server/source.ts +89 -0
  87. package/src/server/vite-plugin.ts +32 -0
  88. package/src/server/watch.ts +65 -0
@@ -0,0 +1,653 @@
1
+ import type { SectorNudge } from "@goodbones/campaigns";
2
+
3
+ import type {
4
+ CampaignCard,
5
+ CampaignView,
6
+ ObjectiveCard,
7
+ PhaseCard,
8
+ SectorCard,
9
+ } from "../../../model/campaigns.js";
10
+ import type { Pick } from "./pick.js";
11
+
12
+ // What the CLI would say about the thing under the pointer: `campaigns` for
13
+ // the campaign, `check` for an objective's ledger against the code, `explain`
14
+ // for a sector's files, `campaigns status --changed` for the sectors the
15
+ // working tree touches.
16
+
17
+ type Props = {
18
+ readonly view: CampaignView;
19
+ readonly campaign: CampaignCard;
20
+ readonly shown: Pick;
21
+ readonly pinned: boolean;
22
+ readonly onPick: (pick: Pick | null) => void;
23
+ };
24
+
25
+ const pct = (fraction: number): string => `${String(Math.round(fraction * 100))}%`;
26
+
27
+ const when = (iso: string | null): string => {
28
+ if (iso === null) return "—";
29
+ const date = new Date(iso);
30
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleDateString();
31
+ };
32
+
33
+ const at = (position: { file: string; line: number } | null): string =>
34
+ position === null ? "" : `${position.file}:${String(position.line)}`;
35
+
36
+ export const Detail = ({ campaign, onPick, pinned, shown, view }: Props): React.JSX.Element => (
37
+ <div className="detail">
38
+ <div className="pane-head">
39
+ <span className="eyebrow">{shown.kind}</span>
40
+ {pinned ? (
41
+ <button
42
+ type="button"
43
+ className="small"
44
+ onClick={() => {
45
+ onPick(null);
46
+ }}
47
+ >
48
+ unpin
49
+ </button>
50
+ ) : shown.kind === "campaign" ? null : (
51
+ <span className="hint">click to pin</span>
52
+ )}
53
+ </div>
54
+ {shown.kind === "campaign" ? (
55
+ <CampaignDetail view={view} campaign={campaign} onPick={onPick} />
56
+ ) : shown.kind === "phase" ? (
57
+ <PhaseDetail campaign={campaign} id={shown.id} onPick={onPick} />
58
+ ) : shown.kind === "objective" ? (
59
+ <ObjectiveDetail campaign={campaign} id={shown.id} onPick={onPick} />
60
+ ) : (
61
+ <SectorDetail view={view} campaign={campaign} name={shown.name} onPick={onPick} />
62
+ )}
63
+ </div>
64
+ );
65
+
66
+ const CampaignDetail = ({
67
+ campaign,
68
+ onPick,
69
+ view,
70
+ }: {
71
+ view: CampaignView;
72
+ campaign: CampaignCard;
73
+ onPick: Props["onPick"];
74
+ }): React.JSX.Element => {
75
+ const nudged = view.nudge?.sectors.filter((one) => one.campaign === campaign.id) ?? [];
76
+ return (
77
+ <>
78
+ <h2>{campaign.title ?? campaign.id}</h2>
79
+ <p className="muted small mono">
80
+ {campaign.id}
81
+ {campaign.position === null ? "" : ` · ${at(campaign.position)}`}
82
+ </p>
83
+ {campaign.why !== null ? (
84
+ <>
85
+ <h3>Why</h3>
86
+ <p className="message">{campaign.why}</p>
87
+ </>
88
+ ) : null}
89
+ {campaign.how !== null ? (
90
+ <>
91
+ <h3>How</h3>
92
+ <p className="message">{campaign.how}</p>
93
+ </>
94
+ ) : null}
95
+ <dl className="facts">
96
+ <dt>progress</dt>
97
+ <dd>{pct(campaign.progress)}</dd>
98
+ <dt>holdouts left</dt>
99
+ <dd>{campaign.count}</dd>
100
+ <dt>owner</dt>
101
+ <dd>{campaign.owner ?? "—"}</dd>
102
+ <dt>scope</dt>
103
+ <dd className="mono">{campaign.scope.join(", ")}</dd>
104
+ <dt>perimeter</dt>
105
+ <dd>{campaign.perimeter ?? "none: the scope is the one sector"}</dd>
106
+ <dt>stale after</dt>
107
+ <dd>
108
+ {campaign.staleAfterDays === null ? "never" : `${String(campaign.staleAfterDays)} days`}
109
+ </dd>
110
+ <dt>on completion</dt>
111
+ <dd>{campaign.onComplete}</dd>
112
+ <dt>ledgers</dt>
113
+ <dd className={campaign.ledgered ? "good" : "bad"}>
114
+ {campaign.ledgered
115
+ ? `every objective, under ${view.ledgerDir}/${campaign.id}/`
116
+ : "missing: run `architecture objectives clear`"}
117
+ </dd>
118
+ <dt>arithmetic</dt>
119
+ <dd className={campaign.arithmetic ? "good" : "bad"}>
120
+ {campaign.arithmetic ? "holds" : "does not hold"}
121
+ </dd>
122
+ <dt>legacy</dt>
123
+ <dd>
124
+ {campaign.legacy.files} file{campaign.legacy.files === 1 ? "" : "s"} no sector claims
125
+ {campaign.legacy.holdouts > 0 ? `, ${String(campaign.legacy.holdouts)} holdouts` : ""}
126
+ </dd>
127
+ </dl>
128
+ {campaign.plan.changed.length +
129
+ campaign.plan.unreceipted.length +
130
+ campaign.plan.refined.length >
131
+ 0 ? (
132
+ <>
133
+ <h3>Plan</h3>
134
+ <ul className="list">
135
+ {campaign.plan.unreceipted.map((id) => (
136
+ <li key={`u:${id}`} className="bad">
137
+ phase <span className="mono">{id}</span> changed without a concession
138
+ </li>
139
+ ))}
140
+ {campaign.plan.changed.map((id) => (
141
+ <li key={`c:${id}`}>
142
+ phase <span className="mono">{id}</span> changed, with its receipt
143
+ </li>
144
+ ))}
145
+ {campaign.plan.refined.map((id) => (
146
+ <li key={`r:${id}`}>
147
+ open phase refined into <span className="mono">{id}</span>
148
+ </li>
149
+ ))}
150
+ </ul>
151
+ </>
152
+ ) : null}
153
+ {campaign.drift.length > 0 ? (
154
+ <>
155
+ <h3>Drift</h3>
156
+ <ul className="list">
157
+ {campaign.drift.map((one) => (
158
+ <li key={one.file} className="bad">
159
+ <span className="mono">{one.file}</span> is claimed by {one.sectors.join(" and ")}
160
+ </li>
161
+ ))}
162
+ </ul>
163
+ </>
164
+ ) : null}
165
+ <h3>Objectives</h3>
166
+ <ul className="list">
167
+ {campaign.objectives.map((objective) => (
168
+ <li key={objective.id}>
169
+ <button
170
+ type="button"
171
+ className="link mono"
172
+ onClick={() => {
173
+ onPick({ kind: "objective", id: objective.id });
174
+ }}
175
+ >
176
+ {objective.id}
177
+ </button>
178
+ <span className="muted small">
179
+ {" "}
180
+ {objective.measure === null
181
+ ? `${String(objective.count)} left · ${pct(objective.progress)}`
182
+ : `${String(objective.measure.value ?? "?")} ${objective.measure.direction === "down" ? "↓" : "↑"} ${objective.measure.target === null ? "" : `target ${String(objective.measure.target)}`}`}
183
+ {objective.phase === null ? "" : ` · ${objective.phase}`}
184
+ </span>
185
+ </li>
186
+ ))}
187
+ </ul>
188
+ <h3>Sectors</h3>
189
+ <ul className="list compact">
190
+ {campaign.sectors.map((sector) => (
191
+ <li key={sector.name}>
192
+ <button
193
+ type="button"
194
+ className="link mono"
195
+ onClick={() => {
196
+ onPick({ kind: "sector", name: sector.name });
197
+ }}
198
+ >
199
+ {sector.name}
200
+ </button>
201
+ <span className="muted small">
202
+ {" "}
203
+ {sector.done ? "done" : sector.phaseId === null ? "" : `at ${sector.phaseId}`}
204
+ {sector.stalled ? " · stalled" : ""}
205
+ </span>
206
+ </li>
207
+ ))}
208
+ </ul>
209
+ {view.nudge !== null ? (
210
+ <>
211
+ <h3>Working tree</h3>
212
+ {view.nudge.touched.length === 0 ? (
213
+ <p className="muted">No uncommitted change touches the scope.</p>
214
+ ) : nudged.length === 0 ? (
215
+ <p className="muted">
216
+ {view.nudge.touched.length} file(s) changed, none in a sector of this campaign.
217
+ </p>
218
+ ) : (
219
+ <ul className="list">
220
+ {nudged.map((one) => (
221
+ <li key={one.sector}>
222
+ <button
223
+ type="button"
224
+ className="link mono"
225
+ onClick={() => {
226
+ onPick({ kind: "sector", name: one.sector });
227
+ }}
228
+ >
229
+ {one.sector}
230
+ </button>{" "}
231
+ <NudgeWord nudge={one} />
232
+ </li>
233
+ ))}
234
+ </ul>
235
+ )}
236
+ </>
237
+ ) : null}
238
+ </>
239
+ );
240
+ };
241
+
242
+ const NudgeWord = ({ nudge }: { nudge: SectorNudge }): React.JSX.Element => (
243
+ <span
244
+ className={`flag ${nudge.verdict === "ok" ? "good" : nudge.verdict === "back" ? "bad" : "warn"}`}
245
+ >
246
+ {nudge.verdict}
247
+ {nudge.ask === "none" ? "" : ` · ${nudge.ask}`}
248
+ </span>
249
+ );
250
+
251
+ const PhaseDetail = ({
252
+ campaign,
253
+ id,
254
+ onPick,
255
+ }: {
256
+ campaign: CampaignCard;
257
+ id: string;
258
+ onPick: Props["onPick"];
259
+ }): React.JSX.Element | null => {
260
+ const phase: PhaseCard | undefined = campaign.phases.find((one) => one.id === id);
261
+ if (phase === undefined) return null;
262
+ const here = campaign.sectors.filter((one) => one.phase === phase.index && !one.done);
263
+ return (
264
+ <>
265
+ <h2 className="mono">{phase.id}</h2>
266
+ <p className="muted small">
267
+ phase {phase.index + 1} of {campaign.phases.length}
268
+ {phase.defined ? ", defined" : ", open"}
269
+ {phase.attested ? ", attested" : ""}
270
+ {phase.position === null ? "" : ` · ${at(phase.position)}`}
271
+ </p>
272
+ {phase.intent !== null ? <p className="message">“{phase.intent}”</p> : null}
273
+ {!phase.defined ? (
274
+ <p className="muted">
275
+ An open phase carries intent only. It is refined into defined phases as sectors reach it
276
+ and the next step becomes clear.
277
+ </p>
278
+ ) : null}
279
+ {phase.concessions > 0 ? (
280
+ <p className="muted small">
281
+ {phase.concessions} recorded change{phase.concessions === 1 ? "" : "s"} to this phase.
282
+ </p>
283
+ ) : null}
284
+ <h3>Objectives</h3>
285
+ <ul className="list">
286
+ {phase.objectives.map((objectiveId) => {
287
+ const objective = campaign.objectives.find((one) => one.id === objectiveId);
288
+ return (
289
+ <li key={objectiveId}>
290
+ <button
291
+ type="button"
292
+ className="link mono"
293
+ onClick={() => {
294
+ onPick({ kind: "objective", id: objectiveId });
295
+ }}
296
+ >
297
+ {objectiveId}
298
+ </button>
299
+ {objective === undefined ? null : (
300
+ <span className="muted small">
301
+ {" "}
302
+ {objective.count} left · {pct(objective.progress)}
303
+ </span>
304
+ )}
305
+ </li>
306
+ );
307
+ })}
308
+ {phase.objectives.length === 0 ? <li className="muted">none</li> : null}
309
+ </ul>
310
+ <h3>Sectors standing here</h3>
311
+ <ul className="list compact">
312
+ {here.map((sector) => (
313
+ <li key={sector.name}>
314
+ <button
315
+ type="button"
316
+ className="link mono"
317
+ onClick={() => {
318
+ onPick({ kind: "sector", name: sector.name });
319
+ }}
320
+ >
321
+ {sector.name}
322
+ </button>
323
+ <span className="muted small">
324
+ {" "}
325
+ {Object.entries(sector.residue)
326
+ .filter(([objective]) => phase.objectives.includes(objective))
327
+ .map(([objective, n]) => `${objective}: ${String(n)}`)
328
+ .join(", ")}
329
+ </span>
330
+ </li>
331
+ ))}
332
+ {here.length === 0 ? <li className="muted">none</li> : null}
333
+ </ul>
334
+ </>
335
+ );
336
+ };
337
+
338
+ const ObjectiveDetail = ({
339
+ campaign,
340
+ id,
341
+ onPick,
342
+ }: {
343
+ campaign: CampaignCard;
344
+ id: string;
345
+ onPick: Props["onPick"];
346
+ }): React.JSX.Element | null => {
347
+ const objective: ObjectiveCard | undefined = campaign.objectives.find((one) => one.id === id);
348
+ if (objective === undefined) return null;
349
+ const measure = objective.measure;
350
+ return (
351
+ <>
352
+ <h2 className="mono">{objective.id}</h2>
353
+ <p className="muted small">
354
+ holdout: {objective.holdout}
355
+ {objective.phase === null ? " · in window everywhere" : ` · phase ${objective.phase}`}
356
+ {objective.position === null ? "" : ` · ${at(objective.position)}`}
357
+ </p>
358
+ <p className="message">{objective.why ?? objective.message}</p>
359
+ {measure === null ? (
360
+ <>
361
+ <div
362
+ className="progress"
363
+ title={`${pct(objective.progress)} of the initial holdouts cleared`}
364
+ >
365
+ <div className="progress-bar" style={{ width: pct(objective.progress) }} />
366
+ </div>
367
+ <dl className="facts">
368
+ <dt>left</dt>
369
+ <dd className={objective.count === 0 ? "good" : ""}>{objective.count}</dd>
370
+ <dt>initial</dt>
371
+ <dd>
372
+ {objective.initial}
373
+ {objective.allowed > 0 ? ` +${String(objective.allowed)} conceded` : ""}
374
+ </dd>
375
+ <dt>cleared</dt>
376
+ <dd>{objective.cleared}</dd>
377
+ <dt>closed</dt>
378
+ <dd>{objective.closed}</dd>
379
+ <dt>last cleared</dt>
380
+ <dd>{when(objective.lastCleared)}</dd>
381
+ <dt>concessions</dt>
382
+ <dd>{objective.concessions}</dd>
383
+ <dt>ledger</dt>
384
+ <dd className={objective.ledgered ? "good" : "bad"}>
385
+ {objective.ledgered ? "present" : "missing"}
386
+ </dd>
387
+ </dl>
388
+ </>
389
+ ) : (
390
+ <dl className="facts">
391
+ <dt>now</dt>
392
+ <dd>{measure.value ?? "unmeasured"}</dd>
393
+ <dt>recorded</dt>
394
+ <dd>{measure.recorded ?? "not yet"}</dd>
395
+ <dt>target</dt>
396
+ <dd>{measure.target ?? "standing: only ratchets"}</dd>
397
+ <dt>direction</dt>
398
+ <dd>{measure.direction === "down" ? "lower is better" : "higher is better"}</dd>
399
+ <dt>tolerance</dt>
400
+ <dd>{measure.tolerance}</dd>
401
+ <dt>progress</dt>
402
+ <dd>{pct(objective.progress)}</dd>
403
+ <dt>ledger</dt>
404
+ <dd className={objective.ledgered ? "good" : "bad"}>
405
+ {objective.ledgered ? "present" : "missing"}
406
+ </dd>
407
+ </dl>
408
+ )}
409
+ <h3>By sector</h3>
410
+ {objective.sectors.length === 0 ? (
411
+ <p className="muted">no sector is in this objective's window</p>
412
+ ) : null}
413
+ {objective.sectors.map((sector) => (
414
+ <div key={sector.sector} className="sector-block">
415
+ <div className="sector-row">
416
+ <button
417
+ type="button"
418
+ className="link mono"
419
+ onClick={() => {
420
+ onPick({ kind: "sector", name: sector.sector });
421
+ }}
422
+ >
423
+ {sector.sector}
424
+ </button>
425
+ <span className="muted small">
426
+ {" "}
427
+ {sector.measure === null
428
+ ? `${String(sector.count)} left`
429
+ : `${String(sector.measure.value ?? "?")} now, ${String(sector.measure.recorded ?? "?")} recorded, ${sector.measure.standing}`}
430
+ {sector.unrecorded ? " · unrecorded" : ""}
431
+ {sector.initial === null
432
+ ? ""
433
+ : ` · ${String(sector.initial)} initially, ${String(sector.cleared ?? 0)} cleared`}
434
+ </span>
435
+ </div>
436
+ {sector.new.length > 0 ? (
437
+ <ul className="list compact">
438
+ {sector.new.map((entry) => (
439
+ <li key={`new:${entry}`} className="bad mono small">
440
+ + {entry} <span className="muted">(not in the ledger)</span>
441
+ </li>
442
+ ))}
443
+ </ul>
444
+ ) : null}
445
+ {sector.stale.length > 0 ? (
446
+ <ul className="list compact">
447
+ {sector.stale.map((entry) => (
448
+ <li key={`stale:${entry}`} className="good mono small">
449
+ − {entry} <span className="muted">(cleared, run `objectives clear`)</span>
450
+ </li>
451
+ ))}
452
+ </ul>
453
+ ) : null}
454
+ {sector.holdouts.length > 0 ? (
455
+ <details>
456
+ <summary className="muted small">{sector.holdouts.length} in the ledger</summary>
457
+ <ul className="list compact">
458
+ {sector.holdouts.map((entry) => (
459
+ <li key={entry} className="mono small">
460
+ {entry}
461
+ </li>
462
+ ))}
463
+ </ul>
464
+ </details>
465
+ ) : null}
466
+ </div>
467
+ ))}
468
+ </>
469
+ );
470
+ };
471
+
472
+ const SectorDetail = ({
473
+ campaign,
474
+ name,
475
+ onPick,
476
+ view,
477
+ }: {
478
+ view: CampaignView;
479
+ campaign: CampaignCard;
480
+ name: string;
481
+ onPick: Props["onPick"];
482
+ }): React.JSX.Element | null => {
483
+ const sector: SectorCard | undefined = campaign.sectors.find((one) => one.name === name);
484
+ if (sector === undefined) return null;
485
+ const nudge = view.nudge?.sectors.find(
486
+ (one) => one.campaign === campaign.id && one.sector === name,
487
+ );
488
+ return (
489
+ <>
490
+ <h2 className="mono">
491
+ {sector.name}
492
+ {sector.legacy ? <span className="flag warn">legacy</span> : null}
493
+ {sector.done ? <span className="flag good">done</span> : null}
494
+ {sector.stalled ? <span className="flag bad">stalled</span> : null}
495
+ </h2>
496
+ {sector.legacy ? (
497
+ <p className="muted">What no sector has claimed. It stands at the first phase.</p>
498
+ ) : null}
499
+ <dl className="facts">
500
+ <dt>phase</dt>
501
+ <dd>
502
+ {sector.done
503
+ ? "end state"
504
+ : (sector.phaseId ?? (campaign.phases.length === 0 ? "standing" : "—"))}
505
+ </dd>
506
+ <dt>reached</dt>
507
+ <dd>
508
+ {sector.reached ?? "not placed by `clear` yet"}
509
+ {sector.since === null ? "" : ` since ${when(sector.since)}`}
510
+ </dd>
511
+ <dt>files</dt>
512
+ <dd>{sector.files.length}</dd>
513
+ {sector.marker !== null ? (
514
+ <>
515
+ <dt>marker</dt>
516
+ <dd className="mono">{sector.marker}</dd>
517
+ </>
518
+ ) : null}
519
+ <dt>roots</dt>
520
+ <dd className="mono">{sector.roots.length === 0 ? "—" : sector.roots.join(", ")}</dd>
521
+ </dl>
522
+ <h3>Residue</h3>
523
+ <ul className="list">
524
+ {campaign.objectives.map((objective) => {
525
+ const count = sector.residue[objective.id] ?? sector.counts[objective.id];
526
+ const value = sector.values[objective.id];
527
+ return (
528
+ <li key={objective.id}>
529
+ <button
530
+ type="button"
531
+ className="link mono"
532
+ onClick={() => {
533
+ onPick({ kind: "objective", id: objective.id });
534
+ }}
535
+ >
536
+ {objective.id}
537
+ </button>
538
+ <span className={`muted small ${(count ?? 0) > 0 ? "" : "good"}`}>
539
+ {" "}
540
+ {objective.measure !== null
541
+ ? value === undefined
542
+ ? "unmeasured"
543
+ : `${String(value)}${objective.measure.target === null ? "" : ` → ${String(objective.measure.target)}`}`
544
+ : count === undefined
545
+ ? "not in window"
546
+ : `${String(count)} left`}
547
+ </span>
548
+ </li>
549
+ );
550
+ })}
551
+ </ul>
552
+ {nudge !== undefined ? (
553
+ <>
554
+ <h3>Working tree</h3>
555
+ <p>
556
+ <NudgeWord nudge={nudge} />{" "}
557
+ <span className="muted small">
558
+ direction {nudge.direction} · onTouch {nudge.onTouch}
559
+ </span>
560
+ </p>
561
+ {nudge.holdouts.shown.length > 0 ? (
562
+ <ul className="list compact">
563
+ {nudge.holdouts.shown.map((one) => (
564
+ <li key={`${one.file}:${one.subject ?? ""}`} className="small">
565
+ <span className="mono">
566
+ {one.file}
567
+ {one.line === null ? "" : `:${String(one.line)}`}
568
+ </span>
569
+ <div className="muted">{one.message}</div>
570
+ </li>
571
+ ))}
572
+ {nudge.holdouts.total > nudge.holdouts.shown.length ? (
573
+ <li className="muted small">
574
+ … {nudge.holdouts.total - nudge.holdouts.shown.length} more
575
+ </li>
576
+ ) : null}
577
+ </ul>
578
+ ) : null}
579
+ {nudge.belongsInSector.length > 0 ? (
580
+ <p className="bad small">
581
+ Added inside the scope, in no sector: {nudge.belongsInSector.join(", ")}
582
+ </p>
583
+ ) : null}
584
+ </>
585
+ ) : null}
586
+ {sector.attested.length > 0 ? (
587
+ <>
588
+ <h3>Attestations</h3>
589
+ <ul className="list">
590
+ {sector.attested.map((one) => (
591
+ <li key={`${one.phase}:${one.at}`}>
592
+ <span className="mono">{one.phase}</span> — {one.reason}
593
+ <div className="muted small">
594
+ {one.by}, {when(one.at)}
595
+ {one.evidence === undefined ? "" : ` · ${one.evidence}`}
596
+ </div>
597
+ </li>
598
+ ))}
599
+ </ul>
600
+ </>
601
+ ) : null}
602
+ {sector.notes.length > 0 ? (
603
+ <>
604
+ <h3>Notes</h3>
605
+ <ul className="list">
606
+ {sector.notes.map((one) => (
607
+ <li key={`${one.at}:${one.by}`}>
608
+ {one.text}
609
+ <div className="muted small">
610
+ {one.by}, {when(one.at)}
611
+ {one.phase === null ? "" : ` · at ${one.phase}`}
612
+ </div>
613
+ </li>
614
+ ))}
615
+ </ul>
616
+ </>
617
+ ) : null}
618
+ <h3>Holdouts</h3>
619
+ {sector.hits.length === 0 ? (
620
+ <p className="good">none firing</p>
621
+ ) : (
622
+ <ul className="list compact">
623
+ {sector.hits.slice(0, 80).map((hit) => (
624
+ <li key={`${hit.objective}:${hit.entry}`} className="small">
625
+ <span className="mono">
626
+ {hit.file}
627
+ {hit.line === null ? "" : `:${String(hit.line)}`}
628
+ </span>
629
+ <span className="muted">
630
+ {" "}
631
+ {hit.objective}
632
+ {hit.subject === null ? "" : ` · ${hit.subject}`}
633
+ </span>
634
+ </li>
635
+ ))}
636
+ {sector.hits.length > 80 ? (
637
+ <li className="muted">… {sector.hits.length - 80} more</li>
638
+ ) : null}
639
+ </ul>
640
+ )}
641
+ <details>
642
+ <summary className="muted small">{sector.files.length} files</summary>
643
+ <ul className="list compact">
644
+ {sector.files.map((file) => (
645
+ <li key={file} className="mono small">
646
+ {file}
647
+ </li>
648
+ ))}
649
+ </ul>
650
+ </details>
651
+ </>
652
+ );
653
+ };