@skitterbyte/skitterspec-linear 11.0.0 → 13.0.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/MIGRATION.md +260 -10
- package/README.md +32 -2
- package/assets/claude-md-section.md +48 -2
- package/assets/commands/spec-connect.md +2 -2
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/SETUP.md +21 -3
- package/assets/core/env.config.json.example +9 -3
- package/assets/core/env.config.md +102 -30
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/linear.config.md +67 -8
- package/assets/review/page.html +1501 -0
- package/assets/rules/spec-planning.md +224 -15
- package/assets/rules/spec-reports.md +269 -0
- package/assets/skills/spec/SKILL.md +64 -13
- package/assets/skills/spec-bug/SKILL.md +193 -27
- package/assets/skills/spec-cancel/SKILL.md +99 -8
- package/assets/skills/spec-claim/SKILL.md +114 -0
- package/assets/skills/spec-complete/SKILL.md +123 -22
- package/assets/skills/spec-diff/SKILL.md +564 -0
- package/assets/skills/spec-hotfix/SKILL.md +202 -22
- package/assets/skills/spec-init/SKILL.md +49 -9
- package/assets/skills/spec-linear-setup/SKILL.md +86 -7
- package/assets/skills/spec-list/SKILL.md +218 -0
- package/assets/skills/spec-next/SKILL.md +300 -7
- package/assets/skills/spec-push/SKILL.md +45 -22
- package/assets/skills/spec-review/SKILL.md +59 -11
- package/assets/skills/spec-reviewed/SKILL.md +241 -0
- package/assets/skills/spec-start/SKILL.md +426 -66
- package/assets/skills/spec-status/SKILL.md +24 -2
- package/assets/skills/spec-sync/SKILL.md +47 -11
- package/assets/skills/spec-to-main/SKILL.md +42 -20
- package/package.json +11 -7
- package/src/cli.js +1710 -80
- package/src/env/building.js +143 -0
- package/src/env/classify.js +91 -0
- package/src/env/config.js +57 -9
- package/src/env/provision.js +192 -19
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +296 -9
- package/src/env/review.js +1329 -0
- package/src/env/serve.js +549 -0
- package/src/env/teardown.js +13 -6
- package/src/gating.js +155 -0
- package/src/init.js +124 -2
- package/src/prompts.js +10 -1
- package/src/vendor/linear/api.js +104 -1
- package/src/vendor/linear/cli-sync.js +874 -17
- package/src/vendor/linear/config.js +8 -0
- package/src/vendor/linear/credentials.js +94 -0
- package/src/vendor/linear/doctor.js +35 -0
- package/src/vendor/linear/identity.js +105 -0
- package/src/vendor/linear/mcp.js +26 -0
- package/src/vendor/sync-core/index.js +6 -2
- package/src/vendor/sync-core/src/compare.js +74 -5
- package/src/vendor/sync-core/src/normalize.js +30 -0
- package/src/vendor/sync-core/src/push.js +11 -1
- package/src/vendor/sync-core/src/write.js +38 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,1501 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>__REVIEW_TITLE__</title>
|
|
7
|
+
<style>
|
|
8
|
+
/*
|
|
9
|
+
* THE COMPLETE LIGHT PALETTE LIVES HERE, on bare :root — every token this page
|
|
10
|
+
* uses is declared in this block and nowhere else gains a first definition. The
|
|
11
|
+
* two blocks below only REDEFINE. A colour whose sole definition sits inside a
|
|
12
|
+
* media query or a [data-theme] block is the classic unreadable-in-one-theme
|
|
13
|
+
* bug, and a test asserts against it.
|
|
14
|
+
*/
|
|
15
|
+
:root {
|
|
16
|
+
--bg: #ffffff;
|
|
17
|
+
--fg: #1b1b1f;
|
|
18
|
+
--muted: #5c6070;
|
|
19
|
+
--line: #e1e3e8;
|
|
20
|
+
--panel: #f6f7f9;
|
|
21
|
+
--panel-2: #eceef2;
|
|
22
|
+
--accent: #0b5fd0;
|
|
23
|
+
--focus: #0b5fd0;
|
|
24
|
+
--add-bg: #e8fbee;
|
|
25
|
+
--add-fg: #04441c;
|
|
26
|
+
--add-mark: #1a7f37;
|
|
27
|
+
--del-bg: #fdecea;
|
|
28
|
+
--del-fg: #4c0a12;
|
|
29
|
+
--del-mark: #b8262f;
|
|
30
|
+
--band-bg: #f0f3f8;
|
|
31
|
+
--band-fg: #46506a;
|
|
32
|
+
--ln-fg: #9aa0ae;
|
|
33
|
+
--chip-bg: #eceef2;
|
|
34
|
+
--chip-fg: #46506a;
|
|
35
|
+
--flag-fg: #a3400b;
|
|
36
|
+
--flag-bg: #fdf0e6;
|
|
37
|
+
--confirm-fg: #6a4c05;
|
|
38
|
+
--confirm-bg: #fdf7e3;
|
|
39
|
+
--good-fg: #1a7f37;
|
|
40
|
+
--good-bg: #eaf8ee;
|
|
41
|
+
--note-bg: #eef4ff;
|
|
42
|
+
--note-fg: #1b2b4d;
|
|
43
|
+
--note-line: #b9cdf2;
|
|
44
|
+
--ln-w: 4.5ch;
|
|
45
|
+
}
|
|
46
|
+
:root:not([data-theme="light"]) {
|
|
47
|
+
@media (prefers-color-scheme: dark) {
|
|
48
|
+
--bg: #121316;
|
|
49
|
+
--fg: #e4e6ea;
|
|
50
|
+
--muted: #969bab;
|
|
51
|
+
--line: #2a2c34;
|
|
52
|
+
--panel: #1a1c21;
|
|
53
|
+
--panel-2: #23262d;
|
|
54
|
+
--accent: #7fb0ff;
|
|
55
|
+
--focus: #7fb0ff;
|
|
56
|
+
--add-bg: #0e2a18;
|
|
57
|
+
--add-fg: #b7f0c4;
|
|
58
|
+
--add-mark: #4ac26b;
|
|
59
|
+
--del-bg: #2e1215;
|
|
60
|
+
--del-fg: #ffc9c4;
|
|
61
|
+
--del-mark: #f2685f;
|
|
62
|
+
--band-bg: #1e2128;
|
|
63
|
+
--band-fg: #a3aabd;
|
|
64
|
+
--ln-fg: #6b7183;
|
|
65
|
+
--chip-bg: #23262d;
|
|
66
|
+
--chip-fg: #a3aabd;
|
|
67
|
+
--flag-fg: #ffb083;
|
|
68
|
+
--flag-bg: #2e1a0d;
|
|
69
|
+
--confirm-fg: #f2d88a;
|
|
70
|
+
--confirm-bg: #2a2412;
|
|
71
|
+
--good-fg: #7ee08a;
|
|
72
|
+
--good-bg: #12261a;
|
|
73
|
+
--note-bg: #16202e;
|
|
74
|
+
--note-fg: #cfe0fa;
|
|
75
|
+
--note-line: #35486a;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
:root[data-theme="dark"] {
|
|
79
|
+
--bg: #121316;
|
|
80
|
+
--fg: #e4e6ea;
|
|
81
|
+
--muted: #969bab;
|
|
82
|
+
--line: #2a2c34;
|
|
83
|
+
--panel: #1a1c21;
|
|
84
|
+
--panel-2: #23262d;
|
|
85
|
+
--accent: #7fb0ff;
|
|
86
|
+
--focus: #7fb0ff;
|
|
87
|
+
--add-bg: #0e2a18;
|
|
88
|
+
--add-fg: #b7f0c4;
|
|
89
|
+
--add-mark: #4ac26b;
|
|
90
|
+
--del-bg: #2e1215;
|
|
91
|
+
--del-fg: #ffc9c4;
|
|
92
|
+
--del-mark: #f2685f;
|
|
93
|
+
--band-bg: #1e2128;
|
|
94
|
+
--band-fg: #a3aabd;
|
|
95
|
+
--ln-fg: #6b7183;
|
|
96
|
+
--chip-bg: #23262d;
|
|
97
|
+
--chip-fg: #a3aabd;
|
|
98
|
+
--flag-fg: #ffb083;
|
|
99
|
+
--flag-bg: #2e1a0d;
|
|
100
|
+
--confirm-fg: #f2d88a;
|
|
101
|
+
--confirm-bg: #2a2412;
|
|
102
|
+
--good-fg: #7ee08a;
|
|
103
|
+
--good-bg: #12261a;
|
|
104
|
+
--note-bg: #16202e;
|
|
105
|
+
--note-fg: #cfe0fa;
|
|
106
|
+
--note-line: #35486a;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
* { box-sizing: border-box; }
|
|
110
|
+
|
|
111
|
+
body {
|
|
112
|
+
margin: 0;
|
|
113
|
+
/* Explicit, from a token: a transparent body borrows the host's ground. */
|
|
114
|
+
background: var(--bg);
|
|
115
|
+
color: var(--fg);
|
|
116
|
+
font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.wrap { max-width: 1400px; margin: 0 auto; padding: 1.25rem 1rem 5rem; }
|
|
120
|
+
|
|
121
|
+
header { margin-bottom: 1rem; }
|
|
122
|
+
h1 { font-size: 1.3rem; margin: 0 0 .3rem; overflow-wrap: anywhere; }
|
|
123
|
+
.sub { color: var(--muted); font-size: .85rem; margin: 0; overflow-wrap: anywhere; }
|
|
124
|
+
.sub .sep { opacity: .5; padding: 0 .35rem; }
|
|
125
|
+
.a { color: var(--add-mark); }
|
|
126
|
+
.d { color: var(--del-mark); }
|
|
127
|
+
|
|
128
|
+
.controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: .9rem 0 0; }
|
|
129
|
+
|
|
130
|
+
button, .toggle {
|
|
131
|
+
font: inherit;
|
|
132
|
+
font-size: .8rem;
|
|
133
|
+
color: var(--chip-fg);
|
|
134
|
+
background: var(--chip-bg);
|
|
135
|
+
border: 1px solid var(--line);
|
|
136
|
+
border-radius: 999px;
|
|
137
|
+
padding: .28rem .7rem;
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
}
|
|
140
|
+
button:hover, .toggle:hover { border-color: var(--accent); color: var(--accent); }
|
|
141
|
+
/* Every control gets a VISIBLE focus ring — keyboard review is review too. */
|
|
142
|
+
button:focus-visible,
|
|
143
|
+
summary:focus-visible,
|
|
144
|
+
input:focus-visible,
|
|
145
|
+
.toggle:focus-visible {
|
|
146
|
+
outline: 2px solid var(--focus);
|
|
147
|
+
outline-offset: 2px;
|
|
148
|
+
}
|
|
149
|
+
.toggle { display: inline-flex; align-items: center; gap: .4rem; user-select: none; }
|
|
150
|
+
.toggle input { accent-color: var(--accent); margin: 0; }
|
|
151
|
+
|
|
152
|
+
.layout { display: grid; grid-template-columns: 1fr; gap: 1rem; align-items: start; }
|
|
153
|
+
@media (min-width: 60rem) {
|
|
154
|
+
.layout { grid-template-columns: 17rem minmax(0, 1fr); }
|
|
155
|
+
.tree-wrap { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow: auto; }
|
|
156
|
+
}
|
|
157
|
+
/* min-width:0 is what stops a wide table forcing the PAGE to scroll sideways. */
|
|
158
|
+
.main { min-width: 0; }
|
|
159
|
+
|
|
160
|
+
.tree-wrap {
|
|
161
|
+
border: 1px solid var(--line);
|
|
162
|
+
border-radius: 10px;
|
|
163
|
+
background: var(--panel);
|
|
164
|
+
}
|
|
165
|
+
.tree-wrap > summary {
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
padding: .55rem .75rem;
|
|
168
|
+
font-size: .85rem;
|
|
169
|
+
font-weight: 600;
|
|
170
|
+
}
|
|
171
|
+
.tree { padding: 0 .6rem .7rem; font-size: .82rem; }
|
|
172
|
+
.tree ul { list-style: none; margin: 0; padding-left: .8rem; }
|
|
173
|
+
.tree > ul { padding-left: 0; }
|
|
174
|
+
.tree .dir { color: var(--muted); display: block; padding: .1rem 0; }
|
|
175
|
+
.tree-file {
|
|
176
|
+
display: flex;
|
|
177
|
+
gap: .4rem;
|
|
178
|
+
width: 100%;
|
|
179
|
+
text-align: left;
|
|
180
|
+
background: none;
|
|
181
|
+
border: 0;
|
|
182
|
+
border-radius: 6px;
|
|
183
|
+
padding: .15rem .3rem;
|
|
184
|
+
color: inherit;
|
|
185
|
+
font-size: .82rem;
|
|
186
|
+
}
|
|
187
|
+
.tree-file:hover { background: var(--panel-2); color: inherit; }
|
|
188
|
+
.tree-file .nm { overflow-wrap: anywhere; }
|
|
189
|
+
.tree-file .ct { margin-left: auto; white-space: nowrap; font-family: ui-monospace, monospace; font-size: .75rem; }
|
|
190
|
+
.tree-file.is-noise .nm { color: var(--muted); }
|
|
191
|
+
|
|
192
|
+
.file {
|
|
193
|
+
border: 1px solid var(--line);
|
|
194
|
+
border-radius: 10px;
|
|
195
|
+
margin: 0 0 .9rem;
|
|
196
|
+
overflow: hidden;
|
|
197
|
+
background: var(--bg);
|
|
198
|
+
}
|
|
199
|
+
.file > summary {
|
|
200
|
+
cursor: pointer;
|
|
201
|
+
padding: .55rem .7rem;
|
|
202
|
+
background: var(--panel);
|
|
203
|
+
display: flex;
|
|
204
|
+
gap: .55rem;
|
|
205
|
+
align-items: baseline;
|
|
206
|
+
flex-wrap: wrap;
|
|
207
|
+
}
|
|
208
|
+
.file[hidden] { display: none; }
|
|
209
|
+
.path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; overflow-wrap: anywhere; }
|
|
210
|
+
.chip {
|
|
211
|
+
font-size: .68rem;
|
|
212
|
+
text-transform: uppercase;
|
|
213
|
+
letter-spacing: .04em;
|
|
214
|
+
color: var(--chip-fg);
|
|
215
|
+
background: var(--chip-bg);
|
|
216
|
+
border-radius: 999px;
|
|
217
|
+
padding: .1rem .45rem;
|
|
218
|
+
}
|
|
219
|
+
.counts { margin-left: auto; font-size: .78rem; font-family: ui-monospace, monospace; white-space: nowrap; }
|
|
220
|
+
|
|
221
|
+
/* The ONLY horizontally scrolling box on the page. */
|
|
222
|
+
.code { overflow-x: auto; border-top: 1px solid var(--line); }
|
|
223
|
+
table.diff { border-collapse: collapse; width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }
|
|
224
|
+
table.diff td { padding: 0; vertical-align: top; }
|
|
225
|
+
|
|
226
|
+
/* One sticky gutter cell rather than three: no left-offset arithmetic to get
|
|
227
|
+
wrong, and user-select:none means copying the code does not drag line
|
|
228
|
+
numbers along with it. */
|
|
229
|
+
td.gutter {
|
|
230
|
+
position: sticky;
|
|
231
|
+
left: 0;
|
|
232
|
+
z-index: 1;
|
|
233
|
+
background: var(--panel);
|
|
234
|
+
border-right: 1px solid var(--line);
|
|
235
|
+
user-select: none;
|
|
236
|
+
white-space: pre;
|
|
237
|
+
color: var(--ln-fg);
|
|
238
|
+
padding: 0 .4rem;
|
|
239
|
+
text-align: right;
|
|
240
|
+
}
|
|
241
|
+
td.gutter .n { display: inline-block; min-width: var(--ln-w); }
|
|
242
|
+
td.gutter .s { display: inline-block; min-width: 1.2ch; text-align: center; font-weight: 700; }
|
|
243
|
+
td.src { white-space: pre; padding: 0 .75rem; }
|
|
244
|
+
|
|
245
|
+
tr.add td.src { background: var(--add-bg); color: var(--add-fg); }
|
|
246
|
+
tr.add td.gutter .s { color: var(--add-mark); }
|
|
247
|
+
tr.del td.src { background: var(--del-bg); color: var(--del-fg); }
|
|
248
|
+
tr.del td.gutter .s { color: var(--del-mark); }
|
|
249
|
+
tr.meta td.src { color: var(--muted); font-style: italic; }
|
|
250
|
+
|
|
251
|
+
tr.band td { background: var(--band-bg); color: var(--band-fg); }
|
|
252
|
+
tr.band td.src { padding: .2rem .75rem; white-space: normal; }
|
|
253
|
+
tr.band .n-hidden { margin-right: .6rem; }
|
|
254
|
+
tr.band button { font-size: .72rem; padding: .1rem .5rem; margin-right: .3rem; }
|
|
255
|
+
|
|
256
|
+
.note { color: var(--muted); padding: .6rem .75rem; font-size: .85rem; }
|
|
257
|
+
.empty { color: var(--muted); }
|
|
258
|
+
#review-block:empty { display: none; }
|
|
259
|
+
.review {
|
|
260
|
+
border: 1px solid var(--line);
|
|
261
|
+
border-left: 3px solid var(--accent);
|
|
262
|
+
border-radius: 10px;
|
|
263
|
+
background: var(--panel);
|
|
264
|
+
padding: .8rem 1rem;
|
|
265
|
+
margin: 0 0 1rem;
|
|
266
|
+
}
|
|
267
|
+
.review-summary { margin: 0; }
|
|
268
|
+
.checks { list-style: none; margin: .8rem 0 0; padding: 0; display: grid; gap: .4rem; }
|
|
269
|
+
.check {
|
|
270
|
+
display: grid;
|
|
271
|
+
grid-template-columns: auto 1fr;
|
|
272
|
+
gap: .2rem .6rem;
|
|
273
|
+
align-items: baseline;
|
|
274
|
+
padding: .4rem .6rem;
|
|
275
|
+
border-radius: 8px;
|
|
276
|
+
font-size: .88rem;
|
|
277
|
+
}
|
|
278
|
+
/* The level is a WORD, not a colour — the same reason the diff keeps its +/-
|
|
279
|
+
gutter. Colour is the second signal here, never the only one. */
|
|
280
|
+
.check-level {
|
|
281
|
+
font-size: .68rem;
|
|
282
|
+
text-transform: uppercase;
|
|
283
|
+
letter-spacing: .05em;
|
|
284
|
+
font-weight: 700;
|
|
285
|
+
grid-row: 1 / span 2;
|
|
286
|
+
}
|
|
287
|
+
.check-file {
|
|
288
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
289
|
+
font-size: .78rem;
|
|
290
|
+
color: var(--muted);
|
|
291
|
+
overflow-wrap: anywhere;
|
|
292
|
+
}
|
|
293
|
+
.check-note { grid-column: 2; }
|
|
294
|
+
.check.flag { background: var(--flag-bg); }
|
|
295
|
+
.check.flag .check-level { color: var(--flag-fg); }
|
|
296
|
+
.check.confirm { background: var(--confirm-bg); }
|
|
297
|
+
.check.confirm .check-level { color: var(--confirm-fg); }
|
|
298
|
+
.check.good { background: var(--good-bg); }
|
|
299
|
+
.check.good .check-level { color: var(--good-fg); }
|
|
300
|
+
|
|
301
|
+
/* --- review marks: accepts, comments, replies ---------------------------- */
|
|
302
|
+
/* Every colour below comes from a token defined on bare :root above; nothing
|
|
303
|
+
here gains its first definition inside a theme block. */
|
|
304
|
+
.accept[aria-pressed="true"] {
|
|
305
|
+
background: var(--good-bg);
|
|
306
|
+
color: var(--good-fg);
|
|
307
|
+
border-color: var(--good-fg);
|
|
308
|
+
}
|
|
309
|
+
.chip.accepted { background: var(--good-bg); color: var(--good-fg); }
|
|
310
|
+
.chip.lapsed {
|
|
311
|
+
background: var(--confirm-bg);
|
|
312
|
+
color: var(--confirm-fg);
|
|
313
|
+
text-transform: none;
|
|
314
|
+
letter-spacing: 0;
|
|
315
|
+
}
|
|
316
|
+
.chip.commented { background: var(--note-bg); color: var(--note-fg); }
|
|
317
|
+
|
|
318
|
+
/* The gutter is already user-select:none, so clicking it can never fight with
|
|
319
|
+
selecting the code beside it — which is why the affordance lives there. */
|
|
320
|
+
td.gutter.can-note { cursor: pointer; }
|
|
321
|
+
td.gutter.can-note:hover { color: var(--accent); }
|
|
322
|
+
tr.has-note td.gutter { box-shadow: inset 2px 0 0 var(--note-line); }
|
|
323
|
+
|
|
324
|
+
tr.note-row td {
|
|
325
|
+
background: var(--note-bg);
|
|
326
|
+
color: var(--note-fg);
|
|
327
|
+
white-space: normal;
|
|
328
|
+
padding: .45rem .75rem;
|
|
329
|
+
border-top: 1px solid var(--note-line);
|
|
330
|
+
border-bottom: 1px solid var(--note-line);
|
|
331
|
+
}
|
|
332
|
+
.note-meta { font-size: .72rem; color: var(--muted); display: block; margin-bottom: .15rem; }
|
|
333
|
+
.note-text { overflow-wrap: anywhere; }
|
|
334
|
+
.note-row.is-resolved .note-text { text-decoration: line-through; opacity: .75; }
|
|
335
|
+
.note-resolution { display: block; font-size: .82rem; margin-top: .2rem; }
|
|
336
|
+
.note-input {
|
|
337
|
+
width: 100%;
|
|
338
|
+
font: inherit;
|
|
339
|
+
font-size: .85rem;
|
|
340
|
+
color: var(--fg);
|
|
341
|
+
background: var(--bg);
|
|
342
|
+
border: 1px solid var(--note-line);
|
|
343
|
+
border-radius: 8px;
|
|
344
|
+
padding: .4rem .5rem;
|
|
345
|
+
resize: vertical;
|
|
346
|
+
}
|
|
347
|
+
.note-input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
348
|
+
.note-actions { display: flex; gap: .4rem; margin-top: .4rem; flex-wrap: wrap; }
|
|
349
|
+
|
|
350
|
+
.strip {
|
|
351
|
+
border-top: 1px solid var(--line);
|
|
352
|
+
background: var(--note-bg);
|
|
353
|
+
color: var(--note-fg);
|
|
354
|
+
padding: .5rem .75rem;
|
|
355
|
+
font-size: .85rem;
|
|
356
|
+
display: grid;
|
|
357
|
+
gap: .4rem;
|
|
358
|
+
}
|
|
359
|
+
.strip .note-input { font-size: .85rem; }
|
|
360
|
+
|
|
361
|
+
.reply { grid-column: 2; margin-top: .35rem; }
|
|
362
|
+
.reply .note-input { font-size: .85rem; }
|
|
363
|
+
.reply-given {
|
|
364
|
+
grid-column: 2;
|
|
365
|
+
margin-top: .35rem;
|
|
366
|
+
font-size: .85rem;
|
|
367
|
+
border-left: 2px solid var(--note-line);
|
|
368
|
+
padding-left: .5rem;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* --- the verdict bar ----------------------------------------------------- */
|
|
372
|
+
/* Every colour here is an existing token: approve borrows the `good` pair and
|
|
373
|
+
changes the `flag` pair, so the bar needs no palette of its own. */
|
|
374
|
+
.verdict {
|
|
375
|
+
display: flex;
|
|
376
|
+
flex-wrap: wrap;
|
|
377
|
+
gap: .5rem;
|
|
378
|
+
align-items: center;
|
|
379
|
+
margin: .9rem 0 0;
|
|
380
|
+
padding: .6rem .7rem;
|
|
381
|
+
border: 1px solid var(--line);
|
|
382
|
+
border-radius: 10px;
|
|
383
|
+
background: var(--panel);
|
|
384
|
+
}
|
|
385
|
+
.verdict-end { margin: 1.2rem 0 0; }
|
|
386
|
+
.verdict-title { font-size: .8rem; color: var(--muted); }
|
|
387
|
+
.verdict-count { font-size: .8rem; color: var(--muted); }
|
|
388
|
+
.v-commit { color: var(--good-fg); background: var(--good-bg); }
|
|
389
|
+
.v-changes { color: var(--flag-fg); background: var(--flag-bg); }
|
|
390
|
+
/* A blocked control is dashed as well as dimmed, so the block survives a
|
|
391
|
+
greyscale screen — and the reason rides in the label, never in a tooltip
|
|
392
|
+
nobody hovers on a phone. */
|
|
393
|
+
button:disabled { color: var(--muted); background: var(--panel-2); border-style: dashed; cursor: not-allowed; }
|
|
394
|
+
button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
395
|
+
.verdict-log { font-size: .8rem; color: var(--muted); margin: .45rem 0 0; }
|
|
396
|
+
.verdict-log[hidden] { display: none; }
|
|
397
|
+
/* Which engine drew this page. A render is always current and a stale RENDERER
|
|
398
|
+
is invisible without it — the counts move, the date moves, the diff is right. */
|
|
399
|
+
.drawn-by { font-size: .75rem; color: var(--muted); margin: .9rem 0 0; }
|
|
400
|
+
|
|
401
|
+
.copy-out {
|
|
402
|
+
width: 100%;
|
|
403
|
+
min-height: 8rem;
|
|
404
|
+
margin-top: .6rem;
|
|
405
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
406
|
+
font-size: .75rem;
|
|
407
|
+
color: var(--fg);
|
|
408
|
+
background: var(--panel);
|
|
409
|
+
border: 1px solid var(--line);
|
|
410
|
+
border-radius: 8px;
|
|
411
|
+
padding: .5rem;
|
|
412
|
+
}
|
|
413
|
+
.copy-out[hidden] { display: none; }
|
|
414
|
+
.context { margin: 0 0 1rem; padding: .75rem .9rem; border: 1px solid var(--line); border-radius: 6px; background: var(--panel, transparent); }
|
|
415
|
+
.context h3 { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: .9rem 0 .35rem; }
|
|
416
|
+
.context h3:first-child { margin-top: 0; }
|
|
417
|
+
.context p { margin: 0 0 .5rem; line-height: 1.5; }
|
|
418
|
+
.context p:last-child { margin-bottom: 0; }
|
|
419
|
+
.context table { border-collapse: collapse; width: 100%; font-size: .85rem; }
|
|
420
|
+
.context th, .context td { text-align: left; padding: .2rem .5rem .2rem 0; vertical-align: top; }
|
|
421
|
+
.context th { color: var(--muted); font-weight: 600; }
|
|
422
|
+
.context ul { margin: 0; padding-left: 1.1rem; }
|
|
423
|
+
.context li { margin: .15rem 0; line-height: 1.45; }
|
|
424
|
+
.context li.done { color: var(--muted); }
|
|
425
|
+
.context-more > summary { cursor: pointer; color: var(--muted); font-size: .85rem; margin-top: .6rem; }
|
|
426
|
+
.copy-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 0; }
|
|
427
|
+
.sent-cmd { display: flex; gap: .4rem; align-items: stretch; margin: .4rem 0 0; }
|
|
428
|
+
.sent-cmd-text {
|
|
429
|
+
flex: 1; min-width: 0; font-family: var(--mono, ui-monospace, monospace);
|
|
430
|
+
font-size: .85rem; padding: .35rem .5rem; color: var(--fg);
|
|
431
|
+
background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
|
|
432
|
+
}
|
|
433
|
+
.sent-cmd-copy { white-space: nowrap; }
|
|
434
|
+
/* The claim code is read aloud or copied, so it is set apart from the sentence
|
|
435
|
+
around it rather than buried in it. */
|
|
436
|
+
.copy-hint { user-select: text; }
|
|
437
|
+
</style>
|
|
438
|
+
</head>
|
|
439
|
+
<body>
|
|
440
|
+
<div class="wrap">
|
|
441
|
+
<header>
|
|
442
|
+
<h1 id="title"></h1>
|
|
443
|
+
<p class="sub" id="sub"></p>
|
|
444
|
+
<div class="controls">
|
|
445
|
+
<button id="expand-all" type="button">Show all context</button>
|
|
446
|
+
<button id="collapse-all" type="button">Collapse context</button>
|
|
447
|
+
<label class="toggle"><input type="checkbox" id="show-noise"> <span id="noise-label">Show bookkeeping</span></label>
|
|
448
|
+
<button id="theme" type="button" aria-live="polite">Theme</button>
|
|
449
|
+
</div>
|
|
450
|
+
</header>
|
|
451
|
+
|
|
452
|
+
<div class="layout">
|
|
453
|
+
<details class="tree-wrap" id="tree-wrap" open>
|
|
454
|
+
<summary id="tree-summary">Files</summary>
|
|
455
|
+
<nav class="tree" id="tree"></nav>
|
|
456
|
+
</details>
|
|
457
|
+
<main class="main">
|
|
458
|
+
<!-- WHAT THE CHANGE IS FOR, above the first filename. Composed by the
|
|
459
|
+
engine from the spec's own files — nothing here passed through the
|
|
460
|
+
model, so it is free however large the diff. Absent entirely for a spec
|
|
461
|
+
whose overview cannot be read: the page worked without it before. -->
|
|
462
|
+
<section class="context" id="context" hidden>
|
|
463
|
+
<div class="context-why" id="context-why"></div>
|
|
464
|
+
<details class="context-more" id="context-more" hidden>
|
|
465
|
+
<summary id="context-more-summary">More</summary>
|
|
466
|
+
<div id="context-rest"></div>
|
|
467
|
+
</details>
|
|
468
|
+
</section>
|
|
469
|
+
<div id="review-block">__REVIEW_BLOCK__</div>
|
|
470
|
+
<div id="files"></div>
|
|
471
|
+
<!-- THE VERDICT SITS AT THE END, where reading finishes. It began in the
|
|
472
|
+
header and the first person to use it could not find it: you read a
|
|
473
|
+
350-line diff downward, and the control asking for your conclusion was
|
|
474
|
+
off-screen above you. A conclusion belongs after the thing it is about.
|
|
475
|
+
Not sticky, deliberately — a permanent bar costs vertical space on the
|
|
476
|
+
phone this page is read on, on a page whose whole job is showing code. -->
|
|
477
|
+
<footer class="verdict-end">
|
|
478
|
+
<div class="verdict" id="verdict">
|
|
479
|
+
<span class="verdict-title">Your verdict</span>
|
|
480
|
+
<button id="verdict-commit" class="v-commit" type="button" aria-live="polite">✓ Commit</button>
|
|
481
|
+
<button id="verdict-commit-continue" class="v-commit" type="button" aria-live="polite">✓ Commit & Continue</button>
|
|
482
|
+
<button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
|
|
483
|
+
<button id="verdict-discuss" type="button">… Discuss first</button>
|
|
484
|
+
<span class="verdict-count" id="verdict-count" aria-live="polite"></span>
|
|
485
|
+
</div>
|
|
486
|
+
<p class="verdict-log" id="verdict-log" hidden></p>
|
|
487
|
+
<p class="copy-hint" id="copy-hint" hidden></p>
|
|
488
|
+
<p class="sent-cmd" id="sent-cmd" hidden>
|
|
489
|
+
<input class="sent-cmd-text" id="sent-cmd-text" readonly aria-label="Command to run">
|
|
490
|
+
<button type="button" class="sent-cmd-copy" id="sent-cmd-copy" hidden>Copy</button>
|
|
491
|
+
</p>
|
|
492
|
+
<textarea class="copy-out" id="copy-out" readonly hidden aria-label="Review blob to copy"></textarea>
|
|
493
|
+
<p class="drawn-by" id="drawn-by"></p>
|
|
494
|
+
</footer>
|
|
495
|
+
</main>
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
<script type="application/json" id="review-data">__REVIEW_DATA__</script>
|
|
500
|
+
<script>
|
|
501
|
+
(function () {
|
|
502
|
+
'use strict'
|
|
503
|
+
|
|
504
|
+
var data = JSON.parse(document.getElementById('review-data').textContent)
|
|
505
|
+
|
|
506
|
+
// Lines kept either side of a change, and how much one "expand" step reveals.
|
|
507
|
+
var CONTEXT = 4
|
|
508
|
+
var STEP = 20
|
|
509
|
+
|
|
510
|
+
// --- patch parsing -------------------------------------------------------
|
|
511
|
+
// Rows carry their own line numbers so the gutter needs no second pass. Hunk
|
|
512
|
+
// headers are emitted ONLY when there is more than one hunk: with whole-file
|
|
513
|
+
// context there is exactly one, and a "@@ -1,400 +1,402 @@" banner above a
|
|
514
|
+
// whole file says nothing a reader wants.
|
|
515
|
+
function parsePatch(patch) {
|
|
516
|
+
var raw = patch.split('\n')
|
|
517
|
+
var hunks = 0
|
|
518
|
+
var i
|
|
519
|
+
for (i = 0; i < raw.length; i++) if (raw[i].slice(0, 2) === '@@') hunks++
|
|
520
|
+
|
|
521
|
+
var rows = []
|
|
522
|
+
var oldNo = 0
|
|
523
|
+
var newNo = 0
|
|
524
|
+
var started = false
|
|
525
|
+
for (i = 0; i < raw.length; i++) {
|
|
526
|
+
var line = raw[i]
|
|
527
|
+
if (line.slice(0, 2) === '@@') {
|
|
528
|
+
var m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
|
|
529
|
+
if (m) { oldNo = +m[1]; newNo = +m[2] }
|
|
530
|
+
started = true
|
|
531
|
+
if (hunks > 1) rows.push({ t: 'hunk', text: line })
|
|
532
|
+
continue
|
|
533
|
+
}
|
|
534
|
+
if (!started) continue
|
|
535
|
+
var c = line.charAt(0)
|
|
536
|
+
if (c === '+') rows.push({ t: 'add', text: line.slice(1), n: newNo++ })
|
|
537
|
+
else if (c === '-') rows.push({ t: 'del', text: line.slice(1), o: oldNo++ })
|
|
538
|
+
else if (c === '\\') rows.push({ t: 'meta', text: line.slice(2) })
|
|
539
|
+
else rows.push({ t: 'ctx', text: line.slice(1), o: oldNo++, n: newNo++ })
|
|
540
|
+
}
|
|
541
|
+
return rows
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Which stretches of unchanged context start out hidden. A run touching the
|
|
545
|
+
// top or bottom of the file collapses all the way to the edge — there is no
|
|
546
|
+
// change on that side to need context for.
|
|
547
|
+
function initialBands(rows) {
|
|
548
|
+
var bands = []
|
|
549
|
+
var i = 0
|
|
550
|
+
while (i < rows.length) {
|
|
551
|
+
if (rows[i].t !== 'ctx') { i++; continue }
|
|
552
|
+
var j = i
|
|
553
|
+
while (j < rows.length && rows[j].t === 'ctx') j++
|
|
554
|
+
var atStart = i === 0
|
|
555
|
+
var atEnd = j === rows.length
|
|
556
|
+
var from = atStart ? i : i + CONTEXT
|
|
557
|
+
var to = atEnd ? j : j - CONTEXT
|
|
558
|
+
// A band hiding one or two lines is worse than just showing them.
|
|
559
|
+
if (to - from >= 3) bands.push({ from: from, to: to })
|
|
560
|
+
i = j
|
|
561
|
+
}
|
|
562
|
+
return bands
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// --- per-file page state -------------------------------------------------
|
|
566
|
+
// Band state lives HERE, never in the emitted data: the page is a view of an
|
|
567
|
+
// immutable review, and expanding context is a property of this reading of it.
|
|
568
|
+
var state = data.files.map(function (f) {
|
|
569
|
+
var rows = parsePatch(f.patch)
|
|
570
|
+
var st = {
|
|
571
|
+
file: f,
|
|
572
|
+
rows: rows,
|
|
573
|
+
bands: initialBands(rows),
|
|
574
|
+
rendered: false,
|
|
575
|
+
el: null,
|
|
576
|
+
// What the page holds about THIS reading: whether the accept box is
|
|
577
|
+
// ticked now, and any notes written but not yet copied out.
|
|
578
|
+
accept: f.accepted === true,
|
|
579
|
+
drafts: [],
|
|
580
|
+
}
|
|
581
|
+
revealCommented(st)
|
|
582
|
+
return st
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
// --- review marks --------------------------------------------------------
|
|
586
|
+
// A comment is useless if the line it points at is hidden, so any band that
|
|
587
|
+
// swallows a commented line is dropped before the first render. Dropping the
|
|
588
|
+
// whole band shows a little more context than strictly needed, which is the
|
|
589
|
+
// harmless direction.
|
|
590
|
+
function revealCommented(st) {
|
|
591
|
+
var lines = (st.file.comments || [])
|
|
592
|
+
.map(function (c) { return c.line })
|
|
593
|
+
.filter(function (n) { return n != null })
|
|
594
|
+
if (!lines.length) return
|
|
595
|
+
var idx = []
|
|
596
|
+
st.rows.forEach(function (row, i) {
|
|
597
|
+
if (row.n != null && lines.indexOf(row.n) !== -1) idx.push(i)
|
|
598
|
+
})
|
|
599
|
+
st.bands = st.bands.filter(function (b) {
|
|
600
|
+
return !idx.some(function (i) { return i >= b.from && i < b.to })
|
|
601
|
+
})
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Ids are minted from the render timestamp plus a counter, so they are stable
|
|
605
|
+
// for this page and unique across renders. That is what makes re-pasting the
|
|
606
|
+
// same blob merge instead of doubling every note.
|
|
607
|
+
var seq = 0
|
|
608
|
+
function mintId() {
|
|
609
|
+
seq++
|
|
610
|
+
return data.generatedAt + '-' + seq
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Best effort, both ways. Safari refuses storage on file:// by THROWING, and
|
|
614
|
+
// a review page that fails to render because it could not autosave would be a
|
|
615
|
+
// far worse bug than a lost pass.
|
|
616
|
+
var STORE_KEY = 'skitterspec-review:' + data.spec + ':' + data.generatedAt
|
|
617
|
+
function save() {
|
|
618
|
+
try {
|
|
619
|
+
var accepts = {}
|
|
620
|
+
var drafts = []
|
|
621
|
+
state.forEach(function (st) {
|
|
622
|
+
accepts[st.file.path] = st.accept
|
|
623
|
+
st.drafts.forEach(function (d) { drafts.push(d) })
|
|
624
|
+
})
|
|
625
|
+
window.localStorage.setItem(STORE_KEY, JSON.stringify({ seq: seq, accepts: accepts, drafts: drafts, replies: replies }))
|
|
626
|
+
} catch (e) { /* no storage, or a quota — the pass still works, it just is not saved */ }
|
|
627
|
+
}
|
|
628
|
+
function restore() {
|
|
629
|
+
var raw = null
|
|
630
|
+
try { raw = window.localStorage.getItem(STORE_KEY) } catch (e) { return }
|
|
631
|
+
if (!raw) return
|
|
632
|
+
var saved
|
|
633
|
+
try { saved = JSON.parse(raw) } catch (e) { return }
|
|
634
|
+
seq = saved.seq || 0
|
|
635
|
+
replies = saved.replies || {}
|
|
636
|
+
var byPath = {}
|
|
637
|
+
state.forEach(function (st) { byPath[st.file.path] = st })
|
|
638
|
+
Object.keys(saved.accepts || {}).forEach(function (path) {
|
|
639
|
+
if (byPath[path]) byPath[path].accept = Boolean(saved.accepts[path])
|
|
640
|
+
})
|
|
641
|
+
;(saved.drafts || []).forEach(function (d) {
|
|
642
|
+
if (byPath[d.file]) byPath[d.file].drafts.push(d)
|
|
643
|
+
else orphanDrafts.push(d)
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Drafts whose file left the diff between saving and reloading, and replies to
|
|
648
|
+
// the written review keyed by check id. Both still belong in the blob.
|
|
649
|
+
var orphanDrafts = []
|
|
650
|
+
var replies = {}
|
|
651
|
+
|
|
652
|
+
function el(tag, cls, text) {
|
|
653
|
+
var n = document.createElement(tag)
|
|
654
|
+
if (cls) n.className = cls
|
|
655
|
+
if (text != null) n.textContent = text
|
|
656
|
+
return n
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function gutter(row, st, index) {
|
|
660
|
+
var td = el('td', 'gutter')
|
|
661
|
+
var o = el('span', 'n', row.o == null ? '' : String(row.o))
|
|
662
|
+
var n = el('span', 'n', row.n == null ? '' : String(row.n))
|
|
663
|
+
var s = el('span', 's', row.t === 'add' ? '+' : row.t === 'del' ? '-' : ' ')
|
|
664
|
+
td.appendChild(o); td.appendChild(n); td.appendChild(s)
|
|
665
|
+
if (st) {
|
|
666
|
+
td.className = 'gutter can-note'
|
|
667
|
+
td.title = 'Comment on this line'
|
|
668
|
+
td.addEventListener('click', function () { openDraft(st, index, row) })
|
|
669
|
+
}
|
|
670
|
+
return td
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function bandRow(st, band, idx) {
|
|
674
|
+
var tr = el('tr', 'band')
|
|
675
|
+
tr.appendChild(el('td', 'gutter', '⋯'))
|
|
676
|
+
var td = el('td', 'src')
|
|
677
|
+
var hidden = band.to - band.from
|
|
678
|
+
td.appendChild(el('span', 'n-hidden', hidden + ' unchanged line' + (hidden === 1 ? '' : 's')))
|
|
679
|
+
var step = el('button', null, '±' + STEP)
|
|
680
|
+
step.type = 'button'
|
|
681
|
+
step.setAttribute('aria-label', 'Expand ' + STEP + ' lines either side')
|
|
682
|
+
step.addEventListener('click', function () {
|
|
683
|
+
band.from += STEP
|
|
684
|
+
band.to -= STEP
|
|
685
|
+
if (band.to - band.from < 3) st.bands.splice(st.bands.indexOf(band), 1)
|
|
686
|
+
renderFile(st)
|
|
687
|
+
})
|
|
688
|
+
var all = el('button', null, 'Show all')
|
|
689
|
+
all.type = 'button'
|
|
690
|
+
all.addEventListener('click', function () {
|
|
691
|
+
st.bands.splice(st.bands.indexOf(band), 1)
|
|
692
|
+
renderFile(st)
|
|
693
|
+
})
|
|
694
|
+
td.appendChild(step)
|
|
695
|
+
td.appendChild(all)
|
|
696
|
+
tr.appendChild(td)
|
|
697
|
+
return tr
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Re-render the whole file on every expand. The alternative — pre-rendering
|
|
701
|
+
// thousands of hidden rows and unhiding them — is what makes a whole-file
|
|
702
|
+
// diff page take seconds to open.
|
|
703
|
+
function renderFile(st) {
|
|
704
|
+
var host = st.el
|
|
705
|
+
if (!host) return
|
|
706
|
+
host.textContent = ''
|
|
707
|
+
if (!st.rows.length) {
|
|
708
|
+
host.appendChild(el('p', 'note', st.file.binary ? 'Binary file — not shown.' : 'No textual change.'))
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
var box = el('div', 'code')
|
|
712
|
+
var table = el('table', 'diff')
|
|
713
|
+
var body = document.createElement('tbody')
|
|
714
|
+
var bands = st.bands.slice().sort(function (a, b) { return a.from - b.from })
|
|
715
|
+
var b = 0
|
|
716
|
+
var i = 0
|
|
717
|
+
while (i < st.rows.length) {
|
|
718
|
+
if (b < bands.length && i === bands[b].from) {
|
|
719
|
+
body.appendChild(bandRow(st, bands[b], b))
|
|
720
|
+
i = bands[b].to
|
|
721
|
+
b++
|
|
722
|
+
continue
|
|
723
|
+
}
|
|
724
|
+
var row = st.rows[i]
|
|
725
|
+
var tr = el('tr', row.t)
|
|
726
|
+
if (row.t === 'hunk') {
|
|
727
|
+
tr.className = 'band'
|
|
728
|
+
tr.appendChild(el('td', 'gutter', '⋯'))
|
|
729
|
+
tr.appendChild(el('td', 'src', row.text))
|
|
730
|
+
} else {
|
|
731
|
+
tr.appendChild(gutter(row, st, i))
|
|
732
|
+
tr.appendChild(el('td', 'src', row.text === '' ? ' ' : row.text))
|
|
733
|
+
}
|
|
734
|
+
body.appendChild(tr)
|
|
735
|
+
// Anything said about this line sits directly beneath it — a note you
|
|
736
|
+
// have to go and look for is a note nobody reads.
|
|
737
|
+
var here = notesAt(st, row.n)
|
|
738
|
+
if (here.length) tr.className += ' has-note'
|
|
739
|
+
here.forEach(function (c) { body.appendChild(noteRow(st, c)) })
|
|
740
|
+
if (st.draftAt === i) body.appendChild(draftRow(st, row))
|
|
741
|
+
i++
|
|
742
|
+
}
|
|
743
|
+
table.appendChild(body)
|
|
744
|
+
box.appendChild(table)
|
|
745
|
+
host.appendChild(box)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Every note about one line: what the engine stored, plus anything written on
|
|
749
|
+
// this page and not yet copied out.
|
|
750
|
+
function notesAt(st, line) {
|
|
751
|
+
if (line == null) return []
|
|
752
|
+
var out = []
|
|
753
|
+
;(st.file.comments || []).forEach(function (c) { if (c.line === line) out.push(c) })
|
|
754
|
+
st.drafts.forEach(function (d) { if (d.line === line) out.push(d) })
|
|
755
|
+
return out
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function noteRow(st, c) {
|
|
759
|
+
var tr = el('tr', 'note-row' + (c.resolved ? ' is-resolved' : ''))
|
|
760
|
+
tr.appendChild(el('td', 'gutter', '💬'))
|
|
761
|
+
var td = el('td', 'src')
|
|
762
|
+
var meta = c.line == null ? 'on this file' : 'line ' + c.line
|
|
763
|
+
if (c.check) meta += ' · answering the review'
|
|
764
|
+
if (!c.raisedAt) meta += ' · not sent yet'
|
|
765
|
+
td.appendChild(el('span', 'note-meta', meta))
|
|
766
|
+
td.appendChild(el('span', 'note-text', c.note))
|
|
767
|
+
if (c.resolved) {
|
|
768
|
+
td.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
|
|
769
|
+
}
|
|
770
|
+
if (!c.raisedAt) {
|
|
771
|
+
var actions = el('div', 'note-actions')
|
|
772
|
+
var del = el('button', null, 'Remove')
|
|
773
|
+
del.type = 'button'
|
|
774
|
+
del.addEventListener('click', function () {
|
|
775
|
+
st.drafts.splice(st.drafts.indexOf(c), 1)
|
|
776
|
+
renderFile(st)
|
|
777
|
+
refresh()
|
|
778
|
+
})
|
|
779
|
+
actions.appendChild(del)
|
|
780
|
+
td.appendChild(actions)
|
|
781
|
+
}
|
|
782
|
+
tr.appendChild(td)
|
|
783
|
+
return tr
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// The draft box. `row` is null for a note about the file as a whole.
|
|
787
|
+
function draftRow(st, row) {
|
|
788
|
+
var tr = el('tr', 'note-row')
|
|
789
|
+
tr.appendChild(el('td', 'gutter', '💬'))
|
|
790
|
+
var td = el('td', 'src')
|
|
791
|
+
td.appendChild(el('span', 'note-meta', row ? 'line ' + (row.n == null ? '—' : row.n) : 'on this file'))
|
|
792
|
+
td.appendChild(noteEditor(function (text) {
|
|
793
|
+
addDraft(st, text, row ? row.n : null, row ? row.text : null, null)
|
|
794
|
+
st.draftAt = null
|
|
795
|
+
renderFile(st)
|
|
796
|
+
}, function () {
|
|
797
|
+
st.draftAt = null
|
|
798
|
+
renderFile(st)
|
|
799
|
+
}))
|
|
800
|
+
tr.appendChild(td)
|
|
801
|
+
return tr
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// One editor, used by the gutter, the file strip and the review replies.
|
|
805
|
+
function noteEditor(onSave, onCancel) {
|
|
806
|
+
var wrap = el('div')
|
|
807
|
+
var input = el('textarea', 'note-input')
|
|
808
|
+
input.rows = 2
|
|
809
|
+
input.placeholder = 'What should change here?'
|
|
810
|
+
var actions = el('div', 'note-actions')
|
|
811
|
+
var ok = el('button', null, 'Add note')
|
|
812
|
+
ok.type = 'button'
|
|
813
|
+
ok.addEventListener('click', function () {
|
|
814
|
+
var text = input.value.trim()
|
|
815
|
+
// An empty note would be refused by the engine, so it is refused here —
|
|
816
|
+
// where the person who wrote it is still looking at it.
|
|
817
|
+
if (!text) { input.focus(); return }
|
|
818
|
+
onSave(text)
|
|
819
|
+
})
|
|
820
|
+
actions.appendChild(ok)
|
|
821
|
+
if (onCancel) {
|
|
822
|
+
var no = el('button', null, 'Cancel')
|
|
823
|
+
no.type = 'button'
|
|
824
|
+
no.addEventListener('click', onCancel)
|
|
825
|
+
actions.appendChild(no)
|
|
826
|
+
}
|
|
827
|
+
wrap.appendChild(input)
|
|
828
|
+
wrap.appendChild(actions)
|
|
829
|
+
window.setTimeout(function () { input.focus() }, 0)
|
|
830
|
+
return wrap
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function addDraft(st, note, line, lineText, check) {
|
|
834
|
+
st.drafts.push({
|
|
835
|
+
id: mintId(),
|
|
836
|
+
file: st.file.path,
|
|
837
|
+
line: line == null ? null : line,
|
|
838
|
+
lineText: lineText == null ? null : lineText,
|
|
839
|
+
check: check || null,
|
|
840
|
+
note: note,
|
|
841
|
+
})
|
|
842
|
+
refresh()
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function openDraft(st, index, row) {
|
|
846
|
+
st.draftAt = st.draftAt === index ? null : index
|
|
847
|
+
renderFile(st)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Notes about the file rather than a line, and the way in to writing one.
|
|
851
|
+
function fileStrip(st) {
|
|
852
|
+
var strip = el('div', 'strip')
|
|
853
|
+
var loose = (st.file.comments || []).concat(st.drafts).filter(function (c) { return c.line == null })
|
|
854
|
+
loose.forEach(function (c) {
|
|
855
|
+
var row = el('div', c.resolved ? 'is-resolved' : null)
|
|
856
|
+
row.appendChild(el('span', 'note-meta', (c.check ? 'answering the review' : 'on this file') + (c.raisedAt ? '' : ' · not sent yet')))
|
|
857
|
+
row.appendChild(el('span', 'note-text', c.note))
|
|
858
|
+
if (c.resolved) row.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
|
|
859
|
+
strip.appendChild(row)
|
|
860
|
+
})
|
|
861
|
+
var add = el('button', null, 'Note on this file')
|
|
862
|
+
add.type = 'button'
|
|
863
|
+
add.addEventListener('click', function () {
|
|
864
|
+
add.hidden = true
|
|
865
|
+
strip.appendChild(noteEditor(function (text) {
|
|
866
|
+
addDraft(st, text, null, null, null)
|
|
867
|
+
redrawStrip(st)
|
|
868
|
+
}, function () { redrawStrip(st) }))
|
|
869
|
+
})
|
|
870
|
+
strip.appendChild(add)
|
|
871
|
+
return strip
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function redrawStrip(st) {
|
|
875
|
+
if (!st.stripEl) return
|
|
876
|
+
var next = fileStrip(st)
|
|
877
|
+
st.stripEl.parentNode.replaceChild(next, st.stripEl)
|
|
878
|
+
st.stripEl = next
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// --- file list -----------------------------------------------------------
|
|
882
|
+
var filesHost = document.getElementById('files')
|
|
883
|
+
|
|
884
|
+
state.forEach(function (st) {
|
|
885
|
+
var f = st.file
|
|
886
|
+
var d = el('details', 'file')
|
|
887
|
+
d.id = 'f-' + encodeURIComponent(f.path)
|
|
888
|
+
var s = el('summary')
|
|
889
|
+
s.appendChild(el('span', 'path', f.path))
|
|
890
|
+
s.appendChild(el('span', 'chip', f.status))
|
|
891
|
+
if (!f.whole) s.appendChild(el('span', 'chip', 'partial context'))
|
|
892
|
+
if (f.noise) s.appendChild(el('span', 'chip', 'bookkeeping'))
|
|
893
|
+
// A lapse is SAID. An accept that quietly vanished because the file moved on
|
|
894
|
+
// would leave you believing you had read code you have not.
|
|
895
|
+
if (f.accepted === 'lapsed') {
|
|
896
|
+
s.appendChild(el('span', 'chip lapsed', 'accepted earlier — changed since'))
|
|
897
|
+
}
|
|
898
|
+
var openCount = (f.comments || []).filter(function (x) { return !x.resolved }).length
|
|
899
|
+
if (openCount) s.appendChild(el('span', 'chip commented', openCount + ' note' + (openCount === 1 ? '' : 's')))
|
|
900
|
+
|
|
901
|
+
var accept = el('button', 'accept', '✓ accept')
|
|
902
|
+
accept.type = 'button'
|
|
903
|
+
accept.setAttribute('aria-pressed', String(st.accept))
|
|
904
|
+
// A summary is a toggle for the <details>; a click on a control inside it
|
|
905
|
+
// must not also open and close the file.
|
|
906
|
+
accept.addEventListener('click', function (ev) {
|
|
907
|
+
ev.preventDefault()
|
|
908
|
+
ev.stopPropagation()
|
|
909
|
+
st.accept = !st.accept
|
|
910
|
+
accept.setAttribute('aria-pressed', String(st.accept))
|
|
911
|
+
refresh()
|
|
912
|
+
})
|
|
913
|
+
s.appendChild(accept)
|
|
914
|
+
|
|
915
|
+
var c = el('span', 'counts')
|
|
916
|
+
c.appendChild(el('span', 'a', '+' + f.additions))
|
|
917
|
+
c.appendChild(document.createTextNode(' '))
|
|
918
|
+
c.appendChild(el('span', 'd', '-' + f.deletions))
|
|
919
|
+
s.appendChild(c)
|
|
920
|
+
d.appendChild(s)
|
|
921
|
+
st.stripEl = fileStrip(st)
|
|
922
|
+
d.appendChild(st.stripEl)
|
|
923
|
+
var host = el('div')
|
|
924
|
+
d.appendChild(host)
|
|
925
|
+
st.el = host
|
|
926
|
+
|
|
927
|
+
// Render on first open, so a review of 200 files costs one file's work.
|
|
928
|
+
d.addEventListener('toggle', function () {
|
|
929
|
+
if (d.open && !st.rendered) { st.rendered = true; renderFile(st) }
|
|
930
|
+
})
|
|
931
|
+
if (!f.noise) { d.open = true; st.rendered = true; renderFile(st) }
|
|
932
|
+
filesHost.appendChild(d)
|
|
933
|
+
st.details = d
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
if (!state.length) {
|
|
937
|
+
filesHost.appendChild(el('p', 'empty', 'Nothing to review — no changes found.'))
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// --- noise filter --------------------------------------------------------
|
|
941
|
+
var noiseBox = document.getElementById('show-noise')
|
|
942
|
+
var noiseCount = state.filter(function (s) { return s.file.noise }).length
|
|
943
|
+
document.getElementById('noise-label').textContent = 'Show bookkeeping (' + noiseCount + ')'
|
|
944
|
+
if (!noiseCount) noiseBox.closest('.toggle').hidden = true
|
|
945
|
+
|
|
946
|
+
function applyNoise() {
|
|
947
|
+
state.forEach(function (st) {
|
|
948
|
+
if (st.file.noise) st.details.hidden = !noiseBox.checked
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
noiseBox.addEventListener('change', applyNoise)
|
|
952
|
+
applyNoise()
|
|
953
|
+
|
|
954
|
+
// --- file tree -----------------------------------------------------------
|
|
955
|
+
// Built from the paths, and it lists EVERY file including the ones the noise
|
|
956
|
+
// filter is hiding — a tree with an unreachable entry is worse than no tree.
|
|
957
|
+
function buildTree(files) {
|
|
958
|
+
var root = { dirs: {}, files: [] }
|
|
959
|
+
files.forEach(function (f) {
|
|
960
|
+
var parts = f.path.split('/')
|
|
961
|
+
var node = root
|
|
962
|
+
for (var i = 0; i < parts.length - 1; i++) {
|
|
963
|
+
if (!node.dirs[parts[i]]) node.dirs[parts[i]] = { dirs: {}, files: [] }
|
|
964
|
+
node = node.dirs[parts[i]]
|
|
965
|
+
}
|
|
966
|
+
node.files.push({ name: parts[parts.length - 1], file: f })
|
|
967
|
+
})
|
|
968
|
+
return root
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function reveal(f) {
|
|
972
|
+
if (f.noise && !noiseBox.checked) { noiseBox.checked = true; applyNoise() }
|
|
973
|
+
var d = document.getElementById('f-' + encodeURIComponent(f.path))
|
|
974
|
+
if (!d) return
|
|
975
|
+
d.open = true
|
|
976
|
+
d.scrollIntoView({ block: 'start' })
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function renderTree(node) {
|
|
980
|
+
var ul = el('ul')
|
|
981
|
+
Object.keys(node.dirs).sort().forEach(function (name) {
|
|
982
|
+
var li = el('li')
|
|
983
|
+
li.appendChild(el('span', 'dir', name + '/'))
|
|
984
|
+
li.appendChild(renderTree(node.dirs[name]))
|
|
985
|
+
ul.appendChild(li)
|
|
986
|
+
})
|
|
987
|
+
node.files.sort(function (a, b) { return a.name.localeCompare(b.name) }).forEach(function (entry) {
|
|
988
|
+
var li = el('li')
|
|
989
|
+
var b = el('button', 'tree-file' + (entry.file.noise ? ' is-noise' : ''))
|
|
990
|
+
b.type = 'button'
|
|
991
|
+
b.appendChild(el('span', 'nm', entry.name))
|
|
992
|
+
var ct = el('span', 'ct')
|
|
993
|
+
ct.appendChild(el('span', 'a', '+' + entry.file.additions))
|
|
994
|
+
ct.appendChild(document.createTextNode(' '))
|
|
995
|
+
ct.appendChild(el('span', 'd', '-' + entry.file.deletions))
|
|
996
|
+
b.appendChild(ct)
|
|
997
|
+
b.addEventListener('click', function () { reveal(entry.file) })
|
|
998
|
+
li.appendChild(b)
|
|
999
|
+
ul.appendChild(li)
|
|
1000
|
+
})
|
|
1001
|
+
return ul
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
var tree = document.getElementById('tree')
|
|
1005
|
+
tree.appendChild(renderTree(buildTree(data.files)))
|
|
1006
|
+
document.getElementById('tree-summary').textContent =
|
|
1007
|
+
'Files (' + data.files.length + ')'
|
|
1008
|
+
if (window.matchMedia && !window.matchMedia('(min-width: 60rem)').matches) {
|
|
1009
|
+
document.getElementById('tree-wrap').open = false
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// --- page-level controls -------------------------------------------------
|
|
1013
|
+
document.getElementById('expand-all').addEventListener('click', function () {
|
|
1014
|
+
state.forEach(function (st) {
|
|
1015
|
+
st.bands = []
|
|
1016
|
+
if (st.rendered) renderFile(st)
|
|
1017
|
+
})
|
|
1018
|
+
})
|
|
1019
|
+
document.getElementById('collapse-all').addEventListener('click', function () {
|
|
1020
|
+
state.forEach(function (st) {
|
|
1021
|
+
st.bands = initialBands(st.rows)
|
|
1022
|
+
if (st.rendered) renderFile(st)
|
|
1023
|
+
})
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
// --- answering the written review ---------------------------------------
|
|
1027
|
+
// A `confirm` exists to ask the author a question, and until now the only way
|
|
1028
|
+
// to answer was to retype it in chat. The engine stamps each check with an id
|
|
1029
|
+
// and the file it is about; a reply becomes an ordinary comment carrying that
|
|
1030
|
+
// id, so it travels in the same blob as everything else.
|
|
1031
|
+
function wireChecks() {
|
|
1032
|
+
var checks = document.querySelectorAll('.check[data-check]')
|
|
1033
|
+
Array.prototype.forEach.call(checks, function (li) {
|
|
1034
|
+
var id = li.getAttribute('data-check')
|
|
1035
|
+
// A check with no file is about the review rather than a line of code.
|
|
1036
|
+
// The sentinel keeps the blob valid and lands it under "not in this diff",
|
|
1037
|
+
// which is exactly where a review-wide remark belongs.
|
|
1038
|
+
var file = li.getAttribute('data-file') || '(review)'
|
|
1039
|
+
var given = el('div', 'reply-given')
|
|
1040
|
+
var box = el('div', 'reply')
|
|
1041
|
+
function draw() {
|
|
1042
|
+
given.textContent = replies[id] ? '↳ ' + replies[id] : ''
|
|
1043
|
+
given.hidden = !replies[id]
|
|
1044
|
+
box.textContent = ''
|
|
1045
|
+
var add = el('button', null, replies[id] ? 'Change answer' : 'Answer')
|
|
1046
|
+
add.type = 'button'
|
|
1047
|
+
add.addEventListener('click', function () {
|
|
1048
|
+
box.textContent = ''
|
|
1049
|
+
box.appendChild(noteEditor(function (text) {
|
|
1050
|
+
replies[id] = text
|
|
1051
|
+
draw()
|
|
1052
|
+
refresh()
|
|
1053
|
+
}, draw))
|
|
1054
|
+
})
|
|
1055
|
+
box.appendChild(add)
|
|
1056
|
+
}
|
|
1057
|
+
draw()
|
|
1058
|
+
li.appendChild(given)
|
|
1059
|
+
li.appendChild(box)
|
|
1060
|
+
li.setAttribute('data-reply-file', file)
|
|
1061
|
+
})
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// --- the blob -------------------------------------------------------------
|
|
1065
|
+
/**
|
|
1066
|
+
* A DELTA, not a snapshot. The engine merges rather than replaces, so this
|
|
1067
|
+
* says only what changed: files newly ticked, accepts withdrawn, notes
|
|
1068
|
+
* written. A file already accepted with this exact content has nothing to
|
|
1069
|
+
* say, and a file that was never accepted has nothing to withdraw.
|
|
1070
|
+
*
|
|
1071
|
+
* Withdrawal is explicit for the same reason — under a merge, absence means
|
|
1072
|
+
* "not mentioned", never "remove it".
|
|
1073
|
+
*/
|
|
1074
|
+
function buildBlob(verdict) {
|
|
1075
|
+
var accepted = []
|
|
1076
|
+
var unaccepted = []
|
|
1077
|
+
var comments = []
|
|
1078
|
+
state.forEach(function (st) {
|
|
1079
|
+
var stored = st.file.accepted
|
|
1080
|
+
if (st.accept) {
|
|
1081
|
+
// No hash means the engine could not read the file; accepting it would
|
|
1082
|
+
// record a vouch for content nobody can identify.
|
|
1083
|
+
if (stored !== true && st.file.hash) accepted.push({ path: st.file.path, hash: st.file.hash })
|
|
1084
|
+
} else if (stored === true || stored === 'lapsed') {
|
|
1085
|
+
unaccepted.push(st.file.path)
|
|
1086
|
+
}
|
|
1087
|
+
st.drafts.forEach(function (d) { comments.push(d) })
|
|
1088
|
+
})
|
|
1089
|
+
orphanDrafts.forEach(function (d) { comments.push(d) })
|
|
1090
|
+
Object.keys(replies).forEach(function (id) {
|
|
1091
|
+
var li = document.querySelector('.check[data-check="' + id + '"]')
|
|
1092
|
+
comments.push({
|
|
1093
|
+
id: data.generatedAt + '-reply-' + id,
|
|
1094
|
+
file: (li && li.getAttribute('data-reply-file')) || '(review)',
|
|
1095
|
+
line: null,
|
|
1096
|
+
lineText: null,
|
|
1097
|
+
check: id,
|
|
1098
|
+
note: replies[id],
|
|
1099
|
+
})
|
|
1100
|
+
})
|
|
1101
|
+
var blob = {
|
|
1102
|
+
version: 1,
|
|
1103
|
+
spec: data.spec,
|
|
1104
|
+
generatedAt: data.generatedAt,
|
|
1105
|
+
accepted: accepted,
|
|
1106
|
+
unaccepted: unaccepted,
|
|
1107
|
+
comments: comments,
|
|
1108
|
+
}
|
|
1109
|
+
// Absent unless a verdict was actually chosen. The engine reads an absent
|
|
1110
|
+
// one as `discuss`, and a blob that never claimed one should keep saying so.
|
|
1111
|
+
if (verdict) blob.verdict = verdict
|
|
1112
|
+
return blob
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
var verdictBtns = {
|
|
1116
|
+
commit: document.getElementById('verdict-commit'),
|
|
1117
|
+
'commit-continue': document.getElementById('verdict-commit-continue'),
|
|
1118
|
+
changes: document.getElementById('verdict-changes'),
|
|
1119
|
+
discuss: document.getElementById('verdict-discuss'),
|
|
1120
|
+
}
|
|
1121
|
+
// The committing pair, named once. A fifth verdict that commits is added
|
|
1122
|
+
// here, and the shared block below follows for free — the same reason the
|
|
1123
|
+
// engine keeps a `COMMITTING` list rather than a second condition.
|
|
1124
|
+
var COMMITTERS = ['commit', 'commit-continue']
|
|
1125
|
+
var LABELS = { commit: '✓ Commit', 'commit-continue': '✓ Commit & Continue' }
|
|
1126
|
+
var TITLES = {
|
|
1127
|
+
commit: 'Commit what you just read, and stop there',
|
|
1128
|
+
// Says what it will NOT do: a reader must not press this expecting the
|
|
1129
|
+
// spec to be finished and landed.
|
|
1130
|
+
'commit-continue': 'Commit what you just read, then build the next phase — nothing is landed',
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
var verdictCount = document.getElementById('verdict-count')
|
|
1134
|
+
var verdictLog = document.getElementById('verdict-log')
|
|
1135
|
+
var copyOut = document.getElementById('copy-out')
|
|
1136
|
+
var copyHint = document.getElementById('copy-hint')
|
|
1137
|
+
var sentCmd = document.getElementById('sent-cmd')
|
|
1138
|
+
var sentCmdText = document.getElementById('sent-cmd-text')
|
|
1139
|
+
var sentCmdCopy = document.getElementById('sent-cmd-copy')
|
|
1140
|
+
|
|
1141
|
+
function pending() {
|
|
1142
|
+
var b = buildBlob()
|
|
1143
|
+
return b.accepted.length + b.unaccepted.length + b.comments.length
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* How many notes would still be open once this pass lands — the exact number
|
|
1148
|
+
* the engine's `judgeVerdict` will count, which is what makes the disabled
|
|
1149
|
+
* button a preview of the refusal rather than a second opinion about it.
|
|
1150
|
+
*
|
|
1151
|
+
* Stored comments the agent has not answered, PLUS every comment this pass is
|
|
1152
|
+
* about to send (a note you just wrote is open by definition).
|
|
1153
|
+
*
|
|
1154
|
+
* UNACCEPTED FILES ARE NOT COUNTED AND MUST NEVER BE. A comment is a request
|
|
1155
|
+
* you made; an unticked file is merely something you said nothing about, and
|
|
1156
|
+
* counting those would be the tally this whole feature is designed not to be.
|
|
1157
|
+
*/
|
|
1158
|
+
function openNotes() {
|
|
1159
|
+
var stored = (data.notes && data.notes.totals && data.notes.totals.unresolved) || 0
|
|
1160
|
+
return stored + buildBlob().comments.length
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Redraw the bar and autosave. Called after every mark, so the block lifts
|
|
1164
|
+
// the moment the last note goes and a phone tab switch mid-review costs
|
|
1165
|
+
// nothing.
|
|
1166
|
+
function refresh() {
|
|
1167
|
+
var n = pending()
|
|
1168
|
+
var open = openNotes()
|
|
1169
|
+
verdictCount.textContent = n
|
|
1170
|
+
? n + (n === 1 ? ' mark to send' : ' marks to send')
|
|
1171
|
+
: 'Nothing marked — a clean read still commits'
|
|
1172
|
+
// ONE COUNT, BOTH BUTTONS. Two committing controls blocked by two separate
|
|
1173
|
+
// reads is how a page ends up with one disabled and the other not — which
|
|
1174
|
+
// would be a way around the single refusal this page makes.
|
|
1175
|
+
var blocked = open > 0
|
|
1176
|
+
var why = open + (open === 1 ? ' open note' : ' open notes')
|
|
1177
|
+
// Is there a phase left for `/spec-next` to build? The engine answers, and
|
|
1178
|
+
// its ABSENCE is a third state, not a no: a spec whose phases it could not
|
|
1179
|
+
// read — a legacy layout, an inline-phase overview — leaves the button
|
|
1180
|
+
// exactly as it was. Removing an offer that might work is worse than
|
|
1181
|
+
// leaving one that might not.
|
|
1182
|
+
var noPhaseLeft = Boolean(data.phases) && data.phases.hasNextPhase === false
|
|
1183
|
+
COMMITTERS.forEach(function (key) {
|
|
1184
|
+
var btn = verdictBtns[key]
|
|
1185
|
+
// AN OPEN NOTE WINS. Two reasons can be true at once and the label has
|
|
1186
|
+
// room for one — the open note is the one the reader can act on, so a
|
|
1187
|
+
// "no phase left" in its place would send them to fix the wrong thing.
|
|
1188
|
+
var stop = blocked ? why : key === 'commit-continue' && noPhaseLeft ? 'no phase left' : null
|
|
1189
|
+
// The reason goes ON the control. A button that is merely dimmed reads as
|
|
1190
|
+
// a broken page, and a title attribute is unreachable on a touch screen.
|
|
1191
|
+
btn.disabled = Boolean(stop)
|
|
1192
|
+
btn.textContent = stop ? LABELS[key] + ' — ' + stop : LABELS[key]
|
|
1193
|
+
btn.title = blocked
|
|
1194
|
+
? 'Resolve or remove your notes first — you asked for something, so it cannot also be fine'
|
|
1195
|
+
: key === 'commit-continue' && noPhaseLeft
|
|
1196
|
+
? 'Every phase of this spec is done — there is nothing left to build'
|
|
1197
|
+
: TITLES[key]
|
|
1198
|
+
})
|
|
1199
|
+
// Said rather than left unset: the committing pair is the ONLY thing this
|
|
1200
|
+
// page ever blocks, and the other two are always available on purpose — a
|
|
1201
|
+
// note you want acted on, and a question, are never the wrong thing to send.
|
|
1202
|
+
verdictBtns.changes.disabled = false
|
|
1203
|
+
verdictBtns.discuss.disabled = false
|
|
1204
|
+
save()
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// The last honoured verdict, as history. It is why the page can look
|
|
1208
|
+
// untouched after an approval: the verdict was consumed, not stored.
|
|
1209
|
+
function drawLog() {
|
|
1210
|
+
var last = data.notes && data.notes.lastDecision
|
|
1211
|
+
if (!last) return
|
|
1212
|
+
var said = last.verdict === 'approve' ? 'approved' : last.verdict === 'changes' ? 'changes requested' : 'discussed'
|
|
1213
|
+
var when = String(last.at || '').slice(0, 10)
|
|
1214
|
+
verdictLog.hidden = false
|
|
1215
|
+
verdictLog.textContent = said + ' earlier' + (when ? ' · ' + when : '') + (last.note ? ' · ' + last.note : '')
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Hand over the command itself, where the reader's eye already is.
|
|
1220
|
+
*
|
|
1221
|
+
* Two ways out and the page picks by CAPABILITY, not by trying and failing:
|
|
1222
|
+
* `navigator.clipboard` is secure-context-only, and a page served at
|
|
1223
|
+
* `http://<lan-ip>:7777` is not a secure context — which is the ordinary case
|
|
1224
|
+
* here, not an edge one. A Copy button that does nothing when tapped is worse
|
|
1225
|
+
* than text that was always text, so the button only exists where it can work.
|
|
1226
|
+
*/
|
|
1227
|
+
function showCommand(cmd) {
|
|
1228
|
+
sentCmd.hidden = false
|
|
1229
|
+
sentCmdText.value = cmd
|
|
1230
|
+
var canCopy = Boolean(navigator.clipboard && navigator.clipboard.writeText)
|
|
1231
|
+
sentCmdCopy.hidden = !canCopy
|
|
1232
|
+
if (canCopy) return
|
|
1233
|
+
// Selected, and SAID to be selected: a reader who sees nothing happen has
|
|
1234
|
+
// no way to tell a page that did nothing from one that already did the work.
|
|
1235
|
+
sentCmdText.focus()
|
|
1236
|
+
sentCmdText.select()
|
|
1237
|
+
copyHint.textContent = copyHint.textContent + ' (selected — copy it)'
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (sentCmdCopy) {
|
|
1241
|
+
sentCmdCopy.addEventListener('click', function () {
|
|
1242
|
+
navigator.clipboard.writeText(sentCmdText.value).then(function () {
|
|
1243
|
+
sentCmdCopy.textContent = 'Copied'
|
|
1244
|
+
}, function () {
|
|
1245
|
+
// The capability was there and the write still failed. Fall back to the
|
|
1246
|
+
// same selection the no-clipboard path uses rather than saying nothing.
|
|
1247
|
+
sentCmdText.focus()
|
|
1248
|
+
sentCmdText.select()
|
|
1249
|
+
sentCmdCopy.textContent = 'Copy failed — selected'
|
|
1250
|
+
})
|
|
1251
|
+
})
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* The header: why this change exists, what it touches, what this phase set
|
|
1256
|
+
* out to do. Every part is optional and each is drawn only if present — a
|
|
1257
|
+
* spec with a Problem and no Impact table is ordinary, not broken.
|
|
1258
|
+
*
|
|
1259
|
+
* BUILT WITH createElement, never innerHTML. The text is markdown from a spec
|
|
1260
|
+
* file, which means it can contain anything someone typed — and this page is
|
|
1261
|
+
* served over the network, so treating it as markup would be the one place a
|
|
1262
|
+
* spec's prose could reach the DOM.
|
|
1263
|
+
*/
|
|
1264
|
+
function drawContext() {
|
|
1265
|
+
var ctx = data.context
|
|
1266
|
+
if (!ctx) return
|
|
1267
|
+
var why = document.getElementById('context-why')
|
|
1268
|
+
var rest = document.getElementById('context-rest')
|
|
1269
|
+
var more = document.getElementById('context-more')
|
|
1270
|
+
var drewRest = false
|
|
1271
|
+
|
|
1272
|
+
function head(into, label) {
|
|
1273
|
+
var h = document.createElement('h3')
|
|
1274
|
+
h.textContent = label
|
|
1275
|
+
into.appendChild(h)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (ctx.problem) {
|
|
1279
|
+
// FIRST PARAGRAPH OPEN, the remainder folded. A phone shows perhaps six
|
|
1280
|
+
// lines before the file list is pushed off-screen, and pushing it off is
|
|
1281
|
+
// the thing this header was built to avoid.
|
|
1282
|
+
var paras = String(ctx.problem).split(/\n\s*\n/)
|
|
1283
|
+
head(why, 'Why')
|
|
1284
|
+
var lead = document.createElement('p')
|
|
1285
|
+
lead.textContent = paras[0].replace(/\s+/g, ' ').trim()
|
|
1286
|
+
why.appendChild(lead)
|
|
1287
|
+
for (var i = 1; i < paras.length; i++) {
|
|
1288
|
+
var p = document.createElement('p')
|
|
1289
|
+
p.textContent = paras[i].replace(/\s+/g, ' ').trim()
|
|
1290
|
+
rest.appendChild(p)
|
|
1291
|
+
drewRest = true
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
if (ctx.impact) {
|
|
1296
|
+
head(rest, 'Touches')
|
|
1297
|
+
drewRest = true
|
|
1298
|
+
if (ctx.impact.rows) {
|
|
1299
|
+
var table = document.createElement('table')
|
|
1300
|
+
var hr = document.createElement('tr')
|
|
1301
|
+
;['Surface', 'Change', 'Detail'].forEach(function (label) {
|
|
1302
|
+
var th = document.createElement('th')
|
|
1303
|
+
th.textContent = label
|
|
1304
|
+
hr.appendChild(th)
|
|
1305
|
+
})
|
|
1306
|
+
table.appendChild(hr)
|
|
1307
|
+
ctx.impact.rows.forEach(function (row) {
|
|
1308
|
+
var tr = document.createElement('tr')
|
|
1309
|
+
;[row.surface, row.change, row.detail].forEach(function (cell) {
|
|
1310
|
+
var td = document.createElement('td')
|
|
1311
|
+
td.textContent = cell
|
|
1312
|
+
tr.appendChild(td)
|
|
1313
|
+
})
|
|
1314
|
+
table.appendChild(tr)
|
|
1315
|
+
})
|
|
1316
|
+
rest.appendChild(table)
|
|
1317
|
+
} else {
|
|
1318
|
+
var prose = document.createElement('p')
|
|
1319
|
+
prose.textContent = ctx.impact.prose
|
|
1320
|
+
rest.appendChild(prose)
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
if (ctx.phase) {
|
|
1325
|
+
head(rest, 'Phase ' + ctx.phase.n + (ctx.phase.title ? ' — ' + ctx.phase.title : ''))
|
|
1326
|
+
drewRest = true
|
|
1327
|
+
if (ctx.phase.goal) {
|
|
1328
|
+
var g = document.createElement('p')
|
|
1329
|
+
g.textContent = ctx.phase.goal
|
|
1330
|
+
rest.appendChild(g)
|
|
1331
|
+
}
|
|
1332
|
+
if (ctx.phase.tasks && ctx.phase.tasks.length) {
|
|
1333
|
+
var ul = document.createElement('ul')
|
|
1334
|
+
ctx.phase.tasks.forEach(function (t) {
|
|
1335
|
+
var li = document.createElement('li')
|
|
1336
|
+
// Done is dimmed rather than struck through: every task is ticked by
|
|
1337
|
+
// the time a phase renders, and a whole list struck through reads as
|
|
1338
|
+
// cancelled work.
|
|
1339
|
+
li.className = t.done ? 'done' : ''
|
|
1340
|
+
li.textContent = (t.done ? '✅ ' : '⬜ ') + t.text
|
|
1341
|
+
ul.appendChild(li)
|
|
1342
|
+
})
|
|
1343
|
+
rest.appendChild(ul)
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
document.getElementById('context').hidden = false
|
|
1348
|
+
more.hidden = !drewRest
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function showFallback(json, why) {
|
|
1352
|
+
copyOut.hidden = false
|
|
1353
|
+
copyOut.value = json
|
|
1354
|
+
copyOut.focus()
|
|
1355
|
+
copyOut.select()
|
|
1356
|
+
copyHint.hidden = false
|
|
1357
|
+
copyHint.textContent = why
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// One action per decision: the verdict button IS the copy button, so the blob
|
|
1361
|
+
// can never carry a verdict other than the one just pressed.
|
|
1362
|
+
/**
|
|
1363
|
+
* Send the pass back over the wire, to the page's own URL.
|
|
1364
|
+
*
|
|
1365
|
+
* The blob never passes through the model this way: the engine holds it and
|
|
1366
|
+
* you read six digits out, where a paste costs context in proportion to how
|
|
1367
|
+
* much you wrote.
|
|
1368
|
+
*/
|
|
1369
|
+
function post(json) {
|
|
1370
|
+
copyHint.hidden = false
|
|
1371
|
+
copyHint.textContent = 'Sending…'
|
|
1372
|
+
fetch(location.pathname, {
|
|
1373
|
+
method: 'POST',
|
|
1374
|
+
headers: { 'content-type': 'application/json' },
|
|
1375
|
+
body: json,
|
|
1376
|
+
}).then(function (res) {
|
|
1377
|
+
return res.text().then(function (text) {
|
|
1378
|
+
if (!res.ok) {
|
|
1379
|
+
// A pass the server REFUSED must never read as sent. Its message
|
|
1380
|
+
// names the entry that was wrong, so it is shown rather than summarised.
|
|
1381
|
+
copyHint.textContent = 'Not sent — ' + (text || res.status)
|
|
1382
|
+
return
|
|
1383
|
+
}
|
|
1384
|
+
var code = ''
|
|
1385
|
+
try { code = JSON.parse(text).code } catch (e) {}
|
|
1386
|
+
// NAME THE COMMAND. Not "claim it with 418207", which instructs a
|
|
1387
|
+
// transcription nobody needs to make, and not "tell Claude it is
|
|
1388
|
+
// waiting", which describes the arrangement `/spec-reviewed` replaced —
|
|
1389
|
+
// narrating that a pass exists and hoping the agent looks. The code
|
|
1390
|
+
// stays, as the thing to CHECK: it is the one part of a waiting pass
|
|
1391
|
+
// that tells yours apart from anyone else's.
|
|
1392
|
+
if (code) {
|
|
1393
|
+
copyHint.textContent = 'Sent. Run this where Claude is:'
|
|
1394
|
+
showCommand('/spec-reviewed ' + code)
|
|
1395
|
+
} else {
|
|
1396
|
+
// NO CODE, NO COMMAND. A `/spec-reviewed` with nothing after it still
|
|
1397
|
+
// works — it picks up the single waiting pass — but the page cannot
|
|
1398
|
+
// claim the server named THIS one, so it does not put words in its
|
|
1399
|
+
// mouth. Bare is what the reader is told, because bare is what is true.
|
|
1400
|
+
copyHint.textContent = 'Sent — run /spec-reviewed to pick it up.'
|
|
1401
|
+
}
|
|
1402
|
+
})
|
|
1403
|
+
}, function () {
|
|
1404
|
+
// The server was there when the page was served and is not now. Falling
|
|
1405
|
+
// back to the clipboard keeps the pass recoverable rather than lost.
|
|
1406
|
+
showFallback(json, 'Could not reach the server. Copy this, then paste it to Claude.')
|
|
1407
|
+
})
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function send(verdict) {
|
|
1411
|
+
var json = JSON.stringify(buildBlob(verdict), null, 2)
|
|
1412
|
+
// DECIDED FROM WHAT THE PAGE IS, not from what it can guess. A page opened
|
|
1413
|
+
// over http was served by the engine and can hand the pass straight back; a
|
|
1414
|
+
// `file://` page has no server to talk to and never will.
|
|
1415
|
+
//
|
|
1416
|
+
// NOT "try the POST and fall back on failure": a failed POST and an absent
|
|
1417
|
+
// one look identical to the reader, so the fallback would hide a server
|
|
1418
|
+
// that is refusing passes behind a message about copying.
|
|
1419
|
+
if (location.protocol !== 'file:') {
|
|
1420
|
+
post(json)
|
|
1421
|
+
return
|
|
1422
|
+
}
|
|
1423
|
+
// `file://` is not a secure context everywhere, and the clipboard API is
|
|
1424
|
+
// absent or rejects there — so the textarea is a shipping requirement, not
|
|
1425
|
+
// a nicety. It is the path a local page usually takes.
|
|
1426
|
+
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
|
1427
|
+
showFallback(json, 'Copy this, then paste it to Claude.')
|
|
1428
|
+
return
|
|
1429
|
+
}
|
|
1430
|
+
navigator.clipboard.writeText(json).then(function () {
|
|
1431
|
+
copyOut.hidden = true
|
|
1432
|
+
copyHint.hidden = false
|
|
1433
|
+
copyHint.textContent = 'Copied — paste it to Claude to pick up this review.'
|
|
1434
|
+
}, function () {
|
|
1435
|
+
showFallback(json, 'Could not reach the clipboard. Copy this, then paste it to Claude.')
|
|
1436
|
+
})
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
Object.keys(verdictBtns).forEach(function (verdict) {
|
|
1440
|
+
verdictBtns[verdict].addEventListener('click', function () {
|
|
1441
|
+
// A disabled button does not fire in a browser; the guard is for every
|
|
1442
|
+
// other caller, and it keeps the block a fact rather than a style.
|
|
1443
|
+
if (verdictBtns[verdict].disabled) return
|
|
1444
|
+
send(verdict)
|
|
1445
|
+
})
|
|
1446
|
+
})
|
|
1447
|
+
|
|
1448
|
+
// Said only when the engine reported one. A page from a render that could not
|
|
1449
|
+
// read its own version says nothing rather than claiming "unknown" — an
|
|
1450
|
+
// absence here is about the lookup, not about the page.
|
|
1451
|
+
if (data.engine) {
|
|
1452
|
+
document.getElementById('drawn-by').textContent = 'rendered by skitterspec ' + data.engine
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
restore()
|
|
1456
|
+
drawContext()
|
|
1457
|
+
drawLog()
|
|
1458
|
+
wireChecks()
|
|
1459
|
+
state.forEach(function (st) {
|
|
1460
|
+
var btn = st.details && st.details.querySelector('.accept')
|
|
1461
|
+
if (btn) btn.setAttribute('aria-pressed', String(st.accept))
|
|
1462
|
+
if (st.rendered) renderFile(st)
|
|
1463
|
+
redrawStrip(st)
|
|
1464
|
+
})
|
|
1465
|
+
refresh()
|
|
1466
|
+
|
|
1467
|
+
// The [data-theme] blocks are unreachable in a local file without this — a
|
|
1468
|
+
// browser only ever supplies prefers-color-scheme.
|
|
1469
|
+
var themeBtn = document.getElementById('theme')
|
|
1470
|
+
themeBtn.addEventListener('click', function () {
|
|
1471
|
+
var root = document.documentElement
|
|
1472
|
+
var now = root.getAttribute('data-theme')
|
|
1473
|
+
var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
1474
|
+
var next = now === 'dark' ? 'light' : now === 'light' ? 'dark' : dark ? 'light' : 'dark'
|
|
1475
|
+
root.setAttribute('data-theme', next)
|
|
1476
|
+
themeBtn.textContent = next === 'dark' ? 'Theme: dark' : 'Theme: light'
|
|
1477
|
+
})
|
|
1478
|
+
|
|
1479
|
+
// --- header --------------------------------------------------------------
|
|
1480
|
+
document.getElementById('title').textContent = data.title
|
|
1481
|
+
var t = data.totals
|
|
1482
|
+
var sub = document.getElementById('sub')
|
|
1483
|
+
function part(text, cls) { sub.appendChild(el('span', cls, text)) }
|
|
1484
|
+
function sep() { sub.appendChild(el('span', 'sep', '·')) }
|
|
1485
|
+
part(data.branch)
|
|
1486
|
+
sep()
|
|
1487
|
+
part(data.mode === 'branch'
|
|
1488
|
+
? 'everything since ' + data.base + (data.fellBack ? ' (working tree clean)' : '')
|
|
1489
|
+
: 'uncommitted work')
|
|
1490
|
+
sep()
|
|
1491
|
+
part(t.files + ' file' + (t.files === 1 ? '' : 's'))
|
|
1492
|
+
sep()
|
|
1493
|
+
part('+' + t.additions, 'a')
|
|
1494
|
+
part(' ')
|
|
1495
|
+
part('-' + t.deletions, 'd')
|
|
1496
|
+
sep()
|
|
1497
|
+
part(data.generatedAt)
|
|
1498
|
+
})()
|
|
1499
|
+
</script>
|
|
1500
|
+
</body>
|
|
1501
|
+
</html>
|