@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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 +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- package/src/vendor/sync-core/src/write.js +191 -0
|
@@ -0,0 +1,3384 @@
|
|
|
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
|
+
/* The tree scrolls SIDEWAYS; names never wrap. A wrapped name turned every
|
|
172
|
+
long path into three ragged lines, which is what made the sidebar hard to
|
|
173
|
+
scan. Indentation is per-row padding rather than nested <ul> padding — see
|
|
174
|
+
renderTree, which explains why the sticky column below depends on it. */
|
|
175
|
+
.tree { padding: 0 0 .7rem; font-size: .82rem; overflow-x: auto; }
|
|
176
|
+
.tree ul { list-style: none; margin: 0; padding: 0; }
|
|
177
|
+
/* max-content makes the list as wide as its widest row, and min-width keeps it
|
|
178
|
+
filling the sidebar when everything fits. Together they give every row the
|
|
179
|
+
same width, which is what `.ct` needs. */
|
|
180
|
+
.tree > ul { width: max-content; min-width: 100%; }
|
|
181
|
+
.tree .dir { color: var(--muted); display: block; padding: .1rem .6rem; white-space: nowrap; }
|
|
182
|
+
.tree-file {
|
|
183
|
+
display: flex;
|
|
184
|
+
gap: .4rem;
|
|
185
|
+
width: 100%;
|
|
186
|
+
text-align: left;
|
|
187
|
+
/* A COLOUR, not `none`: `.ct` inherits it to mask the name scrolling under
|
|
188
|
+
it, and inherit re-resolves on hover so the pinned counts follow the row. */
|
|
189
|
+
background: var(--panel);
|
|
190
|
+
border: 0;
|
|
191
|
+
border-radius: 6px;
|
|
192
|
+
/* NO horizontal padding, deliberately. A sticky box is clamped by its
|
|
193
|
+
containing block — this row's content box — so a .6rem side padding gave
|
|
194
|
+
`.ct` two different resting places: flush to the scrollport while pinned,
|
|
195
|
+
and .6rem inside it once scrolled to the end, which reads as the counts
|
|
196
|
+
sliding as you reach the right. The gutters live on `.nm` and `.ct`
|
|
197
|
+
instead, where they move with the element rather than fencing it in. */
|
|
198
|
+
padding: .15rem 0;
|
|
199
|
+
color: inherit;
|
|
200
|
+
font-size: .82rem;
|
|
201
|
+
}
|
|
202
|
+
.tree-file:hover { background: var(--panel-2); color: inherit; }
|
|
203
|
+
.tree-file .nm { white-space: nowrap; }
|
|
204
|
+
/* Pinned to the right edge of the tree's scrollport, so the change counts stay
|
|
205
|
+
readable however far the names are scrolled. Sticky can only pin within its
|
|
206
|
+
own row, which is why every row spans the full scroll width above. */
|
|
207
|
+
.tree-file .ct {
|
|
208
|
+
margin-left: auto;
|
|
209
|
+
position: sticky;
|
|
210
|
+
right: 0;
|
|
211
|
+
background: inherit;
|
|
212
|
+
/* The right gutter is PADDING on the pinned element, not a `right` offset.
|
|
213
|
+
An offset would hold the box away from the edge and let names scroll
|
|
214
|
+
visibly through the gap beside it; padding keeps the masking background
|
|
215
|
+
running to the scrollport edge with the text inset. */
|
|
216
|
+
padding-left: .55rem;
|
|
217
|
+
padding-right: .6rem;
|
|
218
|
+
box-shadow: -8px 0 8px -8px rgba(0, 0, 0, .45);
|
|
219
|
+
white-space: nowrap;
|
|
220
|
+
font-family: ui-monospace, monospace;
|
|
221
|
+
font-size: .75rem;
|
|
222
|
+
}
|
|
223
|
+
.tree-file.is-noise .nm { color: var(--muted); }
|
|
224
|
+
|
|
225
|
+
/* THE FILE YOU ARE READING — the card crossing the top of the viewport.
|
|
226
|
+
An INSET SHADOW rather than a border or a left padding: either of those
|
|
227
|
+
would change the row's size, so the highlight moving down the list would
|
|
228
|
+
shunt every name sideways as it went. A shadow paints inside the box the
|
|
229
|
+
row already occupies.
|
|
230
|
+
|
|
231
|
+
A DIFFERENT CHANNEL FROM HOVER, deliberately. Hover owns the `--panel-2`
|
|
232
|
+
background, and the current row is very often NOT the row under your
|
|
233
|
+
finger — you scroll with one and point with the other. Sharing the
|
|
234
|
+
treatment would make the two indistinguishable at exactly the moment they
|
|
235
|
+
disagree, so this is a bar and a colour, and the background is left alone.
|
|
236
|
+
Leaving it alone also keeps `.ct` masking the names scrolling under it,
|
|
237
|
+
which is a background the row inherits. */
|
|
238
|
+
.tree-file.is-current { box-shadow: inset 3px 0 0 var(--accent); }
|
|
239
|
+
.tree-file.is-current .nm { color: var(--accent); font-weight: 600; }
|
|
240
|
+
|
|
241
|
+
.file {
|
|
242
|
+
border: 1px solid var(--line);
|
|
243
|
+
border-radius: 10px;
|
|
244
|
+
margin: 0 0 .9rem;
|
|
245
|
+
overflow: hidden;
|
|
246
|
+
background: var(--bg);
|
|
247
|
+
}
|
|
248
|
+
.file > summary {
|
|
249
|
+
cursor: pointer;
|
|
250
|
+
padding: .55rem .7rem;
|
|
251
|
+
background: var(--panel);
|
|
252
|
+
display: flex;
|
|
253
|
+
gap: .55rem;
|
|
254
|
+
align-items: baseline;
|
|
255
|
+
flex-wrap: wrap;
|
|
256
|
+
}
|
|
257
|
+
.file[hidden] { display: none; }
|
|
258
|
+
.path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; overflow-wrap: anywhere; }
|
|
259
|
+
.chip {
|
|
260
|
+
font-size: .68rem;
|
|
261
|
+
text-transform: uppercase;
|
|
262
|
+
letter-spacing: .04em;
|
|
263
|
+
color: var(--chip-fg);
|
|
264
|
+
background: var(--chip-bg);
|
|
265
|
+
border-radius: 999px;
|
|
266
|
+
padding: .1rem .45rem;
|
|
267
|
+
}
|
|
268
|
+
.counts { margin-left: auto; font-size: .78rem; font-family: ui-monospace, monospace; white-space: nowrap; }
|
|
269
|
+
|
|
270
|
+
/* The ONLY horizontally scrolling box on the page. */
|
|
271
|
+
.code { overflow-x: auto; border-top: 1px solid var(--line); }
|
|
272
|
+
table.diff { border-collapse: collapse; width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }
|
|
273
|
+
table.diff td { padding: 0; vertical-align: top; }
|
|
274
|
+
|
|
275
|
+
/* One sticky gutter cell rather than three: no left-offset arithmetic to get
|
|
276
|
+
wrong, and user-select:none means copying the code does not drag line
|
|
277
|
+
numbers along with it. */
|
|
278
|
+
td.gutter {
|
|
279
|
+
position: sticky;
|
|
280
|
+
left: 0;
|
|
281
|
+
z-index: 1;
|
|
282
|
+
background: var(--panel);
|
|
283
|
+
border-right: 1px solid var(--line);
|
|
284
|
+
user-select: none;
|
|
285
|
+
white-space: pre;
|
|
286
|
+
color: var(--ln-fg);
|
|
287
|
+
padding: 0 .4rem;
|
|
288
|
+
text-align: right;
|
|
289
|
+
}
|
|
290
|
+
td.gutter .n { display: inline-block; min-width: var(--ln-w); }
|
|
291
|
+
td.gutter .s { display: inline-block; min-width: 1.2ch; text-align: center; font-weight: 700; }
|
|
292
|
+
td.src { white-space: pre; padding: 0 .75rem; }
|
|
293
|
+
|
|
294
|
+
tr.add td.src { background: var(--add-bg); color: var(--add-fg); }
|
|
295
|
+
tr.add td.gutter .s { color: var(--add-mark); }
|
|
296
|
+
tr.del td.src { background: var(--del-bg); color: var(--del-fg); }
|
|
297
|
+
tr.del td.gutter .s { color: var(--del-mark); }
|
|
298
|
+
tr.meta td.src { color: var(--muted); font-style: italic; }
|
|
299
|
+
|
|
300
|
+
tr.band td { background: var(--band-bg); color: var(--band-fg); }
|
|
301
|
+
tr.band td.src { padding: .2rem .75rem; white-space: normal; }
|
|
302
|
+
tr.band .n-hidden { margin-right: .6rem; }
|
|
303
|
+
tr.band button { font-size: .72rem; padding: .1rem .5rem; margin-right: .3rem; }
|
|
304
|
+
|
|
305
|
+
.note { color: var(--muted); padding: .6rem .75rem; font-size: .85rem; }
|
|
306
|
+
.empty { color: var(--muted); }
|
|
307
|
+
#review-block:empty { display: none; }
|
|
308
|
+
.review {
|
|
309
|
+
border: 1px solid var(--line);
|
|
310
|
+
border-left: 3px solid var(--accent);
|
|
311
|
+
border-radius: 10px;
|
|
312
|
+
background: var(--panel);
|
|
313
|
+
padding: .8rem 1rem;
|
|
314
|
+
margin: 0 0 1rem;
|
|
315
|
+
}
|
|
316
|
+
.review-summary { margin: 0; }
|
|
317
|
+
.checks { list-style: none; margin: .8rem 0 0; padding: 0; display: grid; gap: .4rem; }
|
|
318
|
+
.check {
|
|
319
|
+
display: grid;
|
|
320
|
+
grid-template-columns: auto 1fr;
|
|
321
|
+
gap: .2rem .6rem;
|
|
322
|
+
align-items: baseline;
|
|
323
|
+
padding: .4rem .6rem;
|
|
324
|
+
border-radius: 8px;
|
|
325
|
+
font-size: .88rem;
|
|
326
|
+
}
|
|
327
|
+
/* The level is a WORD, not a colour — the same reason the diff keeps its +/-
|
|
328
|
+
gutter. Colour is the second signal here, never the only one. */
|
|
329
|
+
.check-level {
|
|
330
|
+
font-size: .68rem;
|
|
331
|
+
text-transform: uppercase;
|
|
332
|
+
letter-spacing: .05em;
|
|
333
|
+
font-weight: 700;
|
|
334
|
+
grid-row: 1 / span 2;
|
|
335
|
+
}
|
|
336
|
+
.check-file {
|
|
337
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
338
|
+
font-size: .78rem;
|
|
339
|
+
color: var(--muted);
|
|
340
|
+
overflow-wrap: anywhere;
|
|
341
|
+
/* A button, because it jumps to the code — but it must keep reading as the
|
|
342
|
+
quiet path label it has always been, not gain a control's chrome. */
|
|
343
|
+
background: none;
|
|
344
|
+
border: 0;
|
|
345
|
+
padding: 0;
|
|
346
|
+
text-align: left;
|
|
347
|
+
cursor: pointer;
|
|
348
|
+
text-decoration: underline;
|
|
349
|
+
text-decoration-style: dotted;
|
|
350
|
+
text-underline-offset: 2px;
|
|
351
|
+
}
|
|
352
|
+
.check-file:hover { color: inherit; }
|
|
353
|
+
/* WHO SAID IT. Absent on a written review's checks, which is why it is a
|
|
354
|
+
separate span rather than a prefix on the note: a page with one author looks
|
|
355
|
+
exactly as it always did. */
|
|
356
|
+
.check-src {
|
|
357
|
+
grid-column: 2;
|
|
358
|
+
font-size: .68rem;
|
|
359
|
+
text-transform: uppercase;
|
|
360
|
+
letter-spacing: .04em;
|
|
361
|
+
font-weight: 700;
|
|
362
|
+
color: var(--muted);
|
|
363
|
+
margin-right: .4rem;
|
|
364
|
+
}
|
|
365
|
+
.check-src + .check-file { grid-column: 2; }
|
|
366
|
+
.check-note { grid-column: 2; }
|
|
367
|
+
|
|
368
|
+
/* --- the reviewer strip --------------------------------------------------
|
|
369
|
+
One line per configured reviewer, present even when every one found nothing.
|
|
370
|
+
A reviewer that was rate-limited and one that read the diff and approved are
|
|
371
|
+
the same page without it, and the reader is about to decide whether to
|
|
372
|
+
commit. */
|
|
373
|
+
.reviewers { list-style: none; margin: 0 0 .8rem; padding: 0; display: grid; gap: .25rem; }
|
|
374
|
+
.reviewer {
|
|
375
|
+
display: flex;
|
|
376
|
+
flex-wrap: wrap;
|
|
377
|
+
gap: .5rem;
|
|
378
|
+
align-items: baseline;
|
|
379
|
+
font-size: .8rem;
|
|
380
|
+
color: var(--muted);
|
|
381
|
+
}
|
|
382
|
+
.reviewer-name {
|
|
383
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
384
|
+
font-weight: 700;
|
|
385
|
+
color: var(--fg);
|
|
386
|
+
}
|
|
387
|
+
.reviewer-name::after { content: ' —'; color: var(--muted); font-weight: 400; }
|
|
388
|
+
/* A reviewer that could not run is marked, not hidden — the distinction between
|
|
389
|
+
"clean" and "never answered" is the whole reason this strip exists. */
|
|
390
|
+
.reviewer.failed .reviewer-said,
|
|
391
|
+
.reviewer.missing .reviewer-said,
|
|
392
|
+
.reviewer.timeout .reviewer-said { color: var(--flag-fg); }
|
|
393
|
+
.reviewer.findings .reviewer-said { color: var(--fg); }
|
|
394
|
+
|
|
395
|
+
/* A line a check pointed at, so a jump lands somewhere visible rather than
|
|
396
|
+
leaving the reader to find the row themselves. */
|
|
397
|
+
tr.goto-hit > td { background: var(--confirm-bg); }
|
|
398
|
+
.check.flag { background: var(--flag-bg); }
|
|
399
|
+
.check.flag .check-level { color: var(--flag-fg); }
|
|
400
|
+
.check.confirm { background: var(--confirm-bg); }
|
|
401
|
+
.check.confirm .check-level { color: var(--confirm-fg); }
|
|
402
|
+
.check.good { background: var(--good-bg); }
|
|
403
|
+
.check.good .check-level { color: var(--good-fg); }
|
|
404
|
+
|
|
405
|
+
/* --- review marks: accepts, comments, replies ---------------------------- */
|
|
406
|
+
/* Every colour below comes from a token defined on bare :root above; nothing
|
|
407
|
+
here gains its first definition inside a theme block. */
|
|
408
|
+
.accept[aria-pressed="true"] {
|
|
409
|
+
background: var(--good-bg);
|
|
410
|
+
color: var(--good-fg);
|
|
411
|
+
border-color: var(--good-fg);
|
|
412
|
+
}
|
|
413
|
+
.chip.accepted { background: var(--good-bg); color: var(--good-fg); }
|
|
414
|
+
.chip.lapsed {
|
|
415
|
+
background: var(--confirm-bg);
|
|
416
|
+
color: var(--confirm-fg);
|
|
417
|
+
text-transform: none;
|
|
418
|
+
letter-spacing: 0;
|
|
419
|
+
}
|
|
420
|
+
.chip.commented { background: var(--note-bg); color: var(--note-fg); }
|
|
421
|
+
|
|
422
|
+
/* The gutter is already user-select:none, so clicking it can never fight with
|
|
423
|
+
selecting the code beside it — which is why the affordance lives there. */
|
|
424
|
+
td.gutter.can-note { cursor: pointer; }
|
|
425
|
+
td.gutter.can-note:hover { color: var(--accent); }
|
|
426
|
+
tr.has-note td.gutter { box-shadow: inset 2px 0 0 var(--note-line); }
|
|
427
|
+
|
|
428
|
+
tr.note-row td {
|
|
429
|
+
background: var(--note-bg);
|
|
430
|
+
color: var(--note-fg);
|
|
431
|
+
white-space: normal;
|
|
432
|
+
padding: .45rem .75rem;
|
|
433
|
+
border-top: 1px solid var(--note-line);
|
|
434
|
+
border-bottom: 1px solid var(--note-line);
|
|
435
|
+
}
|
|
436
|
+
/* A NOTE IS PROSE, AND IT WRAPS TO THE PANE — never to the file.
|
|
437
|
+
The cell it sits in is a column of `table.diff`, so its width is the longest
|
|
438
|
+
line of source in this file (`td.src` is white-space:pre): 4,900px for a
|
|
439
|
+
wrapped markdown spec is ordinary, and a `width:100%` note inside that is four
|
|
440
|
+
screens wide. It scrolled sideways instead of wrapping, which is what a reader
|
|
441
|
+
sees as a box that keeps growing.
|
|
442
|
+
Sticky for the same reason `td.gutter` is: the row can be reached while the
|
|
443
|
+
pane is scrolled right, and a note you cannot see while typing it is no
|
|
444
|
+
better. `--pane-w`/`--gutter-w` are measured per code box by `sizeNotes` —
|
|
445
|
+
measured rather than summed from `--ln-w`, because the gutter is ONE sticky
|
|
446
|
+
cell precisely so nobody has to do that arithmetic.
|
|
447
|
+
`max-width` caps it at the cell, so the note can never be the thing that
|
|
448
|
+
widens the table. */
|
|
449
|
+
.note-body {
|
|
450
|
+
position: sticky;
|
|
451
|
+
left: var(--gutter-w, 0px);
|
|
452
|
+
width: var(--pane-w, 100%);
|
|
453
|
+
max-width: 100%;
|
|
454
|
+
box-sizing: border-box;
|
|
455
|
+
}
|
|
456
|
+
.note-meta { font-size: .72rem; color: var(--muted); display: block; margin-bottom: .15rem; }
|
|
457
|
+
.note-text { overflow-wrap: anywhere; }
|
|
458
|
+
.note-row.is-resolved .note-text { text-decoration: line-through; opacity: .75; }
|
|
459
|
+
.note-resolution { display: block; font-size: .82rem; margin-top: .2rem; }
|
|
460
|
+
.note-input {
|
|
461
|
+
width: 100%;
|
|
462
|
+
font: inherit;
|
|
463
|
+
font-size: .85rem;
|
|
464
|
+
color: var(--fg);
|
|
465
|
+
background: var(--bg);
|
|
466
|
+
border: 1px solid var(--note-line);
|
|
467
|
+
border-radius: 8px;
|
|
468
|
+
padding: .4rem .5rem;
|
|
469
|
+
resize: vertical;
|
|
470
|
+
}
|
|
471
|
+
.note-input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
472
|
+
.note-actions { display: flex; gap: .4rem; margin-top: .4rem; flex-wrap: wrap; }
|
|
473
|
+
|
|
474
|
+
.strip {
|
|
475
|
+
border-top: 1px solid var(--line);
|
|
476
|
+
background: var(--note-bg);
|
|
477
|
+
color: var(--note-fg);
|
|
478
|
+
padding: .5rem .75rem;
|
|
479
|
+
font-size: .85rem;
|
|
480
|
+
display: grid;
|
|
481
|
+
gap: .4rem;
|
|
482
|
+
}
|
|
483
|
+
.strip .note-input { font-size: .85rem; }
|
|
484
|
+
|
|
485
|
+
.reply { grid-column: 2; margin-top: .35rem; }
|
|
486
|
+
.reply .note-input { font-size: .85rem; }
|
|
487
|
+
.reply-given {
|
|
488
|
+
grid-column: 2;
|
|
489
|
+
margin-top: .35rem;
|
|
490
|
+
font-size: .85rem;
|
|
491
|
+
border-left: 2px solid var(--note-line);
|
|
492
|
+
padding-left: .5rem;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/* --- the verdict bar ----------------------------------------------------- */
|
|
496
|
+
/* Every colour here is an existing token: approve borrows the `good` pair and
|
|
497
|
+
changes the `flag` pair, so the bar needs no palette of its own. */
|
|
498
|
+
.verdict {
|
|
499
|
+
display: flex;
|
|
500
|
+
flex-wrap: wrap;
|
|
501
|
+
gap: .5rem;
|
|
502
|
+
align-items: center;
|
|
503
|
+
margin: .9rem 0 0;
|
|
504
|
+
padding: .6rem .7rem;
|
|
505
|
+
border: 1px solid var(--line);
|
|
506
|
+
border-radius: 10px;
|
|
507
|
+
background: var(--panel);
|
|
508
|
+
}
|
|
509
|
+
/* WHERE THIS REVIEW LIVES, and whether it is also running. It sits ABOVE the
|
|
510
|
+
verdicts because the question it answers — do I want to look at this running
|
|
511
|
+
before I conclude? — comes before the conclusion, not after it. Visually its
|
|
512
|
+
own strip, because it is not a verdict: pressing one of these changes what is
|
|
513
|
+
running (or which surfaces are permitted) and hands the reader straight back
|
|
514
|
+
to the same page with the same options. */
|
|
515
|
+
.surfaces {
|
|
516
|
+
margin: 1.2rem 0 0;
|
|
517
|
+
padding: .55rem .7rem;
|
|
518
|
+
border: 1px solid var(--line);
|
|
519
|
+
border-radius: 10px;
|
|
520
|
+
background: var(--panel);
|
|
521
|
+
}
|
|
522
|
+
.surfaces[hidden] { display: none; }
|
|
523
|
+
.surfaces-title { display: block; font-size: .8rem; color: var(--muted); margin: 0 0 .35rem; }
|
|
524
|
+
.surface-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: .3rem 0; }
|
|
525
|
+
.surface-label { font-size: .8rem; font-weight: 600; min-width: 4.5rem; }
|
|
526
|
+
.surface-state { font-size: .8rem; color: var(--muted); }
|
|
527
|
+
.surface-note { font-size: .78rem; color: var(--muted); }
|
|
528
|
+
/* The command, for a page with no transport. Same treatment as the verdict
|
|
529
|
+
command list: plain text a single tap selects whole, never an input. */
|
|
530
|
+
.surface-cmd { font-size: .78rem; user-select: all; }
|
|
531
|
+
.verdict-end { margin: 1.2rem 0 0; }
|
|
532
|
+
.verdict-title { font-size: .8rem; color: var(--muted); }
|
|
533
|
+
.verdict-count { font-size: .8rem; color: var(--muted); }
|
|
534
|
+
.v-commit { color: var(--good-fg); background: var(--good-bg); }
|
|
535
|
+
/* Continue borrows the same affirmative pair: it is the mid-run reader saying
|
|
536
|
+
carry on, and it never appears beside a commit button to be confused with. */
|
|
537
|
+
.v-continue { color: var(--good-fg); background: var(--good-bg); }
|
|
538
|
+
.v-changes { color: var(--flag-fg); background: var(--flag-bg); }
|
|
539
|
+
/* A button the render did not ask for is GONE, not dimmed — a disabled control
|
|
540
|
+
invites a reader to work out how to enable it, and there is nothing to do. */
|
|
541
|
+
.verdict button[hidden] { display: none; }
|
|
542
|
+
/* AND THE BAR ITSELF. `display: flex` beats the browser's `[hidden]`, so the
|
|
543
|
+
whole bar stayed on screen on a `file://` page — offering four buttons that
|
|
544
|
+
POST to a server that is not there, directly above the command list that
|
|
545
|
+
exists because they cannot. The guard in `assets-review` only read the
|
|
546
|
+
template for a `hidden` attribute, and this element is hidden at runtime, so
|
|
547
|
+
it never saw it; the guard reads the script now too. */
|
|
548
|
+
.verdict[hidden] { display: none; }
|
|
549
|
+
/* A blocked control is dashed as well as dimmed, so the block survives a
|
|
550
|
+
greyscale screen — and the reason rides in the label, never in a tooltip
|
|
551
|
+
nobody hovers on a phone. */
|
|
552
|
+
button:disabled { color: var(--muted); background: var(--panel-2); border-style: dashed; cursor: not-allowed; }
|
|
553
|
+
button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
554
|
+
.verdict-log { font-size: .8rem; color: var(--muted); margin: .45rem 0 0; }
|
|
555
|
+
.verdict-log[hidden] { display: none; }
|
|
556
|
+
/* Not muted, unlike the log beneath it: this one is the reason a press did
|
|
557
|
+
nothing, and a grey line under a bar that appeared to ignore you reads as a
|
|
558
|
+
broken page. */
|
|
559
|
+
.verdict-warn {
|
|
560
|
+
font-size: .85rem;
|
|
561
|
+
color: var(--flag-fg);
|
|
562
|
+
background: var(--flag-bg);
|
|
563
|
+
border-left: 3px solid var(--flag-fg);
|
|
564
|
+
border-radius: 0 6px 6px 0;
|
|
565
|
+
padding: .45rem .6rem;
|
|
566
|
+
margin: .5rem 0 0;
|
|
567
|
+
}
|
|
568
|
+
.verdict-warn[hidden] { display: none; }
|
|
569
|
+
/* Which engine drew this page. A render is always current and a stale RENDERER
|
|
570
|
+
is invisible without it — the counts move, the date moves, the diff is right. */
|
|
571
|
+
.drawn-by { font-size: .75rem; color: var(--muted); margin: .9rem 0 0; }
|
|
572
|
+
|
|
573
|
+
.copy-out {
|
|
574
|
+
width: 100%;
|
|
575
|
+
min-height: 8rem;
|
|
576
|
+
margin-top: .6rem;
|
|
577
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
578
|
+
font-size: .75rem;
|
|
579
|
+
color: var(--fg);
|
|
580
|
+
background: var(--panel);
|
|
581
|
+
border: 1px solid var(--line);
|
|
582
|
+
border-radius: 8px;
|
|
583
|
+
padding: .5rem;
|
|
584
|
+
}
|
|
585
|
+
.copy-out[hidden] { display: none; }
|
|
586
|
+
/* THE REVIEW, ONCE IT IS OVER. A verdict that has actually been handed over
|
|
587
|
+
ends the page: the diff fades out and what you decided takes its place. The
|
|
588
|
+
fade is a transition on a class rather than a timer, so nothing depends on
|
|
589
|
+
JavaScript finishing, and `Show the diff anyway` brings it back — read-only,
|
|
590
|
+
with the buttons dead. */
|
|
591
|
+
.reviewable { transition: opacity .35s ease; }
|
|
592
|
+
.is-decided .reviewable { display: none; }
|
|
593
|
+
.is-decided.show-diff .reviewable { display: block; opacity: .75; }
|
|
594
|
+
.is-decided.show-diff .reviewable:hover { opacity: 1; }
|
|
595
|
+
/* Bringing the diff back must not resurrect a section that was hidden for its
|
|
596
|
+
own reasons — a spec with no context ships `#context` hidden, and the rule
|
|
597
|
+
above would have shown an empty panel. The second selector is there for
|
|
598
|
+
specificity: `.is-decided.show-diff .reviewable` outranks a bare
|
|
599
|
+
`.reviewable[hidden]`. */
|
|
600
|
+
.reviewable[hidden],
|
|
601
|
+
.is-decided.show-diff .reviewable[hidden] { display: none; }
|
|
602
|
+
|
|
603
|
+
.decided {
|
|
604
|
+
margin: 0 0 1.25rem;
|
|
605
|
+
padding: 1.1rem 1.2rem;
|
|
606
|
+
border: 1px solid var(--line);
|
|
607
|
+
border-left: 4px solid var(--accent);
|
|
608
|
+
border-radius: 10px;
|
|
609
|
+
background: var(--panel);
|
|
610
|
+
animation: decided-in .35s ease;
|
|
611
|
+
}
|
|
612
|
+
@keyframes decided-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
613
|
+
.decided[hidden] { display: none; }
|
|
614
|
+
.decided-what { margin: 0 0 .35rem; font-size: 1.05rem; font-weight: 650; line-height: 1.4; }
|
|
615
|
+
.decided-note { margin: 0 0 .8rem; color: var(--muted); font-size: .88rem; line-height: 1.5; max-width: 62ch; }
|
|
616
|
+
.decided-note:last-child { margin-bottom: 0; }
|
|
617
|
+
/* SPACE ABOVE IT. The toggle sits under whatever the panel ended on — a
|
|
618
|
+
sentence, or the command callout — and butted straight against the callout's
|
|
619
|
+
border it read as part of it rather than as the way back to the diff. */
|
|
620
|
+
.decided-toggle { font-size: .78rem; margin-top: .8rem; }
|
|
621
|
+
|
|
622
|
+
/* STILL ASKING. The window between the pass landing on the server and the
|
|
623
|
+
engine saying whether a session took it — up to seven seconds, and until now
|
|
624
|
+
the page filled it with the pessimistic answer and retracted it afterwards.
|
|
625
|
+
A spinner is the honest shape for a question with no answer yet. */
|
|
626
|
+
.pickup-wait {
|
|
627
|
+
display: flex;
|
|
628
|
+
align-items: center;
|
|
629
|
+
gap: .5rem;
|
|
630
|
+
margin: 0 0 .8rem;
|
|
631
|
+
color: var(--muted);
|
|
632
|
+
font-size: .88rem;
|
|
633
|
+
}
|
|
634
|
+
.pickup-wait[hidden] { display: none; }
|
|
635
|
+
.pickup-spin {
|
|
636
|
+
width: .85rem;
|
|
637
|
+
height: .85rem;
|
|
638
|
+
flex: none;
|
|
639
|
+
border: 2px solid var(--line);
|
|
640
|
+
/* One side in the accent is what makes a ring read as turning. */
|
|
641
|
+
border-top-color: var(--accent);
|
|
642
|
+
border-radius: 50%;
|
|
643
|
+
animation: pickup-spin .8s linear infinite;
|
|
644
|
+
}
|
|
645
|
+
@keyframes pickup-spin { to { transform: rotate(360deg); } }
|
|
646
|
+
/* A STATIC RING, not a hidden one. Someone who asked for less motion still
|
|
647
|
+
needs to see that something is in progress, and the sentence beside it is
|
|
648
|
+
doing most of that work anyway. */
|
|
649
|
+
@media (prefers-reduced-motion: reduce) {
|
|
650
|
+
.pickup-spin { animation: none; border-top-color: var(--accent); }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/* WHY THE CHANGE EXISTS — the PR description this page never had. It is prose
|
|
654
|
+
to be read rather than data to be scanned, so it gets a measure, air, and a
|
|
655
|
+
quieter voice than the diff below it. It was once full-bleed 15px text at
|
|
656
|
+
1.5 in a tight box, which read as a wall and got skipped. */
|
|
657
|
+
.context {
|
|
658
|
+
margin: 0 0 1.5rem;
|
|
659
|
+
padding: 1.1rem 1.3rem;
|
|
660
|
+
border: 1px solid var(--line);
|
|
661
|
+
border-radius: 10px;
|
|
662
|
+
background: var(--panel, transparent);
|
|
663
|
+
}
|
|
664
|
+
.context h3 {
|
|
665
|
+
font-size: .72rem;
|
|
666
|
+
text-transform: uppercase;
|
|
667
|
+
letter-spacing: .08em;
|
|
668
|
+
color: var(--muted);
|
|
669
|
+
font-weight: 650;
|
|
670
|
+
margin: 1.4rem 0 .5rem;
|
|
671
|
+
}
|
|
672
|
+
.context h3:first-child { margin-top: 0; }
|
|
673
|
+
/* A MEASURE. Prose set the full width of a 1400px page is measurably harder to
|
|
674
|
+
read — the eye loses the line it is returning to. */
|
|
675
|
+
.context p { margin: 0 0 .7rem; font-size: .92rem; line-height: 1.65; max-width: 72ch; }
|
|
676
|
+
.context p:last-child { margin-bottom: 0; }
|
|
677
|
+
.context table { border-collapse: collapse; width: 100%; font-size: .84rem; margin: .2rem 0 .3rem; }
|
|
678
|
+
.context th, .context td { text-align: left; padding: .35rem .8rem .35rem 0; vertical-align: top; }
|
|
679
|
+
.context th {
|
|
680
|
+
color: var(--muted);
|
|
681
|
+
font-weight: 600;
|
|
682
|
+
font-size: .72rem;
|
|
683
|
+
text-transform: uppercase;
|
|
684
|
+
letter-spacing: .05em;
|
|
685
|
+
border-bottom: 1px solid var(--line);
|
|
686
|
+
padding-bottom: .3rem;
|
|
687
|
+
}
|
|
688
|
+
.context tbody tr + tr td { border-top: 1px solid var(--line); }
|
|
689
|
+
.context ul { margin: 0; padding-left: 1.15rem; max-width: 72ch; }
|
|
690
|
+
.context li { margin: .3rem 0; font-size: .92rem; line-height: 1.6; }
|
|
691
|
+
/* The disclosure is a CONTROL, not a panel. Full-width and boxed, it read as a
|
|
692
|
+
section header that happened to be clickable. */
|
|
693
|
+
.context-more { margin: .9rem 0 0; }
|
|
694
|
+
.context-more > summary {
|
|
695
|
+
display: inline-block;
|
|
696
|
+
width: auto;
|
|
697
|
+
cursor: pointer;
|
|
698
|
+
font-size: .78rem;
|
|
699
|
+
color: var(--muted);
|
|
700
|
+
padding: .25rem .7rem;
|
|
701
|
+
border: 1px solid var(--line);
|
|
702
|
+
border-radius: 999px;
|
|
703
|
+
list-style: none;
|
|
704
|
+
}
|
|
705
|
+
.context-more > summary::-webkit-details-marker { display: none; }
|
|
706
|
+
.context-more > summary:hover { color: var(--accent); border-color: var(--accent); }
|
|
707
|
+
.context-more[open] > summary { margin-bottom: .6rem; }
|
|
708
|
+
.context li.done { color: var(--muted); }
|
|
709
|
+
/* The ticket rides ON the title line rather than under it — it is the other
|
|
710
|
+
name for this work, not a fact about it. Sized down and tinted so the title
|
|
711
|
+
still reads as the title. */
|
|
712
|
+
.ticket {
|
|
713
|
+
font-size: .62em; font-weight: 600; vertical-align: middle;
|
|
714
|
+
font-family: var(--mono, ui-monospace, monospace);
|
|
715
|
+
color: var(--accent); text-decoration: none;
|
|
716
|
+
padding: .12em .45em; border: 1px solid var(--line); border-radius: 999px;
|
|
717
|
+
white-space: nowrap;
|
|
718
|
+
}
|
|
719
|
+
a.ticket:hover { border-color: var(--accent); }
|
|
720
|
+
.context-more > summary { cursor: pointer; color: var(--muted); font-size: .85rem; margin-top: .6rem; }
|
|
721
|
+
.copy-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 0; }
|
|
722
|
+
.sent-cmd { display: flex; gap: .4rem; align-items: stretch; margin: .4rem 0 0; }
|
|
723
|
+
/* A `display` rule BEATS the browser's own `[hidden] { display: none }`, so
|
|
724
|
+
every element this page hides in JavaScript needs its own override or the
|
|
725
|
+
hide silently does nothing. It shipped without one and a finished review kept
|
|
726
|
+
showing an empty command box. `assets-review` guards the whole class now. */
|
|
727
|
+
.sent-cmd[hidden] { display: none; }
|
|
728
|
+
.sent-cmd-text {
|
|
729
|
+
flex: 1; min-width: 0; font-family: var(--mono, ui-monospace, monospace);
|
|
730
|
+
font-size: .85rem; padding: .35rem .5rem; color: var(--fg);
|
|
731
|
+
background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
|
|
732
|
+
/* One tap takes the whole command — the gesture that still works on a page
|
|
733
|
+
served over plain http, where the clipboard API is withheld. */
|
|
734
|
+
user-select: all; -webkit-user-select: all;
|
|
735
|
+
overflow-x: auto; white-space: pre; line-height: 1.6;
|
|
736
|
+
}
|
|
737
|
+
.sent-cmd-copy { white-space: nowrap; }
|
|
738
|
+
/* ONE GRID FOR THE WHOLE LIST, so the command column starts at the same x on
|
|
739
|
+
every row. A per-row layout cannot do that: each row would size its own label
|
|
740
|
+
column to its own label, and `Commit` and `Commit & Continue` are not the
|
|
741
|
+
same width. The label column is `max-content` — the longest label decides,
|
|
742
|
+
which is a fact about the labels rather than a rem value someone has to
|
|
743
|
+
remember to update when one changes. */
|
|
744
|
+
.cmd-list { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; margin: .6rem 0 0; }
|
|
745
|
+
.cmd-list[hidden] { display: none; }
|
|
746
|
+
.cmd-lead { grid-column: 1 / -1; font-size: .85rem; color: var(--muted); margin: 0 0 .5rem; }
|
|
747
|
+
/* ONE LINE: label, command, Copy. It was three lines deep — the label on its
|
|
748
|
+
own row above the command — which spent a screenful on four rows that fit on
|
|
749
|
+
four lines. */
|
|
750
|
+
.cmd-row {
|
|
751
|
+
display: grid;
|
|
752
|
+
grid-column: 1 / -1;
|
|
753
|
+
/* FALLBACK FIRST, then subgrid. A browser without subgrid drops the second
|
|
754
|
+
declaration and keeps this one, which still gives one line per row — just
|
|
755
|
+
each sized to itself. Without a fallback the row would fall back to a
|
|
756
|
+
single auto column and stack, which is worse than the misalignment. */
|
|
757
|
+
grid-template-columns: max-content minmax(0, 1fr) max-content;
|
|
758
|
+
grid-template-columns: subgrid;
|
|
759
|
+
gap: .5rem; align-items: center;
|
|
760
|
+
padding: .4rem .5rem; margin-bottom: .35rem;
|
|
761
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
|
|
762
|
+
}
|
|
763
|
+
.cmd-row[hidden] { display: none; }
|
|
764
|
+
.cmd-label { font-weight: 600; font-size: .85rem; white-space: nowrap; }
|
|
765
|
+
/* A TINTED EDGE, NOT A TINTED BOX. Four filled rows read as four warnings —
|
|
766
|
+
the colour shouted where it only needed to identify. The left border and the
|
|
767
|
+
label carry it now, which is enough to recognise the choice by and quiet
|
|
768
|
+
enough to read past. No new tokens: `v-commit` and `v-changes` above borrow
|
|
769
|
+
exactly this pair. */
|
|
770
|
+
.cmd-row[data-verdict="commit"],
|
|
771
|
+
.cmd-row[data-verdict="commit-start"],
|
|
772
|
+
.cmd-row[data-verdict="commit-land"],
|
|
773
|
+
.cmd-row[data-verdict="commit-continue"],
|
|
774
|
+
.cmd-row[data-verdict="continue"] { border-left: 3px solid var(--good-fg); }
|
|
775
|
+
.cmd-row[data-verdict="commit"] .cmd-label,
|
|
776
|
+
.cmd-row[data-verdict="commit-start"] .cmd-label,
|
|
777
|
+
.cmd-row[data-verdict="commit-land"] .cmd-label,
|
|
778
|
+
.cmd-row[data-verdict="commit-continue"] .cmd-label,
|
|
779
|
+
.cmd-row[data-verdict="continue"] .cmd-label { color: var(--good-fg); }
|
|
780
|
+
.cmd-row[data-verdict="changes"] { border-left: 3px solid var(--flag-fg); }
|
|
781
|
+
.cmd-row[data-verdict="changes"] .cmd-label { color: var(--flag-fg); }
|
|
782
|
+
/* Discuss keeps the neutral panel, exactly as its button does — it is the
|
|
783
|
+
ending that decides nothing, and colouring it would say otherwise. */
|
|
784
|
+
.cmd-text {
|
|
785
|
+
/* No `flex` — the list's grid column sizes this now. min-width:0 still
|
|
786
|
+
earns its place: it is what lets a long command scroll inside its own box
|
|
787
|
+
instead of widening the column and pushing Copy off the row. */
|
|
788
|
+
min-width: 0; font-family: var(--mono, ui-monospace, monospace);
|
|
789
|
+
font-size: .85rem; padding: .3rem .45rem; color: var(--fg);
|
|
790
|
+
background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
|
|
791
|
+
/* One tap takes the whole command — see `.sent-cmd-text`. */
|
|
792
|
+
user-select: all; -webkit-user-select: all;
|
|
793
|
+
overflow-x: auto; white-space: pre; line-height: 1.6;
|
|
794
|
+
}
|
|
795
|
+
.cmd-copy { white-space: nowrap; }
|
|
796
|
+
/* AN ACTION, NOT A NOTE. The command reached the reader as a grey line under a
|
|
797
|
+
bar that had just closed, and it read as a footnote to something finished —
|
|
798
|
+
so it is now a filled callout inside the decided panel, which is the block a
|
|
799
|
+
re-opened page shows first. It reuses the add palette rather than minting a
|
|
800
|
+
green of its own: those three tokens are already defined on bare `:root` and
|
|
801
|
+
redefined in both theme blocks, which is the property `assets-review` guards. */
|
|
802
|
+
.sent-cmd.act {
|
|
803
|
+
flex-wrap: wrap; align-items: center; margin-top: .7rem;
|
|
804
|
+
padding: .55rem .6rem; border-radius: 6px;
|
|
805
|
+
background: var(--add-bg); border: 1px solid var(--add-mark); color: var(--add-fg);
|
|
806
|
+
}
|
|
807
|
+
.sent-cmd-lead { flex-basis: 100%; font-size: .85rem; font-weight: 600; color: var(--add-fg); }
|
|
808
|
+
/* A FAILED SEND, IN THE PALETTE OF A FAILURE. The same callout the waiting-pass
|
|
809
|
+
case uses, in the delete colours rather than the add ones — `--del-bg` /
|
|
810
|
+
`--del-fg` / `--del-mark` are already defined on bare `:root` and redefined in
|
|
811
|
+
both theme blocks, which is the property `assets-review` guards. No new token
|
|
812
|
+
is minted for this. */
|
|
813
|
+
.sent-cmd.act.fail {
|
|
814
|
+
background: var(--del-bg); border-color: var(--del-mark); color: var(--del-fg);
|
|
815
|
+
}
|
|
816
|
+
.sent-cmd.act.fail .sent-cmd-lead { color: var(--del-fg); }
|
|
817
|
+
/* THE OUTCOME THE READER MUST ACT ON, AS LOUD AS THE ONE THEY NEED NOT.
|
|
818
|
+
A failed send used to be a small grey line in the footer, under a verdict bar
|
|
819
|
+
that had just closed — quieter than the success it was not. It gets the panel
|
|
820
|
+
the decided page gets, at the top of the column, in the delete palette. What
|
|
821
|
+
it does NOT get is `is-decided`: nothing was delivered, so the diff stays and
|
|
822
|
+
the verdict buttons stay live for a second try. */
|
|
823
|
+
.decided.failed {
|
|
824
|
+
border-left-color: var(--del-mark);
|
|
825
|
+
background: var(--panel);
|
|
826
|
+
}
|
|
827
|
+
.decided.failed .decided-what { color: var(--del-mark); }
|
|
828
|
+
/* The claim code is read aloud or copied, so it is set apart from the sentence
|
|
829
|
+
around it rather than buried in it. */
|
|
830
|
+
.copy-hint { user-select: text; }
|
|
831
|
+
</style>
|
|
832
|
+
</head>
|
|
833
|
+
<body>
|
|
834
|
+
<div class="wrap" id="wrap">
|
|
835
|
+
<header>
|
|
836
|
+
<h1 id="title"></h1>
|
|
837
|
+
<p class="sub" id="sub"></p>
|
|
838
|
+
<div class="controls">
|
|
839
|
+
<button id="expand-all" type="button">Show all context</button>
|
|
840
|
+
<button id="collapse-all" type="button">Collapse context</button>
|
|
841
|
+
<label class="toggle"><input type="checkbox" id="show-noise"> <span id="noise-label">Show bookkeeping</span></label>
|
|
842
|
+
<button id="theme" type="button" aria-live="polite">Theme</button>
|
|
843
|
+
</div>
|
|
844
|
+
</header>
|
|
845
|
+
|
|
846
|
+
<div class="layout">
|
|
847
|
+
<details class="tree-wrap" id="tree-wrap" open>
|
|
848
|
+
<summary id="tree-summary">Files</summary>
|
|
849
|
+
<nav class="tree" id="tree"></nav>
|
|
850
|
+
</details>
|
|
851
|
+
<main class="main">
|
|
852
|
+
<!-- WHAT THE CHANGE IS FOR, above the first filename. Composed by the
|
|
853
|
+
engine from the spec's own files — nothing here passed through the
|
|
854
|
+
model, so it is free however large the diff. Absent entirely for a spec
|
|
855
|
+
whose overview cannot be read: the page worked without it before. -->
|
|
856
|
+
<!-- A SEND THAT DID NOT LAND. Same shape as the panel below, delete palette,
|
|
857
|
+
and deliberately NOT the same section: this one leaves the page
|
|
858
|
+
undecided, because nothing was delivered and the reader still has a
|
|
859
|
+
verdict to give. It names which failure happened, since a server that
|
|
860
|
+
has moved and a pass the engine refused send the reader to different
|
|
861
|
+
places. -->
|
|
862
|
+
<section class="decided failed" id="send-failed" hidden>
|
|
863
|
+
<p class="decided-what" id="send-failed-what"></p>
|
|
864
|
+
<p class="decided-note" id="send-failed-note"></p>
|
|
865
|
+
<p class="sent-cmd act fail" id="send-failed-cmd" hidden>
|
|
866
|
+
<span class="sent-cmd-lead" id="send-failed-lead"></span>
|
|
867
|
+
<code class="sent-cmd-text" id="send-failed-text"></code>
|
|
868
|
+
<button type="button" class="sent-cmd-copy" id="send-failed-copy" hidden>Copy</button>
|
|
869
|
+
</p>
|
|
870
|
+
</section>
|
|
871
|
+
<!-- WHAT YOU DECIDED, once the pass has actually been handed over. It sits
|
|
872
|
+
above everything because on a re-open it is the only thing you need:
|
|
873
|
+
the review is over, and this says how it ended. -->
|
|
874
|
+
<section class="decided" id="decided" hidden>
|
|
875
|
+
<p class="decided-what" id="decided-what"></p>
|
|
876
|
+
<p class="decided-note" id="decided-note"></p>
|
|
877
|
+
<!-- SHOWN ONLY WHILE THE PAGE IS ACTUALLY ASKING. A tab reopened
|
|
878
|
+
tomorrow over a stored `null` is reading history, not watching a
|
|
879
|
+
live poll, so it gets the command instead — a spinner that can never
|
|
880
|
+
resolve is worse than no spinner. -->
|
|
881
|
+
<p class="pickup-wait" id="pickup-wait" hidden>
|
|
882
|
+
<span class="pickup-spin" aria-hidden="true"></span>
|
|
883
|
+
<span id="pickup-wait-text">Sent — waiting for Claude to pick it up…</span>
|
|
884
|
+
</p>
|
|
885
|
+
<!-- THE ONE THING LEFT TO DO, and it lives HERE rather than under the
|
|
886
|
+
verdict bar because that is where it was being missed: a grey line
|
|
887
|
+
below a bar that had just closed, in the type size of a footnote,
|
|
888
|
+
saying the same words whether it mattered or not. It is shown only
|
|
889
|
+
where the page has ESTABLISHED that the pass is still sitting there,
|
|
890
|
+
so when it appears it is always an instruction. -->
|
|
891
|
+
<p class="sent-cmd act" id="sent-cmd" hidden>
|
|
892
|
+
<span class="sent-cmd-lead" id="sent-cmd-lead"></span>
|
|
893
|
+
<!-- PLAIN TEXT, NOT AN INPUT. A readonly field still looks like
|
|
894
|
+
something to type into, and on a phone tapping it raises the
|
|
895
|
+
keyboard for a value nobody can change. It is a command to read and
|
|
896
|
+
copy, so it is marked up as one — `user-select: all` makes a single
|
|
897
|
+
tap take the whole thing, which is the gesture that works where no
|
|
898
|
+
clipboard API does. -->
|
|
899
|
+
<code class="sent-cmd-text" id="sent-cmd-text"></code>
|
|
900
|
+
<button type="button" class="sent-cmd-copy" id="sent-cmd-copy" hidden>Copy</button>
|
|
901
|
+
</p>
|
|
902
|
+
<button type="button" class="decided-toggle" id="decided-toggle">Show the diff anyway</button>
|
|
903
|
+
</section>
|
|
904
|
+
<section class="context reviewable" id="context" hidden>
|
|
905
|
+
<div class="context-why" id="context-why"></div>
|
|
906
|
+
<details class="context-more" id="context-more" hidden>
|
|
907
|
+
<summary id="context-more-summary">More</summary>
|
|
908
|
+
<div id="context-rest"></div>
|
|
909
|
+
</details>
|
|
910
|
+
</section>
|
|
911
|
+
<div id="review-block" class="reviewable">__REVIEW_BLOCK__</div>
|
|
912
|
+
<div id="files" class="reviewable"></div>
|
|
913
|
+
<!-- THE SURFACES STRIP. Built by the script from `data.surfaces`, and
|
|
914
|
+
absent entirely when the render carried none — a `--docs` page has no
|
|
915
|
+
branch to put live, so it gets no strip and no explanation of why. -->
|
|
916
|
+
<section class="surfaces reviewable" id="surfaces" hidden>
|
|
917
|
+
<span class="surfaces-title">Where this runs</span>
|
|
918
|
+
<div id="surface-rows"></div>
|
|
919
|
+
</section>
|
|
920
|
+
<!-- THE VERDICT SITS AT THE END, where reading finishes. It began in the
|
|
921
|
+
header and the first person to use it could not find it: you read a
|
|
922
|
+
350-line diff downward, and the control asking for your conclusion was
|
|
923
|
+
off-screen above you. A conclusion belongs after the thing it is about.
|
|
924
|
+
Not sticky, deliberately — a permanent bar costs vertical space on the
|
|
925
|
+
phone this page is read on, on a page whose whole job is showing code. -->
|
|
926
|
+
<footer class="verdict-end">
|
|
927
|
+
<div class="verdict" id="verdict">
|
|
928
|
+
<span class="verdict-title">Your verdict</span>
|
|
929
|
+
<button id="verdict-commit" class="v-commit" type="button" aria-live="polite">✓ Commit</button>
|
|
930
|
+
<button id="verdict-commit-continue" class="v-commit" type="button" aria-live="polite">✓ Commit & Continue</button>
|
|
931
|
+
<!-- THE MID-RUN VERDICT, hidden unless the render asked for it. "Commit"
|
|
932
|
+
is the wrong verb for unfinished work, so a page rendered part-way
|
|
933
|
+
through a run offers this instead of the committing pair: I have
|
|
934
|
+
read it, carry on. It is not the removed `none` verdict — that
|
|
935
|
+
recorded itself and did nothing, while this one resumes the run. -->
|
|
936
|
+
<button id="verdict-continue" class="v-continue" type="button" hidden>→ Continue</button>
|
|
937
|
+
<!-- THE AUTHORING VERDICT, hidden unless the render asked for it. A spec
|
|
938
|
+
that has just been written has no phase in flight to continue, so
|
|
939
|
+
"Commit & Continue" is the wrong verb for it: this one commits the
|
|
940
|
+
spec and puts it IN FLIGHT, which is the `commit && /spec-start`
|
|
941
|
+
that was typed by hand until now. -->
|
|
942
|
+
<button id="verdict-commit-start" class="v-commit" type="button" hidden>✓ Commit & Start</button>
|
|
943
|
+
<!-- THE /no-spec VERDICT, hidden unless the render asked for it. Work with
|
|
944
|
+
no spec has no next phase and nothing to put in flight, so neither
|
|
945
|
+
committing verb above fits: this one commits, fast-forwards the base
|
|
946
|
+
branch, and tears the worktree down. Its own word rather than a
|
|
947
|
+
context-dependent reading of "Commit", because one of those two
|
|
948
|
+
readings destroys a worktree. -->
|
|
949
|
+
<button id="verdict-commit-land" class="v-commit" type="button" hidden>✓ Commit & Land</button>
|
|
950
|
+
<button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
|
|
951
|
+
<button id="verdict-discuss" type="button">… Discuss first</button>
|
|
952
|
+
<span class="verdict-count" id="verdict-count" aria-live="polite"></span>
|
|
953
|
+
</div>
|
|
954
|
+
<!-- UNSENT TEXT, named at the moment it would have been destroyed. It sits
|
|
955
|
+
directly under the bar rather than beside the editor, because the
|
|
956
|
+
press that raises it happens here and the editor may be a screen
|
|
957
|
+
away. `assertive`: the reader is mid-decision and a polite region
|
|
958
|
+
would be read out after they have pressed again. -->
|
|
959
|
+
<p class="verdict-warn" id="verdict-warn" hidden aria-live="assertive"></p>
|
|
960
|
+
<!-- WHERE THE BUTTONS CANNOT DELIVER, the commands stand in their place.
|
|
961
|
+
A `file://` page has no server to POST to and no store to write to,
|
|
962
|
+
so a verdict button there only ever built a blob for the reader to
|
|
963
|
+
paste — a wall of JSON standing in for one word. Each row is named
|
|
964
|
+
with the BUTTON'S own label, because that is the word the reader
|
|
965
|
+
chose everywhere else; `commit-continue` is the wire spelling, and
|
|
966
|
+
nobody should have to learn a second vocabulary to recognise their
|
|
967
|
+
own decision. Built by the script, so there is exactly one list of
|
|
968
|
+
verdicts and it is the one the bar already uses. -->
|
|
969
|
+
<div class="cmd-list" id="cmd-list" hidden>
|
|
970
|
+
<p class="cmd-lead" id="cmd-lead"></p>
|
|
971
|
+
</div>
|
|
972
|
+
<p class="verdict-log" id="verdict-log" hidden></p>
|
|
973
|
+
<p class="copy-hint" id="copy-hint" hidden></p>
|
|
974
|
+
<textarea class="copy-out" id="copy-out" readonly hidden aria-label="Review blob to copy"></textarea>
|
|
975
|
+
<p class="drawn-by" id="drawn-by"></p>
|
|
976
|
+
</footer>
|
|
977
|
+
</main>
|
|
978
|
+
</div>
|
|
979
|
+
</div>
|
|
980
|
+
|
|
981
|
+
<script type="application/json" id="review-data">__REVIEW_DATA__</script>
|
|
982
|
+
<script>
|
|
983
|
+
(function () {
|
|
984
|
+
'use strict'
|
|
985
|
+
|
|
986
|
+
var data = JSON.parse(document.getElementById('review-data').textContent)
|
|
987
|
+
|
|
988
|
+
// Lines kept either side of a change, and how much one "expand" step reveals.
|
|
989
|
+
var CONTEXT = 4
|
|
990
|
+
var STEP = 20
|
|
991
|
+
|
|
992
|
+
// --- patch parsing -------------------------------------------------------
|
|
993
|
+
// Rows carry their own line numbers so the gutter needs no second pass. Hunk
|
|
994
|
+
// headers are emitted ONLY when there is more than one hunk: with whole-file
|
|
995
|
+
// context there is exactly one, and a "@@ -1,400 +1,402 @@" banner above a
|
|
996
|
+
// whole file says nothing a reader wants.
|
|
997
|
+
function parsePatch(patch) {
|
|
998
|
+
var raw = patch.split('\n')
|
|
999
|
+
var hunks = 0
|
|
1000
|
+
var i
|
|
1001
|
+
for (i = 0; i < raw.length; i++) if (raw[i].slice(0, 2) === '@@') hunks++
|
|
1002
|
+
|
|
1003
|
+
var rows = []
|
|
1004
|
+
var oldNo = 0
|
|
1005
|
+
var newNo = 0
|
|
1006
|
+
var started = false
|
|
1007
|
+
for (i = 0; i < raw.length; i++) {
|
|
1008
|
+
var line = raw[i]
|
|
1009
|
+
if (line.slice(0, 2) === '@@') {
|
|
1010
|
+
var m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
|
|
1011
|
+
if (m) { oldNo = +m[1]; newNo = +m[2] }
|
|
1012
|
+
started = true
|
|
1013
|
+
if (hunks > 1) rows.push({ t: 'hunk', text: line })
|
|
1014
|
+
continue
|
|
1015
|
+
}
|
|
1016
|
+
if (!started) continue
|
|
1017
|
+
var c = line.charAt(0)
|
|
1018
|
+
if (c === '+') rows.push({ t: 'add', text: line.slice(1), n: newNo++ })
|
|
1019
|
+
else if (c === '-') rows.push({ t: 'del', text: line.slice(1), o: oldNo++ })
|
|
1020
|
+
else if (c === '\\') rows.push({ t: 'meta', text: line.slice(2) })
|
|
1021
|
+
else rows.push({ t: 'ctx', text: line.slice(1), o: oldNo++, n: newNo++ })
|
|
1022
|
+
}
|
|
1023
|
+
return rows
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Which stretches of unchanged context start out hidden. A run touching the
|
|
1027
|
+
// top or bottom of the file collapses all the way to the edge — there is no
|
|
1028
|
+
// change on that side to need context for.
|
|
1029
|
+
function initialBands(rows) {
|
|
1030
|
+
var bands = []
|
|
1031
|
+
var i = 0
|
|
1032
|
+
while (i < rows.length) {
|
|
1033
|
+
if (rows[i].t !== 'ctx') { i++; continue }
|
|
1034
|
+
var j = i
|
|
1035
|
+
while (j < rows.length && rows[j].t === 'ctx') j++
|
|
1036
|
+
var atStart = i === 0
|
|
1037
|
+
var atEnd = j === rows.length
|
|
1038
|
+
var from = atStart ? i : i + CONTEXT
|
|
1039
|
+
var to = atEnd ? j : j - CONTEXT
|
|
1040
|
+
// A band hiding one or two lines is worse than just showing them.
|
|
1041
|
+
if (to - from >= 3) bands.push({ from: from, to: to })
|
|
1042
|
+
i = j
|
|
1043
|
+
}
|
|
1044
|
+
return bands
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// --- one order, for both columns -----------------------------------------
|
|
1048
|
+
// The sidebar and the panes are two views of one list, and they must be the
|
|
1049
|
+
// SAME list in the same order — a reader scrolling the panes watches the
|
|
1050
|
+
// current-file mark walk the sidebar, and a mark that walks backwards is the
|
|
1051
|
+
// whole reason this exists.
|
|
1052
|
+
//
|
|
1053
|
+
// The emitted order cannot be it. `review.js` collects tracked changes and
|
|
1054
|
+
// untracked files in two passes, so a new file is appended after everything
|
|
1055
|
+
// else however its path sorts; git's own order is not the tree's either.
|
|
1056
|
+
//
|
|
1057
|
+
// So the tree's order is the one order, settled here and used by both. It is
|
|
1058
|
+
// expressed as a comparator rather than derived from the built tree because
|
|
1059
|
+
// the panes are built long before the tree is, and a second traversal that
|
|
1060
|
+
// had to agree with the first is the thing that went wrong.
|
|
1061
|
+
function treeOrder(a, b) {
|
|
1062
|
+
var A = a.split('/')
|
|
1063
|
+
var B = b.split('/')
|
|
1064
|
+
var n = Math.min(A.length, B.length)
|
|
1065
|
+
for (var i = 0; i < n; i++) {
|
|
1066
|
+
// A segment with anything after it is a directory. `renderTree` walks
|
|
1067
|
+
// every directory at a level — label and subtree — before that level's
|
|
1068
|
+
// files, so a directory sorts ahead of a file it ties with by name.
|
|
1069
|
+
var aDir = i < A.length - 1
|
|
1070
|
+
var bDir = i < B.length - 1
|
|
1071
|
+
if (aDir !== bDir) return aDir ? -1 : 1
|
|
1072
|
+
if (A[i] !== B[i]) return A[i].localeCompare(B[i])
|
|
1073
|
+
}
|
|
1074
|
+
return 0
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// A copy: `data` is the emitted review, and the page is a view of it.
|
|
1078
|
+
var files = data.files.slice().sort(function (a, b) { return treeOrder(a.path, b.path) })
|
|
1079
|
+
|
|
1080
|
+
// --- per-file page state -------------------------------------------------
|
|
1081
|
+
// Band state lives HERE, never in the emitted data: the page is a view of an
|
|
1082
|
+
// immutable review, and expanding context is a property of this reading of it.
|
|
1083
|
+
var state = files.map(function (f) {
|
|
1084
|
+
var rows = parsePatch(f.patch)
|
|
1085
|
+
var st = {
|
|
1086
|
+
file: f,
|
|
1087
|
+
rows: rows,
|
|
1088
|
+
bands: initialBands(rows),
|
|
1089
|
+
rendered: false,
|
|
1090
|
+
el: null,
|
|
1091
|
+
// What the page holds about THIS reading: whether the accept box is
|
|
1092
|
+
// ticked now, and any notes written but not yet copied out.
|
|
1093
|
+
accept: f.accepted === true,
|
|
1094
|
+
drafts: [],
|
|
1095
|
+
}
|
|
1096
|
+
revealCommented(st)
|
|
1097
|
+
return st
|
|
1098
|
+
})
|
|
1099
|
+
|
|
1100
|
+
// --- review marks --------------------------------------------------------
|
|
1101
|
+
// A comment is useless if the line it points at is hidden, so any band that
|
|
1102
|
+
// swallows a commented line is dropped before the first render. Dropping the
|
|
1103
|
+
// whole band shows a little more context than strictly needed, which is the
|
|
1104
|
+
// harmless direction.
|
|
1105
|
+
function revealCommented(st) {
|
|
1106
|
+
var lines = (st.file.comments || [])
|
|
1107
|
+
.map(function (c) { return c.line })
|
|
1108
|
+
.filter(function (n) { return n != null })
|
|
1109
|
+
if (!lines.length) return
|
|
1110
|
+
var idx = []
|
|
1111
|
+
st.rows.forEach(function (row, i) {
|
|
1112
|
+
if (row.n != null && lines.indexOf(row.n) !== -1) idx.push(i)
|
|
1113
|
+
})
|
|
1114
|
+
st.bands = st.bands.filter(function (b) {
|
|
1115
|
+
return !idx.some(function (i) { return i >= b.from && i < b.to })
|
|
1116
|
+
})
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Ids are minted from the render timestamp plus a counter, so they are stable
|
|
1120
|
+
// for this page and unique across renders. That is what makes re-pasting the
|
|
1121
|
+
// same blob merge instead of doubling every note.
|
|
1122
|
+
var seq = 0
|
|
1123
|
+
function mintId() {
|
|
1124
|
+
seq++
|
|
1125
|
+
return data.generatedAt + '-' + seq
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Best effort, both ways. Safari refuses storage on file:// by THROWING, and
|
|
1129
|
+
// a review page that fails to render because it could not autosave would be a
|
|
1130
|
+
// far worse bug than a lost pass.
|
|
1131
|
+
// What this reader concluded, once it was actually HANDED OVER — never on a
|
|
1132
|
+
// clipboard copy, which is a pass still sitting in the reader's hands.
|
|
1133
|
+
var decided = null
|
|
1134
|
+
var STORE_KEY = 'skitterspec-review:' + data.spec + ':' + data.generatedAt
|
|
1135
|
+
function save() {
|
|
1136
|
+
try {
|
|
1137
|
+
var accepts = {}
|
|
1138
|
+
var drafts = []
|
|
1139
|
+
state.forEach(function (st) {
|
|
1140
|
+
accepts[st.file.path] = st.accept
|
|
1141
|
+
st.drafts.forEach(function (d) { drafts.push(d) })
|
|
1142
|
+
})
|
|
1143
|
+
window.localStorage.setItem(
|
|
1144
|
+
STORE_KEY,
|
|
1145
|
+
JSON.stringify({ seq: seq, accepts: accepts, drafts: drafts, replies: replies, decided: decided }),
|
|
1146
|
+
)
|
|
1147
|
+
} catch (e) { /* no storage, or a quota — the pass still works, it just is not saved */ }
|
|
1148
|
+
}
|
|
1149
|
+
function restore() {
|
|
1150
|
+
var raw = null
|
|
1151
|
+
try { raw = window.localStorage.getItem(STORE_KEY) } catch (e) { return }
|
|
1152
|
+
if (!raw) return
|
|
1153
|
+
var saved
|
|
1154
|
+
try { saved = JSON.parse(raw) } catch (e) { return }
|
|
1155
|
+
seq = saved.seq || 0
|
|
1156
|
+
replies = saved.replies || {}
|
|
1157
|
+
// THE DECISION OUTLIVES THE TAB. Keyed to this render like every other
|
|
1158
|
+
// mark, so the next render — the one after the commit — is a live page
|
|
1159
|
+
// again rather than a page that thinks it is finished.
|
|
1160
|
+
if (saved.decided && saved.decided.verdict) decided = saved.decided
|
|
1161
|
+
var byPath = {}
|
|
1162
|
+
state.forEach(function (st) { byPath[st.file.path] = st })
|
|
1163
|
+
Object.keys(saved.accepts || {}).forEach(function (path) {
|
|
1164
|
+
if (byPath[path]) byPath[path].accept = Boolean(saved.accepts[path])
|
|
1165
|
+
})
|
|
1166
|
+
;(saved.drafts || []).forEach(function (d) {
|
|
1167
|
+
if (byPath[d.file]) byPath[d.file].drafts.push(d)
|
|
1168
|
+
else orphanDrafts.push(d)
|
|
1169
|
+
})
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Drafts whose file left the diff between saving and reloading, and replies to
|
|
1173
|
+
// the written review keyed by check id. Both still belong in the blob.
|
|
1174
|
+
var orphanDrafts = []
|
|
1175
|
+
var replies = {}
|
|
1176
|
+
|
|
1177
|
+
function el(tag, cls, text) {
|
|
1178
|
+
var n = document.createElement(tag)
|
|
1179
|
+
if (cls) n.className = cls
|
|
1180
|
+
if (text != null) n.textContent = text
|
|
1181
|
+
return n
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function gutter(row, st, index) {
|
|
1185
|
+
var td = el('td', 'gutter')
|
|
1186
|
+
var o = el('span', 'n', row.o == null ? '' : String(row.o))
|
|
1187
|
+
var n = el('span', 'n', row.n == null ? '' : String(row.n))
|
|
1188
|
+
var s = el('span', 's', row.t === 'add' ? '+' : row.t === 'del' ? '-' : ' ')
|
|
1189
|
+
td.appendChild(o); td.appendChild(n); td.appendChild(s)
|
|
1190
|
+
if (st) {
|
|
1191
|
+
td.className = 'gutter can-note'
|
|
1192
|
+
td.title = 'Comment on this line'
|
|
1193
|
+
td.addEventListener('click', function () { openDraft(st, index, row) })
|
|
1194
|
+
}
|
|
1195
|
+
return td
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function bandRow(st, band, idx) {
|
|
1199
|
+
var tr = el('tr', 'band')
|
|
1200
|
+
tr.appendChild(el('td', 'gutter', '⋯'))
|
|
1201
|
+
var td = el('td', 'src')
|
|
1202
|
+
var hidden = band.to - band.from
|
|
1203
|
+
td.appendChild(el('span', 'n-hidden', hidden + ' unchanged line' + (hidden === 1 ? '' : 's')))
|
|
1204
|
+
var step = el('button', null, '±' + STEP)
|
|
1205
|
+
step.type = 'button'
|
|
1206
|
+
step.setAttribute('aria-label', 'Expand ' + STEP + ' lines either side')
|
|
1207
|
+
step.addEventListener('click', function () {
|
|
1208
|
+
band.from += STEP
|
|
1209
|
+
band.to -= STEP
|
|
1210
|
+
if (band.to - band.from < 3) st.bands.splice(st.bands.indexOf(band), 1)
|
|
1211
|
+
renderFile(st)
|
|
1212
|
+
})
|
|
1213
|
+
var all = el('button', null, 'Show all')
|
|
1214
|
+
all.type = 'button'
|
|
1215
|
+
all.addEventListener('click', function () {
|
|
1216
|
+
st.bands.splice(st.bands.indexOf(band), 1)
|
|
1217
|
+
renderFile(st)
|
|
1218
|
+
})
|
|
1219
|
+
td.appendChild(step)
|
|
1220
|
+
td.appendChild(all)
|
|
1221
|
+
tr.appendChild(td)
|
|
1222
|
+
return tr
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Re-render the whole file on every expand. The alternative — pre-rendering
|
|
1226
|
+
// thousands of hidden rows and unhiding them — is what makes a whole-file
|
|
1227
|
+
// diff page take seconds to open.
|
|
1228
|
+
function renderFile(st) {
|
|
1229
|
+
var host = st.el
|
|
1230
|
+
if (!host) return
|
|
1231
|
+
host.textContent = ''
|
|
1232
|
+
if (!st.rows.length) {
|
|
1233
|
+
host.appendChild(el('p', 'note', st.file.binary ? 'Binary file — not shown.' : 'No textual change.'))
|
|
1234
|
+
return
|
|
1235
|
+
}
|
|
1236
|
+
var box = el('div', 'code')
|
|
1237
|
+
var table = el('table', 'diff')
|
|
1238
|
+
var body = document.createElement('tbody')
|
|
1239
|
+
var bands = st.bands.slice().sort(function (a, b) { return a.from - b.from })
|
|
1240
|
+
var b = 0
|
|
1241
|
+
var i = 0
|
|
1242
|
+
while (i < st.rows.length) {
|
|
1243
|
+
if (b < bands.length && i === bands[b].from) {
|
|
1244
|
+
body.appendChild(bandRow(st, bands[b], b))
|
|
1245
|
+
i = bands[b].to
|
|
1246
|
+
b++
|
|
1247
|
+
continue
|
|
1248
|
+
}
|
|
1249
|
+
var row = st.rows[i]
|
|
1250
|
+
var tr = el('tr', row.t)
|
|
1251
|
+
if (row.t === 'hunk') {
|
|
1252
|
+
tr.className = 'band'
|
|
1253
|
+
tr.appendChild(el('td', 'gutter', '⋯'))
|
|
1254
|
+
tr.appendChild(el('td', 'src', row.text))
|
|
1255
|
+
} else {
|
|
1256
|
+
tr.appendChild(gutter(row, st, i))
|
|
1257
|
+
tr.appendChild(el('td', 'src', row.text === '' ? ' ' : row.text))
|
|
1258
|
+
// The line number, so a check can point at this row. Set here rather
|
|
1259
|
+
// than derived on demand because the file re-renders on every expand,
|
|
1260
|
+
// and a lookup built once would go stale the first time a band opened.
|
|
1261
|
+
if (row.n != null) tr.setAttribute('data-n', String(row.n))
|
|
1262
|
+
}
|
|
1263
|
+
body.appendChild(tr)
|
|
1264
|
+
// Anything said about this line sits directly beneath it — a note you
|
|
1265
|
+
// have to go and look for is a note nobody reads.
|
|
1266
|
+
var here = notesAt(st, row.n)
|
|
1267
|
+
if (here.length) tr.className += ' has-note'
|
|
1268
|
+
here.forEach(function (c) { body.appendChild(noteRow(st, c)) })
|
|
1269
|
+
if (st.draftAt === i) body.appendChild(draftRow(st, row))
|
|
1270
|
+
i++
|
|
1271
|
+
}
|
|
1272
|
+
table.appendChild(body)
|
|
1273
|
+
box.appendChild(table)
|
|
1274
|
+
host.appendChild(box)
|
|
1275
|
+
sizeNotes(box)
|
|
1276
|
+
watchPane(host)
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Publish this code box's visible width, for the note bodies inside it.
|
|
1281
|
+
*
|
|
1282
|
+
* A note has to wrap at the width of what the reader can SEE, and the cell it
|
|
1283
|
+
* lives in is a diff-table column sized by the longest line of source in the
|
|
1284
|
+
* file. So the pane's own width is measured here and handed to the CSS.
|
|
1285
|
+
*
|
|
1286
|
+
* MEASURED, NEVER SUMMED. The gutter's width is `--ln-w` plus the +/- column
|
|
1287
|
+
* plus padding plus a border, in whatever font the reader has — and `td.gutter`
|
|
1288
|
+
* is one sticky cell rather than three precisely so that nobody has to get
|
|
1289
|
+
* that arithmetic right. Asking the element is the same decision one layer up.
|
|
1290
|
+
*
|
|
1291
|
+
* A BOX MEASURED AT NOTHING PUBLISHES NOTHING. A file whose <details> has
|
|
1292
|
+
* never been opened reports 0 for everything, and 0 is not a narrow pane —
|
|
1293
|
+
* it is a box that has not been laid out. Writing it would give every note in
|
|
1294
|
+
* that file a zero-width editor on the one render that matters, so the
|
|
1295
|
+
* cannot-tell case leaves whatever was there alone and the CSS falls back to
|
|
1296
|
+
* its own defaults.
|
|
1297
|
+
*/
|
|
1298
|
+
function sizeNotes(box) {
|
|
1299
|
+
if (!box || !box.style) return
|
|
1300
|
+
var g = box.querySelector('td.gutter')
|
|
1301
|
+
var gw = (g && g.offsetWidth) || 0
|
|
1302
|
+
var pane = box.clientWidth || 0
|
|
1303
|
+
if (!pane || !gw) return
|
|
1304
|
+
box.style.setProperty('--gutter-w', gw + 'px')
|
|
1305
|
+
box.style.setProperty('--pane-w', Math.max(0, pane - gw) + 'px')
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Re-measure when the layout moves under it — a rotated phone, a window
|
|
1309
|
+
// dragged narrower, the file tree folding away. The HOST is watched rather
|
|
1310
|
+
// than the box: a box is rebuilt on every band expansion, and an observer
|
|
1311
|
+
// holding the old ones would keep a whole file's worth of detached rows alive
|
|
1312
|
+
// for the life of the page. Observing the same host twice replaces the
|
|
1313
|
+
// observation, so re-renders cost nothing.
|
|
1314
|
+
//
|
|
1315
|
+
// A browser without ResizeObserver keeps the width it opened at, which is
|
|
1316
|
+
// wrong only for someone who then resizes. Nothing accuses, nothing breaks.
|
|
1317
|
+
var paneWatch = typeof ResizeObserver === 'function'
|
|
1318
|
+
? new ResizeObserver(function (entries) {
|
|
1319
|
+
entries.forEach(function (e) { sizeNotes(e.target.querySelector('.code')) })
|
|
1320
|
+
})
|
|
1321
|
+
: null
|
|
1322
|
+
|
|
1323
|
+
function watchPane(host) {
|
|
1324
|
+
if (paneWatch && host) paneWatch.observe(host)
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Every note about one line: what the engine stored, plus anything written on
|
|
1328
|
+
// this page and not yet copied out.
|
|
1329
|
+
function notesAt(st, line) {
|
|
1330
|
+
if (line == null) return []
|
|
1331
|
+
var out = []
|
|
1332
|
+
;(st.file.comments || []).forEach(function (c) { if (c.line === line) out.push(c) })
|
|
1333
|
+
st.drafts.forEach(function (d) { if (d.line === line) out.push(d) })
|
|
1334
|
+
return out
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function noteRow(st, c) {
|
|
1338
|
+
var tr = el('tr', 'note-row' + (c.resolved ? ' is-resolved' : ''))
|
|
1339
|
+
tr.appendChild(el('td', 'gutter', '💬'))
|
|
1340
|
+
var td = el('td', 'src')
|
|
1341
|
+
var body = el('div', 'note-body')
|
|
1342
|
+
var meta = c.line == null ? 'on this file' : 'line ' + c.line
|
|
1343
|
+
if (c.check) meta += ' · answering the review'
|
|
1344
|
+
if (!c.raisedAt) meta += ' · not sent yet'
|
|
1345
|
+
body.appendChild(el('span', 'note-meta', meta))
|
|
1346
|
+
body.appendChild(el('span', 'note-text', c.note))
|
|
1347
|
+
if (c.resolved) {
|
|
1348
|
+
body.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
|
|
1349
|
+
}
|
|
1350
|
+
if (!c.raisedAt) {
|
|
1351
|
+
var actions = el('div', 'note-actions')
|
|
1352
|
+
var del = el('button', null, 'Remove')
|
|
1353
|
+
del.type = 'button'
|
|
1354
|
+
del.addEventListener('click', function () {
|
|
1355
|
+
st.drafts.splice(st.drafts.indexOf(c), 1)
|
|
1356
|
+
renderFile(st)
|
|
1357
|
+
refresh()
|
|
1358
|
+
})
|
|
1359
|
+
actions.appendChild(del)
|
|
1360
|
+
body.appendChild(actions)
|
|
1361
|
+
}
|
|
1362
|
+
td.appendChild(body)
|
|
1363
|
+
tr.appendChild(td)
|
|
1364
|
+
return tr
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// The draft box. `row` is null for a note about the file as a whole.
|
|
1368
|
+
function draftRow(st, row) {
|
|
1369
|
+
var tr = el('tr', 'note-row')
|
|
1370
|
+
tr.appendChild(el('td', 'gutter', '💬'))
|
|
1371
|
+
var td = el('td', 'src')
|
|
1372
|
+
var body = el('div', 'note-body')
|
|
1373
|
+
body.appendChild(el('span', 'note-meta', row ? 'line ' + (row.n == null ? '—' : row.n) : 'on this file'))
|
|
1374
|
+
body.appendChild(noteEditor(function (text) {
|
|
1375
|
+
addDraft(st, text, row ? row.n : null, row ? row.text : null, null)
|
|
1376
|
+
st.draftAt = null
|
|
1377
|
+
renderFile(st)
|
|
1378
|
+
}, function () {
|
|
1379
|
+
st.draftAt = null
|
|
1380
|
+
renderFile(st)
|
|
1381
|
+
}))
|
|
1382
|
+
td.appendChild(body)
|
|
1383
|
+
tr.appendChild(td)
|
|
1384
|
+
return tr
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// One editor, used by the gutter, the file strip and the review replies.
|
|
1388
|
+
function noteEditor(onSave, onCancel) {
|
|
1389
|
+
var wrap = el('div')
|
|
1390
|
+
var input = el('textarea', 'note-input')
|
|
1391
|
+
input.rows = 2
|
|
1392
|
+
input.placeholder = 'What should change here?'
|
|
1393
|
+
var actions = el('div', 'note-actions')
|
|
1394
|
+
var ok = el('button', null, 'Add note')
|
|
1395
|
+
ok.type = 'button'
|
|
1396
|
+
ok.addEventListener('click', function () {
|
|
1397
|
+
var text = input.value.trim()
|
|
1398
|
+
// An empty note would be refused by the engine, so it is refused here —
|
|
1399
|
+
// where the person who wrote it is still looking at it.
|
|
1400
|
+
if (!text) { input.focus(); return }
|
|
1401
|
+
onSave(text)
|
|
1402
|
+
})
|
|
1403
|
+
actions.appendChild(ok)
|
|
1404
|
+
if (onCancel) {
|
|
1405
|
+
var no = el('button', null, 'Cancel')
|
|
1406
|
+
no.type = 'button'
|
|
1407
|
+
no.addEventListener('click', onCancel)
|
|
1408
|
+
actions.appendChild(no)
|
|
1409
|
+
}
|
|
1410
|
+
wrap.appendChild(input)
|
|
1411
|
+
wrap.appendChild(actions)
|
|
1412
|
+
window.setTimeout(function () { input.focus() }, 0)
|
|
1413
|
+
return wrap
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function addDraft(st, note, line, lineText, check) {
|
|
1417
|
+
st.drafts.push({
|
|
1418
|
+
id: mintId(),
|
|
1419
|
+
file: st.file.path,
|
|
1420
|
+
line: line == null ? null : line,
|
|
1421
|
+
lineText: lineText == null ? null : lineText,
|
|
1422
|
+
check: check || null,
|
|
1423
|
+
note: note,
|
|
1424
|
+
})
|
|
1425
|
+
refresh()
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function openDraft(st, index, row) {
|
|
1429
|
+
st.draftAt = st.draftAt === index ? null : index
|
|
1430
|
+
renderFile(st)
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Notes about the file rather than a line, and the way in to writing one.
|
|
1434
|
+
function fileStrip(st) {
|
|
1435
|
+
var strip = el('div', 'strip')
|
|
1436
|
+
var loose = (st.file.comments || []).concat(st.drafts).filter(function (c) { return c.line == null })
|
|
1437
|
+
loose.forEach(function (c) {
|
|
1438
|
+
var row = el('div', c.resolved ? 'is-resolved' : null)
|
|
1439
|
+
row.appendChild(el('span', 'note-meta', (c.check ? 'answering the review' : 'on this file') + (c.raisedAt ? '' : ' · not sent yet')))
|
|
1440
|
+
row.appendChild(el('span', 'note-text', c.note))
|
|
1441
|
+
if (c.resolved) row.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
|
|
1442
|
+
strip.appendChild(row)
|
|
1443
|
+
})
|
|
1444
|
+
var add = el('button', null, 'Note on this file')
|
|
1445
|
+
add.type = 'button'
|
|
1446
|
+
add.addEventListener('click', function () {
|
|
1447
|
+
add.hidden = true
|
|
1448
|
+
strip.appendChild(noteEditor(function (text) {
|
|
1449
|
+
addDraft(st, text, null, null, null)
|
|
1450
|
+
redrawStrip(st)
|
|
1451
|
+
}, function () { redrawStrip(st) }))
|
|
1452
|
+
})
|
|
1453
|
+
strip.appendChild(add)
|
|
1454
|
+
return strip
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function redrawStrip(st) {
|
|
1458
|
+
if (!st.stripEl) return
|
|
1459
|
+
var next = fileStrip(st)
|
|
1460
|
+
st.stripEl.parentNode.replaceChild(next, st.stripEl)
|
|
1461
|
+
st.stripEl = next
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// --- file list -----------------------------------------------------------
|
|
1465
|
+
var filesHost = document.getElementById('files')
|
|
1466
|
+
|
|
1467
|
+
state.forEach(function (st) {
|
|
1468
|
+
var f = st.file
|
|
1469
|
+
var d = el('details', 'file')
|
|
1470
|
+
d.id = 'f-' + encodeURIComponent(f.path)
|
|
1471
|
+
// Read back by the current-file observer, which is handed elements rather
|
|
1472
|
+
// than files. The id would do — it is the same path, encoded — but decoding
|
|
1473
|
+
// an id to recover data the element could simply carry is a round trip
|
|
1474
|
+
// through a format that exists for a different reason.
|
|
1475
|
+
d.setAttribute('data-path', f.path)
|
|
1476
|
+
var s = el('summary')
|
|
1477
|
+
s.appendChild(el('span', 'path', f.path))
|
|
1478
|
+
s.appendChild(el('span', 'chip', f.status))
|
|
1479
|
+
if (!f.whole) s.appendChild(el('span', 'chip', 'partial context'))
|
|
1480
|
+
if (f.noise) s.appendChild(el('span', 'chip', 'bookkeeping'))
|
|
1481
|
+
// A lapse is SAID. An accept that quietly vanished because the file moved on
|
|
1482
|
+
// would leave you believing you had read code you have not.
|
|
1483
|
+
if (f.accepted === 'lapsed') {
|
|
1484
|
+
s.appendChild(el('span', 'chip lapsed', 'accepted earlier — changed since'))
|
|
1485
|
+
}
|
|
1486
|
+
var openCount = (f.comments || []).filter(function (x) { return !x.resolved }).length
|
|
1487
|
+
if (openCount) s.appendChild(el('span', 'chip commented', openCount + ' note' + (openCount === 1 ? '' : 's')))
|
|
1488
|
+
|
|
1489
|
+
var accept = el('button', 'accept', '✓ accept')
|
|
1490
|
+
accept.type = 'button'
|
|
1491
|
+
accept.setAttribute('aria-pressed', String(st.accept))
|
|
1492
|
+
// A summary is a toggle for the <details>; a click on a control inside it
|
|
1493
|
+
// must not also open and close the file.
|
|
1494
|
+
accept.addEventListener('click', function (ev) {
|
|
1495
|
+
ev.preventDefault()
|
|
1496
|
+
ev.stopPropagation()
|
|
1497
|
+
st.accept = !st.accept
|
|
1498
|
+
accept.setAttribute('aria-pressed', String(st.accept))
|
|
1499
|
+
refresh()
|
|
1500
|
+
})
|
|
1501
|
+
s.appendChild(accept)
|
|
1502
|
+
|
|
1503
|
+
var c = el('span', 'counts')
|
|
1504
|
+
c.appendChild(el('span', 'a', '+' + f.additions))
|
|
1505
|
+
c.appendChild(document.createTextNode(' '))
|
|
1506
|
+
c.appendChild(el('span', 'd', '-' + f.deletions))
|
|
1507
|
+
s.appendChild(c)
|
|
1508
|
+
d.appendChild(s)
|
|
1509
|
+
st.stripEl = fileStrip(st)
|
|
1510
|
+
d.appendChild(st.stripEl)
|
|
1511
|
+
var host = el('div')
|
|
1512
|
+
d.appendChild(host)
|
|
1513
|
+
st.el = host
|
|
1514
|
+
|
|
1515
|
+
// Render on first open, so a review of 200 files costs one file's work.
|
|
1516
|
+
d.addEventListener('toggle', function () {
|
|
1517
|
+
if (d.open && !st.rendered) { st.rendered = true; renderFile(st) }
|
|
1518
|
+
})
|
|
1519
|
+
if (!f.noise) { d.open = true; st.rendered = true; renderFile(st) }
|
|
1520
|
+
filesHost.appendChild(d)
|
|
1521
|
+
st.details = d
|
|
1522
|
+
})
|
|
1523
|
+
|
|
1524
|
+
if (!state.length) {
|
|
1525
|
+
filesHost.appendChild(el('p', 'empty', 'Nothing to review — no changes found.'))
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// --- noise filter --------------------------------------------------------
|
|
1529
|
+
var noiseBox = document.getElementById('show-noise')
|
|
1530
|
+
var noiseCount = state.filter(function (s) { return s.file.noise }).length
|
|
1531
|
+
document.getElementById('noise-label').textContent = 'Show bookkeeping (' + noiseCount + ')'
|
|
1532
|
+
if (!noiseCount) noiseBox.closest('.toggle').hidden = true
|
|
1533
|
+
|
|
1534
|
+
function applyNoise() {
|
|
1535
|
+
state.forEach(function (st) {
|
|
1536
|
+
if (st.file.noise) st.details.hidden = !noiseBox.checked
|
|
1537
|
+
})
|
|
1538
|
+
}
|
|
1539
|
+
noiseBox.addEventListener('change', applyNoise)
|
|
1540
|
+
applyNoise()
|
|
1541
|
+
|
|
1542
|
+
// --- file tree -----------------------------------------------------------
|
|
1543
|
+
// Built from the paths, and it lists EVERY file including the ones the noise
|
|
1544
|
+
// filter is hiding — a tree with an unreachable entry is worse than no tree.
|
|
1545
|
+
function buildTree(files) {
|
|
1546
|
+
var root = { dirs: {}, files: [] }
|
|
1547
|
+
files.forEach(function (f) {
|
|
1548
|
+
var parts = f.path.split('/')
|
|
1549
|
+
var node = root
|
|
1550
|
+
for (var i = 0; i < parts.length - 1; i++) {
|
|
1551
|
+
if (!node.dirs[parts[i]]) node.dirs[parts[i]] = { dirs: {}, files: [] }
|
|
1552
|
+
node = node.dirs[parts[i]]
|
|
1553
|
+
}
|
|
1554
|
+
node.files.push({ name: parts[parts.length - 1], file: f })
|
|
1555
|
+
})
|
|
1556
|
+
return root
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// --- which file am I reading? --------------------------------------------
|
|
1560
|
+
// The tree row for each path, captured as the tree is built. A lookup rather
|
|
1561
|
+
// than a query, because this is answered on every scroll: a `querySelector`
|
|
1562
|
+
// per event would walk the whole sidebar to find something the build already
|
|
1563
|
+
// had in its hand.
|
|
1564
|
+
var rowByPath = {}
|
|
1565
|
+
var currentPath = null
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Mark one file as the one being read.
|
|
1569
|
+
*
|
|
1570
|
+
* ONE ROW AT A TIME, and the previous one is cleared through the same map
|
|
1571
|
+
* rather than by querying for `.is-current`. A map cannot report a row the
|
|
1572
|
+
* tree no longer has, and it cannot miss one a selector spelled slightly
|
|
1573
|
+
* differently.
|
|
1574
|
+
*/
|
|
1575
|
+
function setCurrent(path) {
|
|
1576
|
+
if (path === currentPath) return
|
|
1577
|
+
var prev = rowByPath[currentPath]
|
|
1578
|
+
if (prev) prev.className = prev.className.replace(/ ?is-current/, '')
|
|
1579
|
+
currentPath = path
|
|
1580
|
+
var row = rowByPath[path]
|
|
1581
|
+
if (!row) return
|
|
1582
|
+
row.className += ' is-current'
|
|
1583
|
+
followCurrent(row)
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Drop the highlight entirely.
|
|
1588
|
+
*
|
|
1589
|
+
* For the decided page, where the diff is hidden and no card can qualify —
|
|
1590
|
+
* leaving a row lit there would point at something the reader can no longer
|
|
1591
|
+
* see.
|
|
1592
|
+
*/
|
|
1593
|
+
function clearCurrent() {
|
|
1594
|
+
var prev = rowByPath[currentPath]
|
|
1595
|
+
if (prev) prev.className = prev.className.replace(/ ?is-current/, '')
|
|
1596
|
+
currentPath = null
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Scroll the tree ONLY when the marked row has left its own scrollport.
|
|
1601
|
+
*
|
|
1602
|
+
* The sidebar is a bounded box (`max-height: calc(100vh - 2rem)`), so on a
|
|
1603
|
+
* long diff the highlight is invisible unless the row can be brought in —
|
|
1604
|
+
* and on a short one nothing should ever move. `block: 'nearest'` keeps the
|
|
1605
|
+
* nudge a nudge: re-centring would put the sidebar in constant motion beside
|
|
1606
|
+
* a reader who is already moving, which is the thing a fixed map is for.
|
|
1607
|
+
*
|
|
1608
|
+
* A browser that withholds `getBoundingClientRect` gets the highlight and no
|
|
1609
|
+
* follow. Guessing a position from nothing is how a sidebar ends up scrolled
|
|
1610
|
+
* somewhere arbitrary.
|
|
1611
|
+
*/
|
|
1612
|
+
function followCurrent(row) {
|
|
1613
|
+
var box = document.getElementById('tree-wrap')
|
|
1614
|
+
if (!box || !row.getBoundingClientRect || !box.getBoundingClientRect) return
|
|
1615
|
+
var r = row.getBoundingClientRect()
|
|
1616
|
+
var b = box.getBoundingClientRect()
|
|
1617
|
+
if (r.top >= b.top && r.bottom <= b.bottom) return
|
|
1618
|
+
if (row.scrollIntoView) row.scrollIntoView({ block: 'nearest' })
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// What is currently crossing the band, keyed by path. A SET rather than a
|
|
1622
|
+
// running answer: entries arrive for what CHANGED, so the observer never
|
|
1623
|
+
// hands over the whole picture and the page has to keep it.
|
|
1624
|
+
var onScreen = {}
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* The topmost file on screen, in document order.
|
|
1628
|
+
*
|
|
1629
|
+
* NOTHING ON SCREEN IS NOT AN ANSWER, and this returning without calling
|
|
1630
|
+
* `setCurrent` is the whole of that. Scrolled above the first card, past the
|
|
1631
|
+
* last, or with the current file hidden by the noise filter, the previous
|
|
1632
|
+
* row stays lit — clearing it would flicker the highlight off every time a
|
|
1633
|
+
* card boundary crossed the band, and a stale "you were here" is worth more
|
|
1634
|
+
* than nothing at all.
|
|
1635
|
+
*/
|
|
1636
|
+
function pickCurrent() {
|
|
1637
|
+
for (var i = 0; i < state.length; i++) {
|
|
1638
|
+
if (onScreen[state[i].file.path]) return setCurrent(state[i].file.path)
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* Watch the cards against a band at the top of the viewport.
|
|
1644
|
+
*
|
|
1645
|
+
* THE BOTTOM MARGIN IS THE WHOLE TRICK. Pulled up by three quarters of the
|
|
1646
|
+
* viewport, only the top slice counts as "on screen" — so the answer changes
|
|
1647
|
+
* as you scroll. Without it every card on a tall screen intersects at once
|
|
1648
|
+
* and the topmost is whatever is furthest up the document, which is the same
|
|
1649
|
+
* file from the first scroll to the last.
|
|
1650
|
+
*
|
|
1651
|
+
* A browser without IntersectionObserver keeps a page with no highlight and
|
|
1652
|
+
* no error — the shape `paneWatch` takes for ResizeObserver above. Nothing
|
|
1653
|
+
* accuses and nothing breaks; the tree is still a tree.
|
|
1654
|
+
*/
|
|
1655
|
+
var currentWatch = typeof IntersectionObserver === 'function'
|
|
1656
|
+
? new IntersectionObserver(function (entries) {
|
|
1657
|
+
entries.forEach(function (e) {
|
|
1658
|
+
var p = e.target.getAttribute('data-path')
|
|
1659
|
+
if (!p) return
|
|
1660
|
+
if (e.isIntersecting) onScreen[p] = true
|
|
1661
|
+
else delete onScreen[p]
|
|
1662
|
+
})
|
|
1663
|
+
pickCurrent()
|
|
1664
|
+
}, { rootMargin: '0px 0px -75% 0px' })
|
|
1665
|
+
: null
|
|
1666
|
+
|
|
1667
|
+
function reveal(f) {
|
|
1668
|
+
if (f.noise && !noiseBox.checked) { noiseBox.checked = true; applyNoise() }
|
|
1669
|
+
var d = document.getElementById('f-' + encodeURIComponent(f.path))
|
|
1670
|
+
if (!d) return
|
|
1671
|
+
d.open = true
|
|
1672
|
+
d.scrollIntoView({ block: 'start' })
|
|
1673
|
+
// Mark it NOW rather than waiting for the scroll to settle. A click on the
|
|
1674
|
+
// tree is a reader saying which file they mean, and the observer would
|
|
1675
|
+
// agree a moment later — but the moment is visible, and a row that lights
|
|
1676
|
+
// up after you have already looked away reads as a lag rather than an
|
|
1677
|
+
// answer.
|
|
1678
|
+
setCurrent(f.path)
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* Jump to a file, and to one line inside it when a check named one.
|
|
1683
|
+
*
|
|
1684
|
+
* THREE STATES, AND ONLY ONE OF THEM IS A JUMP. A file that is not in this
|
|
1685
|
+
* diff, and a line that is not in that file's patch, both do nothing beyond
|
|
1686
|
+
* revealing what IS there — a reviewer's finding can name a line that has
|
|
1687
|
+
* since moved, and a page that scrolled somewhere arbitrary would be worse
|
|
1688
|
+
* than one that scrolled nowhere.
|
|
1689
|
+
*/
|
|
1690
|
+
function gotoLine(path, line) {
|
|
1691
|
+
var st = null
|
|
1692
|
+
state.forEach(function (s) { if (s.file.path === path) st = s })
|
|
1693
|
+
if (!st) return
|
|
1694
|
+
reveal(st.file)
|
|
1695
|
+
if (line == null) return
|
|
1696
|
+
// A collapsed band hides the row, so drop any band covering it first. The
|
|
1697
|
+
// file re-renders anyway; this just decides what is on screen when it does.
|
|
1698
|
+
var idx = -1
|
|
1699
|
+
for (var i = 0; i < st.rows.length; i++) {
|
|
1700
|
+
if (st.rows[i].n === line) { idx = i; break }
|
|
1701
|
+
}
|
|
1702
|
+
if (idx < 0) return
|
|
1703
|
+
st.bands = st.bands.filter(function (b) { return !(idx >= b.from && idx < b.to) })
|
|
1704
|
+
renderFile(st)
|
|
1705
|
+
if (!st.el) return
|
|
1706
|
+
// Attribute-only, no tag prefix: within one file's element nothing but a
|
|
1707
|
+
// code row carries `data-n`, and the narrower selector buys nothing.
|
|
1708
|
+
var tr = st.el.querySelector('[data-n="' + line + '"]')
|
|
1709
|
+
if (!tr) return
|
|
1710
|
+
var prev = document.querySelector('.goto-hit')
|
|
1711
|
+
if (prev) prev.className = prev.className.replace(/ ?goto-hit/, '')
|
|
1712
|
+
tr.className += ' goto-hit'
|
|
1713
|
+
tr.scrollIntoView({ block: 'center' })
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Wired per button rather than delegated from `document`. The review block is
|
|
1717
|
+
// spliced in as markup by the engine, so it is all present by the time this
|
|
1718
|
+
// runs — and a listener on each element is what the rest of this page does.
|
|
1719
|
+
function wireGotos() {
|
|
1720
|
+
var btns = document.querySelectorAll('[data-goto]')
|
|
1721
|
+
Array.prototype.forEach.call(btns, function (btn) {
|
|
1722
|
+
btn.addEventListener('click', function () {
|
|
1723
|
+
var n = btn.getAttribute('data-goto-line')
|
|
1724
|
+
gotoLine(btn.getAttribute('data-goto'), n == null ? null : parseInt(n, 10))
|
|
1725
|
+
})
|
|
1726
|
+
})
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// ONE FLAT LIST, indented by per-row padding rather than by nesting <ul>s.
|
|
1730
|
+
//
|
|
1731
|
+
// That is not a tidy-up: the change counts are `position: sticky; right: 0`,
|
|
1732
|
+
// and a sticky box can only pin within its own row. Nested <ul>s make each
|
|
1733
|
+
// level narrower than its parent, so a deep row ends before the scrollport's
|
|
1734
|
+
// right edge and its counts slide away the moment you scroll sideways. Flat
|
|
1735
|
+
// rows all share the widest row's width, so every one of them has something
|
|
1736
|
+
// to pin to.
|
|
1737
|
+
//
|
|
1738
|
+
// Depth goes on as an inline style because it is data, not a class: the tree
|
|
1739
|
+
// is however deep the diff happens to be, and a rule per level would be a
|
|
1740
|
+
// guess at the maximum.
|
|
1741
|
+
function indent(node, depth, base) {
|
|
1742
|
+
// Rounded, because 3 * 0.8 is 2.4000000000000004 in binary floating point
|
|
1743
|
+
// and that lands verbatim in the attribute. CSS copes; a person reading the
|
|
1744
|
+
// page source does not, and neither does a test asserting the value.
|
|
1745
|
+
var rem = Math.round((base + depth * 0.8) * 100) / 100
|
|
1746
|
+
node.setAttribute('style', 'padding-left:' + rem + 'rem')
|
|
1747
|
+
return node
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
function renderTree(node) {
|
|
1751
|
+
var ul = el('ul')
|
|
1752
|
+
;(function walk(n, depth) {
|
|
1753
|
+
// `treeOrder` above is the authority and these two sorts must match it,
|
|
1754
|
+
// which is why the directories sort by `localeCompare` rather than by the
|
|
1755
|
+
// default `.sort()`: that compares UTF-16 code units, so it puts every
|
|
1756
|
+
// capitalised name ahead of every lowercase one and the panes would drift
|
|
1757
|
+
// from the sidebar again on the first `README/` in a diff.
|
|
1758
|
+
//
|
|
1759
|
+
// Insertion order would be free — the list arrives sorted — but a key
|
|
1760
|
+
// that looks like an array index (`2024/`) is enumerated first whatever
|
|
1761
|
+
// order it was inserted in, and a dated directory is not exotic.
|
|
1762
|
+
Object.keys(n.dirs).sort(function (a, b) { return a.localeCompare(b) }).forEach(function (name) {
|
|
1763
|
+
var li = el('li')
|
|
1764
|
+
li.appendChild(indent(el('span', 'dir', name + '/'), depth, 0.6))
|
|
1765
|
+
ul.appendChild(li)
|
|
1766
|
+
// The subtree goes in AFTER its own label and before this level's
|
|
1767
|
+
// files, which is the order the nested form produced.
|
|
1768
|
+
walk(n.dirs[name], depth + 1)
|
|
1769
|
+
})
|
|
1770
|
+
n.files.sort(function (a, b) { return a.name.localeCompare(b.name) }).forEach(function (entry) {
|
|
1771
|
+
var li = el('li')
|
|
1772
|
+
var b = el('button', 'tree-file' + (entry.file.noise ? ' is-noise' : ''))
|
|
1773
|
+
b.type = 'button'
|
|
1774
|
+
// The same .6rem base as a dir label, so a file sits level with the
|
|
1775
|
+
// directory it is in. It is on the name rather than the row because the
|
|
1776
|
+
// row must reach the scrollport edge — see `.tree-file` padding.
|
|
1777
|
+
b.appendChild(indent(el('span', 'nm', entry.name), depth, 0.6))
|
|
1778
|
+
var ct = el('span', 'ct')
|
|
1779
|
+
ct.appendChild(el('span', 'a', '+' + entry.file.additions))
|
|
1780
|
+
ct.appendChild(document.createTextNode(' '))
|
|
1781
|
+
ct.appendChild(el('span', 'd', '-' + entry.file.deletions))
|
|
1782
|
+
b.appendChild(ct)
|
|
1783
|
+
b.addEventListener('click', function () { reveal(entry.file) })
|
|
1784
|
+
// The path, not the name: two directories can hold a `README.md`, and
|
|
1785
|
+
// the tree deliberately shows the leaf only.
|
|
1786
|
+
rowByPath[entry.file.path] = b
|
|
1787
|
+
li.appendChild(b)
|
|
1788
|
+
ul.appendChild(li)
|
|
1789
|
+
})
|
|
1790
|
+
})(node, 0)
|
|
1791
|
+
return ul
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
var tree = document.getElementById('tree')
|
|
1795
|
+
tree.appendChild(renderTree(buildTree(files)))
|
|
1796
|
+
document.getElementById('tree-summary').textContent =
|
|
1797
|
+
'Files (' + files.length + ')'
|
|
1798
|
+
if (window.matchMedia && !window.matchMedia('(min-width: 60rem)').matches) {
|
|
1799
|
+
document.getElementById('tree-wrap').open = false
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// AFTER the tree, because a mark with no row to put it on is nothing at all.
|
|
1803
|
+
//
|
|
1804
|
+
// It runs on a narrow screen too, where the tree is a collapsed <details> and
|
|
1805
|
+
// nothing is visible: the class is still correct, so a reader who opens the
|
|
1806
|
+
// sidebar half-way down a diff finds it already pointing at where they are,
|
|
1807
|
+
// and `scrollIntoView` inside a closed <details> is a no-op rather than a
|
|
1808
|
+
// special case to write.
|
|
1809
|
+
if (currentWatch) {
|
|
1810
|
+
state.forEach(function (st) { if (st.details) currentWatch.observe(st.details) })
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// --- page-level controls -------------------------------------------------
|
|
1814
|
+
document.getElementById('expand-all').addEventListener('click', function () {
|
|
1815
|
+
state.forEach(function (st) {
|
|
1816
|
+
st.bands = []
|
|
1817
|
+
if (st.rendered) renderFile(st)
|
|
1818
|
+
})
|
|
1819
|
+
})
|
|
1820
|
+
document.getElementById('collapse-all').addEventListener('click', function () {
|
|
1821
|
+
state.forEach(function (st) {
|
|
1822
|
+
st.bands = initialBands(st.rows)
|
|
1823
|
+
if (st.rendered) renderFile(st)
|
|
1824
|
+
})
|
|
1825
|
+
})
|
|
1826
|
+
|
|
1827
|
+
// --- answering the written review ---------------------------------------
|
|
1828
|
+
// A `confirm` exists to ask the author a question, and until now the only way
|
|
1829
|
+
// to answer was to retype it in chat. The engine stamps each check with an id
|
|
1830
|
+
// and the file it is about; a reply becomes an ordinary comment carrying that
|
|
1831
|
+
// id, so it travels in the same blob as everything else.
|
|
1832
|
+
function wireChecks() {
|
|
1833
|
+
var checks = document.querySelectorAll('.check[data-check]')
|
|
1834
|
+
Array.prototype.forEach.call(checks, function (li) {
|
|
1835
|
+
var id = li.getAttribute('data-check')
|
|
1836
|
+
// A check with no file is about the review rather than a line of code.
|
|
1837
|
+
// The sentinel keeps the blob valid and lands it under "not in this diff",
|
|
1838
|
+
// which is exactly where a review-wide remark belongs.
|
|
1839
|
+
var file = li.getAttribute('data-file') || '(review)'
|
|
1840
|
+
var given = el('div', 'reply-given')
|
|
1841
|
+
var box = el('div', 'reply')
|
|
1842
|
+
function draw() {
|
|
1843
|
+
given.textContent = replies[id] ? '↳ ' + replies[id] : ''
|
|
1844
|
+
given.hidden = !replies[id]
|
|
1845
|
+
box.textContent = ''
|
|
1846
|
+
var add = el('button', null, replies[id] ? 'Change answer' : 'Answer')
|
|
1847
|
+
add.type = 'button'
|
|
1848
|
+
add.addEventListener('click', function () {
|
|
1849
|
+
box.textContent = ''
|
|
1850
|
+
box.appendChild(noteEditor(function (text) {
|
|
1851
|
+
replies[id] = text
|
|
1852
|
+
draw()
|
|
1853
|
+
refresh()
|
|
1854
|
+
}, draw))
|
|
1855
|
+
})
|
|
1856
|
+
box.appendChild(add)
|
|
1857
|
+
}
|
|
1858
|
+
draw()
|
|
1859
|
+
li.appendChild(given)
|
|
1860
|
+
li.appendChild(box)
|
|
1861
|
+
li.setAttribute('data-reply-file', file)
|
|
1862
|
+
})
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// --- the blob -------------------------------------------------------------
|
|
1866
|
+
/**
|
|
1867
|
+
* A DELTA, not a snapshot. The engine merges rather than replaces, so this
|
|
1868
|
+
* says only what changed: files newly ticked, accepts withdrawn, notes
|
|
1869
|
+
* written. A file already accepted with this exact content has nothing to
|
|
1870
|
+
* say, and a file that was never accepted has nothing to withdraw.
|
|
1871
|
+
*
|
|
1872
|
+
* Withdrawal is explicit for the same reason — under a merge, absence means
|
|
1873
|
+
* "not mentioned", never "remove it".
|
|
1874
|
+
*/
|
|
1875
|
+
function buildBlob(verdict) {
|
|
1876
|
+
var accepted = []
|
|
1877
|
+
var unaccepted = []
|
|
1878
|
+
var comments = []
|
|
1879
|
+
state.forEach(function (st) {
|
|
1880
|
+
var stored = st.file.accepted
|
|
1881
|
+
if (st.accept) {
|
|
1882
|
+
// No hash means the engine could not read the file; accepting it would
|
|
1883
|
+
// record a vouch for content nobody can identify.
|
|
1884
|
+
if (stored !== true && st.file.hash) accepted.push({ path: st.file.path, hash: st.file.hash })
|
|
1885
|
+
} else if (stored === true || stored === 'lapsed') {
|
|
1886
|
+
unaccepted.push(st.file.path)
|
|
1887
|
+
}
|
|
1888
|
+
st.drafts.forEach(function (d) { comments.push(d) })
|
|
1889
|
+
})
|
|
1890
|
+
orphanDrafts.forEach(function (d) { comments.push(d) })
|
|
1891
|
+
Object.keys(replies).forEach(function (id) {
|
|
1892
|
+
var li = document.querySelector('.check[data-check="' + id + '"]')
|
|
1893
|
+
comments.push({
|
|
1894
|
+
id: data.generatedAt + '-reply-' + id,
|
|
1895
|
+
file: (li && li.getAttribute('data-reply-file')) || '(review)',
|
|
1896
|
+
line: null,
|
|
1897
|
+
lineText: null,
|
|
1898
|
+
check: id,
|
|
1899
|
+
note: replies[id],
|
|
1900
|
+
})
|
|
1901
|
+
})
|
|
1902
|
+
var blob = {
|
|
1903
|
+
version: 1,
|
|
1904
|
+
spec: data.spec,
|
|
1905
|
+
generatedAt: data.generatedAt,
|
|
1906
|
+
accepted: accepted,
|
|
1907
|
+
unaccepted: unaccepted,
|
|
1908
|
+
comments: comments,
|
|
1909
|
+
}
|
|
1910
|
+
// Absent unless a verdict was actually chosen. The engine reads an absent
|
|
1911
|
+
// one as `discuss`, and a blob that never claimed one should keep saying so.
|
|
1912
|
+
if (verdict) blob.verdict = verdict
|
|
1913
|
+
return blob
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
var verdictBtns = {
|
|
1917
|
+
commit: document.getElementById('verdict-commit'),
|
|
1918
|
+
'commit-continue': document.getElementById('verdict-commit-continue'),
|
|
1919
|
+
'commit-start': document.getElementById('verdict-commit-start'),
|
|
1920
|
+
'commit-land': document.getElementById('verdict-commit-land'),
|
|
1921
|
+
continue: document.getElementById('verdict-continue'),
|
|
1922
|
+
changes: document.getElementById('verdict-changes'),
|
|
1923
|
+
discuss: document.getElementById('verdict-discuss'),
|
|
1924
|
+
}
|
|
1925
|
+
// The committing pair, named once. A fifth verdict that commits is added
|
|
1926
|
+
// here, and the shared block below follows for free — the same reason the
|
|
1927
|
+
// engine keeps a `COMMITTING` list rather than a second condition.
|
|
1928
|
+
var COMMITTERS = ['commit', 'commit-continue', 'commit-start', 'commit-land']
|
|
1929
|
+
|
|
1930
|
+
/*
|
|
1931
|
+
* WHICH BUTTONS THIS PAGE OFFERS, and it comes from the render rather than
|
|
1932
|
+
* from the gate. Deriving it — mid-run iff nothing is armed — would take the
|
|
1933
|
+
* committing buttons away from every page in a project running
|
|
1934
|
+
* `review.required: false`, which never arms and whose readers must still be
|
|
1935
|
+
* able to commit from the page. The engine's `BUTTON_SETS` is the same
|
|
1936
|
+
* decision on the other side of the wire.
|
|
1937
|
+
*
|
|
1938
|
+
* ABSENT IS THE COMMITTING SET. A page rendered before any of this existed,
|
|
1939
|
+
* and a caller that did not ask, both land here — so nothing about their page
|
|
1940
|
+
* changes.
|
|
1941
|
+
*/
|
|
1942
|
+
var MIDRUN = data.buttons === 'midrun'
|
|
1943
|
+
// ONE TABLE, keyed by the set the engine named. A chain of ternaries is how a
|
|
1944
|
+
// third set becomes a fourth condition somebody forgets to add a button to.
|
|
1945
|
+
var OFFERS = {
|
|
1946
|
+
midrun: ['continue', 'changes', 'discuss'],
|
|
1947
|
+
authoring: ['commit-start', 'commit', 'changes', 'discuss'],
|
|
1948
|
+
refresh: ['commit', 'changes', 'discuss'],
|
|
1949
|
+
nospec: ['commit-land', 'commit', 'changes', 'discuss'],
|
|
1950
|
+
committing: ['commit', 'commit-continue', 'changes', 'discuss'],
|
|
1951
|
+
// A SINGLE-PASS FIX: the committing set minus the verb it cannot honour.
|
|
1952
|
+
// `/spec-bug` and `/spec-hotfix` render this when the repair took one pass,
|
|
1953
|
+
// because `Commit & Continue` names a next phase a spec with no phase files
|
|
1954
|
+
// does not have. Same three buttons as `refresh`, a different subject.
|
|
1955
|
+
fix: ['commit', 'changes', 'discuss'],
|
|
1956
|
+
}
|
|
1957
|
+
var OFFERED = OFFERS[data.buttons] || OFFERS.committing
|
|
1958
|
+
Object.keys(verdictBtns).forEach(function (key) {
|
|
1959
|
+
verdictBtns[key].hidden = OFFERED.indexOf(key) === -1
|
|
1960
|
+
})
|
|
1961
|
+
|
|
1962
|
+
// EVERY VERDICT, because this is the one place a verdict gets a reader-facing
|
|
1963
|
+
// name: `refresh` relabels only the committing pair, but the decided panel
|
|
1964
|
+
// has to name whichever verdict was actually sent — including one this page
|
|
1965
|
+
// never offered, since a decision restored from storage outlives the render
|
|
1966
|
+
// that made it.
|
|
1967
|
+
var LABELS = {
|
|
1968
|
+
commit: '✓ Commit',
|
|
1969
|
+
'commit-continue': '✓ Commit & Continue',
|
|
1970
|
+
'commit-start': '✓ Commit & Start',
|
|
1971
|
+
'commit-land': '✓ Commit & Land',
|
|
1972
|
+
continue: '→ Continue',
|
|
1973
|
+
changes: '↺ Request changes',
|
|
1974
|
+
discuss: '… Discuss first',
|
|
1975
|
+
}
|
|
1976
|
+
var TITLES = {
|
|
1977
|
+
commit: 'Commit what you just read, and stop there',
|
|
1978
|
+
// Says what it will NOT do: a reader must not press this expecting the
|
|
1979
|
+
// spec to be finished and landed.
|
|
1980
|
+
'commit-continue': 'Commit what you just read, then build the next phase — nothing is landed',
|
|
1981
|
+
// Says what it WILL do, because this is the one verdict that provisions:
|
|
1982
|
+
// a worktree appears and phase 1 starts.
|
|
1983
|
+
'commit-start': 'Commit this spec, then put it in flight and build phase 1',
|
|
1984
|
+
// Says what it WILL do, and every destructive part of it: this is the only
|
|
1985
|
+
// verdict that removes a worktree, so the reader must not learn that from
|
|
1986
|
+
// the outcome afterwards.
|
|
1987
|
+
'commit-land': 'Commit this, fast-forward the base branch to it, and remove the worktree',
|
|
1988
|
+
// Says what it will NOT do, for the same reason: nothing is committed by
|
|
1989
|
+
// this, so the work it resumes is still uncommitted afterwards.
|
|
1990
|
+
continue: 'You have read it — carry on with the run; nothing is committed',
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
var verdictEl = document.getElementById('verdict')
|
|
1994
|
+
var verdictCount = document.getElementById('verdict-count')
|
|
1995
|
+
var verdictLog = document.getElementById('verdict-log')
|
|
1996
|
+
var verdictWarn = document.getElementById('verdict-warn')
|
|
1997
|
+
var copyOut = document.getElementById('copy-out')
|
|
1998
|
+
var wrapEl = document.getElementById('wrap')
|
|
1999
|
+
var decidedBox = document.getElementById('decided')
|
|
2000
|
+
var decidedWhat = document.getElementById('decided-what')
|
|
2001
|
+
var decidedNote = document.getElementById('decided-note')
|
|
2002
|
+
var decidedToggle = document.getElementById('decided-toggle')
|
|
2003
|
+
var copyHint = document.getElementById('copy-hint')
|
|
2004
|
+
var sentCmd = document.getElementById('sent-cmd')
|
|
2005
|
+
var sentCmdText = document.getElementById('sent-cmd-text')
|
|
2006
|
+
var sentCmdCopy = document.getElementById('sent-cmd-copy')
|
|
2007
|
+
var sentCmdLead = document.getElementById('sent-cmd-lead')
|
|
2008
|
+
var failBox = document.getElementById('send-failed')
|
|
2009
|
+
var failWhat = document.getElementById('send-failed-what')
|
|
2010
|
+
var failNote = document.getElementById('send-failed-note')
|
|
2011
|
+
var failCmd = document.getElementById('send-failed-cmd')
|
|
2012
|
+
var failLead = document.getElementById('send-failed-lead')
|
|
2013
|
+
var failText = document.getElementById('send-failed-text')
|
|
2014
|
+
var failCopy = document.getElementById('send-failed-copy')
|
|
2015
|
+
var pickupWait = document.getElementById('pickup-wait')
|
|
2016
|
+
|
|
2017
|
+
// IS A POLL RUNNING RIGHT NOW? Deliberately not persisted, and that is the
|
|
2018
|
+
// whole of what it buys: a stored decision reloaded tomorrow carries the same
|
|
2019
|
+
// `picked: null` — nothing was ever established — but nothing is asking any
|
|
2020
|
+
// more, so it must not spin. Session state answers a question about the
|
|
2021
|
+
// session.
|
|
2022
|
+
var asking = false
|
|
2023
|
+
|
|
2024
|
+
// Has the close already been scheduled? `drawDecided` runs on every redraw
|
|
2025
|
+
// once a page is decided, so without this a redraw would queue another timer.
|
|
2026
|
+
var closing = false
|
|
2027
|
+
|
|
2028
|
+
// Long enough for the confirmation to register, short enough not to read as
|
|
2029
|
+
// a hang. It only matters where the close is actually permitted.
|
|
2030
|
+
var CLOSE_AFTER_MS = 900
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* This tab's job is over — best-effort, and only from the one state that
|
|
2034
|
+
* earns it.
|
|
2035
|
+
*
|
|
2036
|
+
* ONLY ON CONFIRMED PICKUP. Reached from the branch where the engine has
|
|
2037
|
+
* SAID a session claimed the pass, which is the only ending with provably
|
|
2038
|
+
* nothing left on the page to read: no code to quote, no command to run, no
|
|
2039
|
+
* blob to paste. Every other one — still asking, established-nobody-did, a
|
|
2040
|
+
* poll that failed, a `file://` page whose pass is on the clipboard, a
|
|
2041
|
+
* published page whose verdict needs `/spec-reviewed` — keeps the tab,
|
|
2042
|
+
* because each leaves something the reader still needs.
|
|
2043
|
+
*
|
|
2044
|
+
* BEST-EFFORT BY CONSTRUCTION, not by caution. Browsers refuse
|
|
2045
|
+
* `window.close()` on a tab the script did not open, and that is every tab
|
|
2046
|
+
* here: somebody typed the URL or tapped a link. So the confirmation is
|
|
2047
|
+
* drawn FIRST and the close attempted after it — a refused close leaves
|
|
2048
|
+
* exactly the right ending standing, and a permitted one still reads as
|
|
2049
|
+
* acknowledgement rather than a tab vanishing under the press.
|
|
2050
|
+
*/
|
|
2051
|
+
function closeWhenDone() {
|
|
2052
|
+
if (closing) return
|
|
2053
|
+
closing = true
|
|
2054
|
+
if (typeof window.close !== 'function' || !window.setTimeout) return
|
|
2055
|
+
window.setTimeout(function () {
|
|
2056
|
+
// A refusal can throw as well as do nothing, depending on the browser.
|
|
2057
|
+
// Neither is a failure of the review, so neither reaches the reader.
|
|
2058
|
+
try { window.close() } catch (e) {}
|
|
2059
|
+
}, CLOSE_AFTER_MS)
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function pending() {
|
|
2063
|
+
var b = buildBlob()
|
|
2064
|
+
return b.accepted.length + b.unaccepted.length + b.comments.length
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
/**
|
|
2068
|
+
* How many notes would still be open once this pass lands — the exact number
|
|
2069
|
+
* the engine's `judgeVerdict` will count, which is what makes the disabled
|
|
2070
|
+
* button a preview of the refusal rather than a second opinion about it.
|
|
2071
|
+
*
|
|
2072
|
+
* Stored comments the agent has not answered, PLUS every comment this pass is
|
|
2073
|
+
* about to send (a note you just wrote is open by definition).
|
|
2074
|
+
*
|
|
2075
|
+
* UNACCEPTED FILES ARE NOT COUNTED AND MUST NEVER BE. A comment is a request
|
|
2076
|
+
* you made; an unticked file is merely something you said nothing about, and
|
|
2077
|
+
* counting those would be the tally this whole feature is designed not to be.
|
|
2078
|
+
*/
|
|
2079
|
+
function openNotes() {
|
|
2080
|
+
var stored = (data.notes && data.notes.totals && data.notes.totals.unresolved) || 0
|
|
2081
|
+
return stored + buildBlob().comments.length
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// Redraw the bar and autosave. Called after every mark, so the block lifts
|
|
2085
|
+
// the moment the last note goes and a phone tab switch mid-review costs
|
|
2086
|
+
// nothing.
|
|
2087
|
+
function refresh() {
|
|
2088
|
+
// A decided page is finished. `refresh` runs on every mark and re-enables
|
|
2089
|
+
// the two committing buttons unconditionally, so without this it would undo
|
|
2090
|
+
// the ending every time anything redrew.
|
|
2091
|
+
if (decided) return drawDecided()
|
|
2092
|
+
// Which shape this page offers is re-answered here rather than at load: a
|
|
2093
|
+
// reader who ticks an accept half-way through has changed what their pass
|
|
2094
|
+
// contains, and a command line cannot carry the new contents.
|
|
2095
|
+
drawCmdList()
|
|
2096
|
+
var n = pending()
|
|
2097
|
+
var open = openNotes()
|
|
2098
|
+
verdictCount.textContent = n
|
|
2099
|
+
? n + (n === 1 ? ' mark to send' : ' marks to send')
|
|
2100
|
+
// Names the action this page actually offers. A mid-run page saying "a
|
|
2101
|
+
// clean read still commits" would promise the one thing it cannot do.
|
|
2102
|
+
: MIDRUN ? 'Nothing marked — a clean read still carries on' : 'Nothing marked — a clean read still commits'
|
|
2103
|
+
// ONE COUNT, BOTH BUTTONS. Two committing controls blocked by two separate
|
|
2104
|
+
// reads is how a page ends up with one disabled and the other not — which
|
|
2105
|
+
// would be a way around the single refusal this page makes.
|
|
2106
|
+
var blocked = open > 0
|
|
2107
|
+
var why = open + (open === 1 ? ' open note' : ' open notes')
|
|
2108
|
+
// Is there a phase left for `/spec-next` to build? The engine answers, and
|
|
2109
|
+
// its ABSENCE is a third state, not a no: a spec whose phases it could not
|
|
2110
|
+
// read — a legacy layout, an inline-phase overview — leaves the button
|
|
2111
|
+
// exactly as it was. Removing an offer that might work is worse than
|
|
2112
|
+
// leaving one that might not.
|
|
2113
|
+
var noPhaseLeft = Boolean(data.phases) && data.phases.hasNextPhase === false
|
|
2114
|
+
COMMITTERS.forEach(function (key) {
|
|
2115
|
+
var btn = verdictBtns[key]
|
|
2116
|
+
// AN OPEN NOTE WINS. Two reasons can be true at once and the label has
|
|
2117
|
+
// room for one — the open note is the one the reader can act on, so a
|
|
2118
|
+
// "no phase left" in its place would send them to fix the wrong thing.
|
|
2119
|
+
var stop = blocked ? why : key === 'commit-continue' && noPhaseLeft ? 'no phase left' : null
|
|
2120
|
+
// The reason goes ON the control. A button that is merely dimmed reads as
|
|
2121
|
+
// a broken page, and a title attribute is unreachable on a touch screen.
|
|
2122
|
+
btn.disabled = Boolean(stop)
|
|
2123
|
+
btn.textContent = stop ? LABELS[key] + ' — ' + stop : LABELS[key]
|
|
2124
|
+
btn.title = blocked
|
|
2125
|
+
? 'Resolve or remove your notes first — you asked for something, so it cannot also be fine'
|
|
2126
|
+
: key === 'commit-continue' && noPhaseLeft
|
|
2127
|
+
? 'Every phase of this spec is done — there is nothing left to build'
|
|
2128
|
+
: TITLES[key]
|
|
2129
|
+
})
|
|
2130
|
+
// Said rather than left unset: the committing pair is the ONLY thing this
|
|
2131
|
+
// page ever blocks, and the others are always available on purpose — a note
|
|
2132
|
+
// you want acted on, and a question, are never the wrong thing to send.
|
|
2133
|
+
//
|
|
2134
|
+
// CONTINUE IS NOT BLOCKED, and that mirrors the engine rather than softening
|
|
2135
|
+
// it: `judgeVerdict` refuses a COMMITTING verdict against an open note, and
|
|
2136
|
+
// `continue` is deliberately not one. A disabled button here would be a
|
|
2137
|
+
// second opinion about a refusal the engine does not make, which is the one
|
|
2138
|
+
// thing this bar must never be.
|
|
2139
|
+
verdictBtns.continue.disabled = false
|
|
2140
|
+
verdictBtns.changes.disabled = false
|
|
2141
|
+
verdictBtns.discuss.disabled = false
|
|
2142
|
+
// Adding the note is the ordinary way out of a warning, and every mark comes
|
|
2143
|
+
// through here — so the line goes when the situation it described does,
|
|
2144
|
+
// rather than standing over a page where nothing is unsent any more.
|
|
2145
|
+
clearWarn()
|
|
2146
|
+
save()
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* End the review, and say how it ended.
|
|
2151
|
+
*
|
|
2152
|
+
* A verdict that has been handed over is the END of this page's job, and the
|
|
2153
|
+
* page used to carry on as if it were not: the diff stayed, the four buttons
|
|
2154
|
+
* stayed live, and a line of small grey text under them said "Sent." A reader
|
|
2155
|
+
* coming back to that tab has no way to tell a sent review from an unsent
|
|
2156
|
+
* one, and pressing a button again reaches an agent that stopped listening.
|
|
2157
|
+
*
|
|
2158
|
+
* So the diff goes, what was decided takes its place, and the controls die
|
|
2159
|
+
* with a reason on them.
|
|
2160
|
+
*/
|
|
2161
|
+
function drawDecided() {
|
|
2162
|
+
if (!decided) return
|
|
2163
|
+
var said = SAID[decided.verdict] || decided.verdict
|
|
2164
|
+
var when = String(decided.at || '').slice(0, 10)
|
|
2165
|
+
decidedBox.hidden = false
|
|
2166
|
+
decidedWhat.textContent = 'You chose: ' + (LABELS[decided.verdict] || decided.verdict)
|
|
2167
|
+
decidedNote.textContent =
|
|
2168
|
+
'Sent to Claude' + (when ? ' on ' + when : '') + ' — this review is ' + said + '. ' +
|
|
2169
|
+
'Nothing here reaches Claude any more, so the verdict buttons are closed.'
|
|
2170
|
+
wrapEl.className = wrapEl.className.replace(/\s*\bis-decided\b/g, '') + ' is-decided'
|
|
2171
|
+
// The diff is hidden from here on, so there is no card to be reading and
|
|
2172
|
+
// nothing for the tree to point at. `drawDecided` runs on every redraw once
|
|
2173
|
+
// a page is decided, and clearing something already cleared is free.
|
|
2174
|
+
clearCurrent()
|
|
2175
|
+
// The commands go with the bar. A decided page offers no way to decide
|
|
2176
|
+
// again, whichever shape it was offering.
|
|
2177
|
+
if (cmdList) cmdList.hidden = true
|
|
2178
|
+
// EVERY button, not only the committing pair: `refresh` re-enables those two
|
|
2179
|
+
// by design, and a decided page must beat it.
|
|
2180
|
+
Object.keys(verdictBtns).forEach(function (key) {
|
|
2181
|
+
var btn = verdictBtns[key]
|
|
2182
|
+
btn.disabled = true
|
|
2183
|
+
btn.title = 'Already sent — ' + said
|
|
2184
|
+
})
|
|
2185
|
+
verdictCount.textContent = 'Sent — ' + said
|
|
2186
|
+
// ONE STATEMENT, NOT FOUR. The panel, the bar, the history line and the
|
|
2187
|
+
// command box all said the same thing at once. The log line is the panel's
|
|
2188
|
+
// sentence again in smaller type, so it goes.
|
|
2189
|
+
verdictLog.hidden = true
|
|
2190
|
+
|
|
2191
|
+
// WHAT THE PAGE ESTABLISHED, replayed — never re-derived. `picked` is the
|
|
2192
|
+
// answer the poll got, and a re-opened tab has no way to ask again for a
|
|
2193
|
+
// pass that is long gone, so it says what it said before rather than
|
|
2194
|
+
// quietly changing its mind a day later.
|
|
2195
|
+
copyHint.hidden = true
|
|
2196
|
+
copyOut.hidden = true
|
|
2197
|
+
pickupWait.hidden = true
|
|
2198
|
+
// STILL ASKING — and saying so, which is the state this panel used not to
|
|
2199
|
+
// have. `watchPass` takes up to seven seconds to establish whether a
|
|
2200
|
+
// session claimed the pass, and the page used to spend that window
|
|
2201
|
+
// asserting the pessimistic answer: "still waiting to be picked up", with
|
|
2202
|
+
// a command under it, retracted a moment later on the usual outcome. The
|
|
2203
|
+
// reader was being told to run something they almost never needed, in the
|
|
2204
|
+
// same words used when running it is the only thing that saves their
|
|
2205
|
+
// review.
|
|
2206
|
+
//
|
|
2207
|
+
// `asking` and not merely `picked === null`. A tab reopened over a stored
|
|
2208
|
+
// decision has the same `null` — nothing was ever established — but no
|
|
2209
|
+
// poll is running to resolve it, so it falls through to the command below.
|
|
2210
|
+
// A spinner that can never stop is worse than no spinner.
|
|
2211
|
+
// (`.claude/rules/negative-checks.md` rule 4: the cannot-tell case takes
|
|
2212
|
+
// the harmless branch, which here is claiming neither answer.)
|
|
2213
|
+
if (decided.picked === null && asking) {
|
|
2214
|
+
sentCmd.hidden = true
|
|
2215
|
+
pickupWait.hidden = false
|
|
2216
|
+
return
|
|
2217
|
+
}
|
|
2218
|
+
if (decided.picked === true) {
|
|
2219
|
+
// NOTHING TO RUN, and so nothing offered. This is the whole point of
|
|
2220
|
+
// asking: the command used to appear here on every single ending, which
|
|
2221
|
+
// taught the reader it was decoration.
|
|
2222
|
+
sentCmd.hidden = true
|
|
2223
|
+
decidedNote.textContent += ' Claude picked this up — there is nothing to run.'
|
|
2224
|
+
closeWhenDone()
|
|
2225
|
+
return
|
|
2226
|
+
}
|
|
2227
|
+
// THE HAND-OFF SURVIVES THE ENDING — but only where there is something
|
|
2228
|
+
// worth carrying. A code is six digits to transcribe, and the box and its
|
|
2229
|
+
// Copy button exist for exactly that. A bare `/spec-reviewed` is a word the
|
|
2230
|
+
// reader is about to type into the terminal they are already sitting in;
|
|
2231
|
+
// dressing it as a copyable artefact makes the ending look unfinished.
|
|
2232
|
+
if (decided.cmd && /\s/.test(decided.cmd)) {
|
|
2233
|
+
decidedNote.textContent += ' It is still waiting to be picked up.'
|
|
2234
|
+
showCommand(decided.cmd)
|
|
2235
|
+
return
|
|
2236
|
+
}
|
|
2237
|
+
sentCmd.hidden = true
|
|
2238
|
+
if (decided.cmd) {
|
|
2239
|
+
decidedNote.textContent +=
|
|
2240
|
+
' If Claude was not already waiting, run ' + decided.cmd + ' where it is.'
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/**
|
|
2245
|
+
* Record a verdict that was actually delivered, and end the page on it.
|
|
2246
|
+
*
|
|
2247
|
+
* `picked` is three-valued and the third value is the useful one: `true` the
|
|
2248
|
+
* engine confirmed somebody claimed this pass, `false` it confirmed nobody
|
|
2249
|
+
* has, and `null` NOTHING COULD BE ESTABLISHED — an older daemon with no
|
|
2250
|
+
* lookup, a poll that never came back, a page that stopped asking. Only
|
|
2251
|
+
* `true` takes the command away, so every way of not knowing leaves it on
|
|
2252
|
+
* screen (`.claude/rules/negative-checks.md` rule 4).
|
|
2253
|
+
*/
|
|
2254
|
+
function markDecided(verdict, cmd, picked) {
|
|
2255
|
+
decided = {
|
|
2256
|
+
verdict: verdict,
|
|
2257
|
+
at: new Date().toISOString(),
|
|
2258
|
+
cmd: cmd || null,
|
|
2259
|
+
picked: picked === undefined ? null : picked,
|
|
2260
|
+
}
|
|
2261
|
+
save()
|
|
2262
|
+
drawDecided()
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
// A WAY BACK IN, read-only. "It is finished" and "I cannot see what I
|
|
2266
|
+
// approved" are different things, and only the first one is true.
|
|
2267
|
+
decidedToggle.addEventListener('click', function () {
|
|
2268
|
+
var showing = / \bshow-diff\b/.test(' ' + wrapEl.className)
|
|
2269
|
+
wrapEl.className = wrapEl.className.replace(/\s*\bshow-diff\b/g, '') + (showing ? '' : ' show-diff')
|
|
2270
|
+
decidedToggle.textContent = showing ? 'Show the diff anyway' : 'Hide the diff'
|
|
2271
|
+
})
|
|
2272
|
+
|
|
2273
|
+
// How a logged verdict reads. EVERY VERDICT NAMED, and an unknown one says
|
|
2274
|
+
// so: the fallback was `discussed`, which quietly relabelled `commit` — and a
|
|
2275
|
+
// page that tells you a commit was a discussion is worse than one that admits
|
|
2276
|
+
// it does not know the word.
|
|
2277
|
+
var SAID = {
|
|
2278
|
+
approve: 'committed',
|
|
2279
|
+
commit: 'committed',
|
|
2280
|
+
'commit-continue': 'committed, then carried on',
|
|
2281
|
+
'commit-start': 'committed, then put in flight',
|
|
2282
|
+
'commit-land': 'committed, landed, and torn down',
|
|
2283
|
+
continue: 'read it and carried on',
|
|
2284
|
+
changes: 'changes requested',
|
|
2285
|
+
discuss: 'discussed',
|
|
2286
|
+
skip: 'moved on without a verdict',
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// The last thing that happened here, as history. It is why the page can look
|
|
2290
|
+
// untouched after a commit: the verdict was consumed, not stored. A SKIP is
|
|
2291
|
+
// history of exactly the same kind — it is the recorded decision to move on,
|
|
2292
|
+
// and hiding it would make a skipped review indistinguishable from one that
|
|
2293
|
+
// never ended.
|
|
2294
|
+
function drawLog() {
|
|
2295
|
+
var last = data.notes && data.notes.lastDecision
|
|
2296
|
+
var skip = data.gate && data.gate.lastSkip
|
|
2297
|
+
// Whichever is more recent. Both are timestamped, and comparing is what
|
|
2298
|
+
// stops an old verdict shadowing a skip taken after it.
|
|
2299
|
+
if (skip && (!last || String(skip.at || '') > String(last.at || ''))) {
|
|
2300
|
+
last = { verdict: 'skip', at: skip.at, note: skip.reason }
|
|
2301
|
+
}
|
|
2302
|
+
if (!last) return
|
|
2303
|
+
var said = SAID[last.verdict] || ('recorded as "' + last.verdict + '"')
|
|
2304
|
+
var when = String(last.at || '').slice(0, 10)
|
|
2305
|
+
verdictLog.hidden = false
|
|
2306
|
+
verdictLog.textContent = said + ' earlier' + (when ? ' · ' + when : '') + (last.note ? ' · ' + last.note : '')
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
/**
|
|
2310
|
+
* Hand over the command itself, where the reader's eye already is.
|
|
2311
|
+
*
|
|
2312
|
+
* Two ways out and the page picks by CAPABILITY, not by trying and failing:
|
|
2313
|
+
* `navigator.clipboard` is secure-context-only, and a page served at
|
|
2314
|
+
* `http://<lan-ip>:7777` is not a secure context — which is the ordinary case
|
|
2315
|
+
* here, not an edge one. A Copy button that does nothing when tapped is worse
|
|
2316
|
+
* than text that was always text, so the button only exists where it can work.
|
|
2317
|
+
*/
|
|
2318
|
+
/**
|
|
2319
|
+
* Put the whole command in the reader's selection. Returns whether it worked.
|
|
2320
|
+
*
|
|
2321
|
+
* REPORTED, NOT ASSUMED. It replaces `input.select()`, which the box no longer
|
|
2322
|
+
* has anything to call — and the honest thing to do when a browser withholds
|
|
2323
|
+
* `getSelection` is to stop claiming the text is selected, rather than say so
|
|
2324
|
+
* and leave someone hunting for a highlight that is not there.
|
|
2325
|
+
*/
|
|
2326
|
+
function selectCommand() {
|
|
2327
|
+
return selectNode(sentCmdText)
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
/**
|
|
2331
|
+
* Put a node's whole text in the reader's selection. Returns whether it took.
|
|
2332
|
+
*/
|
|
2333
|
+
function selectNode(node) {
|
|
2334
|
+
try {
|
|
2335
|
+
var range = document.createRange()
|
|
2336
|
+
range.selectNodeContents(node)
|
|
2337
|
+
var sel = window.getSelection && window.getSelection()
|
|
2338
|
+
if (!sel) return false
|
|
2339
|
+
sel.removeAllRanges()
|
|
2340
|
+
sel.addRange(range)
|
|
2341
|
+
return true
|
|
2342
|
+
} catch (e) {
|
|
2343
|
+
return false
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/**
|
|
2348
|
+
* Can this browser copy AT ALL — by either route?
|
|
2349
|
+
*
|
|
2350
|
+
* WHAT THIS GOT WRONG, and why the button kept vanishing where it was needed
|
|
2351
|
+
* most: `navigator.clipboard` is **secure-context only**, so it is absent on
|
|
2352
|
+
* every `http://<lan-ip>:7777` page — which is exactly the page someone reads
|
|
2353
|
+
* on a phone. Asking only about that API therefore hid the Copy button in the
|
|
2354
|
+
* one case the button exists for, and left a phone reader long-pressing to
|
|
2355
|
+
* select text on a page that could have copied for them all along.
|
|
2356
|
+
*
|
|
2357
|
+
* `document.execCommand('copy')` is deprecated and universally implemented,
|
|
2358
|
+
* and it works in a non-secure context inside a user gesture. So there are
|
|
2359
|
+
* two routes, and the rule below is unchanged: offer the button only where
|
|
2360
|
+
* one of them exists.
|
|
2361
|
+
*/
|
|
2362
|
+
function canCopy() {
|
|
2363
|
+
return Boolean(
|
|
2364
|
+
(navigator.clipboard && navigator.clipboard.writeText) ||
|
|
2365
|
+
(document.execCommand && window.getSelection),
|
|
2366
|
+
)
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
/**
|
|
2370
|
+
* Copy `text`, by whichever route this browser has. Resolves on success and
|
|
2371
|
+
* rejects otherwise, so every caller reports the same three outcomes.
|
|
2372
|
+
*
|
|
2373
|
+
* `node` is what gets left selected when copying fails — the reader can then
|
|
2374
|
+
* do it by hand, which is the behaviour this button replaced rather than
|
|
2375
|
+
* removed.
|
|
2376
|
+
*/
|
|
2377
|
+
function copyText(text, node) {
|
|
2378
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
2379
|
+
return navigator.clipboard.writeText(text)
|
|
2380
|
+
}
|
|
2381
|
+
return new Promise(function (resolve, reject) {
|
|
2382
|
+
// The selection IS the mechanism here, not a consolation: execCommand
|
|
2383
|
+
// copies what is selected. So it is made first and deliberately left in
|
|
2384
|
+
// place — a failed copy then lands the reader exactly where the old
|
|
2385
|
+
// no-clipboard path did.
|
|
2386
|
+
if (!selectNode(node)) return reject(new Error('no selection'))
|
|
2387
|
+
var ok = false
|
|
2388
|
+
try {
|
|
2389
|
+
ok = document.execCommand('copy')
|
|
2390
|
+
} catch (e) {
|
|
2391
|
+
ok = false
|
|
2392
|
+
}
|
|
2393
|
+
return ok ? resolve() : reject(new Error('execCommand refused'))
|
|
2394
|
+
})
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
function showCommand(cmd) {
|
|
2398
|
+
sentCmd.hidden = false
|
|
2399
|
+
sentCmdText.textContent = cmd
|
|
2400
|
+
var copyable = canCopy()
|
|
2401
|
+
sentCmdCopy.hidden = !copyable
|
|
2402
|
+
sentCmdCopy.textContent = 'Copy'
|
|
2403
|
+
// SET HERE, NOT LEFT TO THE MARKUP. The lead says two different things and
|
|
2404
|
+
// the box is shown more than once in a page's life, so a line that was only
|
|
2405
|
+
// ever correct on first render would go stale the moment either changed.
|
|
2406
|
+
if (copyable) {
|
|
2407
|
+
sentCmdLead.textContent = 'Run this where Claude is:'
|
|
2408
|
+
// NOT SELECTED when there is a button. Pre-selecting made the line look
|
|
2409
|
+
// like a focused input, which is what it was mistaken for — and it is the
|
|
2410
|
+
// long-press-to-select gesture the button is here to spare a phone reader.
|
|
2411
|
+
return
|
|
2412
|
+
}
|
|
2413
|
+
// Selected where that is possible, and SAID to be selected: a reader who
|
|
2414
|
+
// sees nothing happen has no way to tell a page that did nothing from one
|
|
2415
|
+
// that already did the work. The lead carries it now that the box lives in
|
|
2416
|
+
// the decided panel — `copy-hint` is down in the footer with the verdict
|
|
2417
|
+
// bar, and appending to it put the sentence half a page from the thing it
|
|
2418
|
+
// describes.
|
|
2419
|
+
sentCmdLead.textContent = selectCommand()
|
|
2420
|
+
? 'Run this where Claude is (selected — copy it):'
|
|
2421
|
+
: 'Run this where Claude is — copy it:'
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
if (sentCmdCopy) {
|
|
2425
|
+
sentCmdCopy.addEventListener('click', function () {
|
|
2426
|
+
copyText(sentCmdText.textContent, sentCmdText).then(function () {
|
|
2427
|
+
sentCmdCopy.textContent = 'Copied'
|
|
2428
|
+
}, function () {
|
|
2429
|
+
// The capability was there and the write still failed. Fall back to the
|
|
2430
|
+
// same selection the no-clipboard path uses rather than saying nothing —
|
|
2431
|
+
// and if even that is unavailable, say THAT, because a button reading
|
|
2432
|
+
// "selected" over nothing highlighted is worse than an honest failure.
|
|
2433
|
+
sentCmdCopy.textContent = selectCommand() ? 'Copy failed — selected' : 'Copy failed'
|
|
2434
|
+
})
|
|
2435
|
+
})
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/**
|
|
2439
|
+
* The header: why this change exists, what it touches, what this phase set
|
|
2440
|
+
* out to do. Every part is optional and each is drawn only if present — a
|
|
2441
|
+
* spec with a Problem and no Impact table is ordinary, not broken.
|
|
2442
|
+
*
|
|
2443
|
+
* BUILT WITH createElement, never innerHTML. The text is markdown from a spec
|
|
2444
|
+
* file, which means it can contain anything someone typed — and this page is
|
|
2445
|
+
* served over the network, so treating it as markup would be the one place a
|
|
2446
|
+
* spec's prose could reach the DOM.
|
|
2447
|
+
*/
|
|
2448
|
+
function drawContext() {
|
|
2449
|
+
var ctx = data.context
|
|
2450
|
+
if (!ctx) return
|
|
2451
|
+
var why = document.getElementById('context-why')
|
|
2452
|
+
var rest = document.getElementById('context-rest')
|
|
2453
|
+
var more = document.getElementById('context-more')
|
|
2454
|
+
var drewRest = false
|
|
2455
|
+
|
|
2456
|
+
function head(into, label) {
|
|
2457
|
+
var h = document.createElement('h3')
|
|
2458
|
+
h.textContent = label
|
|
2459
|
+
into.appendChild(h)
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
/**
|
|
2463
|
+
* THE PHASE COMES FIRST, and the spec's Problem is folded away behind it.
|
|
2464
|
+
*
|
|
2465
|
+
* The open part of this header used to be `Why` — the overview's Problem,
|
|
2466
|
+
* which is about the whole spec. A reader opening a page for phase 3 got
|
|
2467
|
+
* the same paragraph they had already read at phase 1, and had to expand
|
|
2468
|
+
* `More` to reach the only thing that told them what THIS review covers.
|
|
2469
|
+
* The question a review page answers is "what am I looking at", so the
|
|
2470
|
+
* phase answers it, and the background it sits in is one tap away.
|
|
2471
|
+
*
|
|
2472
|
+
* The TASK LIST is open too, deliberately. It is the cheapest statement of
|
|
2473
|
+
* what is and is not in this diff — which matters most half-way through a
|
|
2474
|
+
* phase, where a `/spec-diff` shows three ticked boxes and four empty ones
|
|
2475
|
+
* and the empty ones are the reason the diff looks unfinished.
|
|
2476
|
+
*/
|
|
2477
|
+
if (ctx.phase) {
|
|
2478
|
+
head(why, 'Phase ' + ctx.phase.n + (ctx.phase.title ? ' — ' + ctx.phase.title : ''))
|
|
2479
|
+
if (ctx.phase.goal) {
|
|
2480
|
+
var g = document.createElement('p')
|
|
2481
|
+
g.textContent = ctx.phase.goal
|
|
2482
|
+
why.appendChild(g)
|
|
2483
|
+
}
|
|
2484
|
+
if (ctx.phase.tasks && ctx.phase.tasks.length) {
|
|
2485
|
+
var ul = document.createElement('ul')
|
|
2486
|
+
ul.className = 'tasks'
|
|
2487
|
+
ctx.phase.tasks.forEach(function (task) {
|
|
2488
|
+
var li = document.createElement('li')
|
|
2489
|
+
// Done is dimmed rather than struck through: every task is ticked by
|
|
2490
|
+
// the time a phase renders, and a whole list struck through reads as
|
|
2491
|
+
// cancelled work.
|
|
2492
|
+
li.className = task.done ? 'done' : ''
|
|
2493
|
+
li.textContent = (task.done ? '✅ ' : '⬜ ') + task.text
|
|
2494
|
+
ul.appendChild(li)
|
|
2495
|
+
})
|
|
2496
|
+
why.appendChild(ul)
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
if (ctx.problem) {
|
|
2501
|
+
// FOLDED, ALL OF IT. With the phase open above, the spec's Problem is
|
|
2502
|
+
// background rather than the answer — and on a phone every line of it is
|
|
2503
|
+
// a line of the file list pushed off-screen, which is the thing this
|
|
2504
|
+
// header was built to avoid. Where there is NO phase (a branch-wide diff,
|
|
2505
|
+
// a spec with no live phase) the first paragraph stays open, because then
|
|
2506
|
+
// it is the only thing the header can say.
|
|
2507
|
+
var paras = String(ctx.problem).split(/\n\s*\n/)
|
|
2508
|
+
var from = 0
|
|
2509
|
+
if (!ctx.phase) {
|
|
2510
|
+
head(why, 'Why')
|
|
2511
|
+
var lead = document.createElement('p')
|
|
2512
|
+
lead.textContent = paras[0].replace(/\s+/g, ' ').trim()
|
|
2513
|
+
why.appendChild(lead)
|
|
2514
|
+
from = 1
|
|
2515
|
+
} else if (paras.length) {
|
|
2516
|
+
head(rest, 'Why')
|
|
2517
|
+
drewRest = true
|
|
2518
|
+
}
|
|
2519
|
+
for (var i = from; i < paras.length; i++) {
|
|
2520
|
+
var p = document.createElement('p')
|
|
2521
|
+
p.textContent = paras[i].replace(/\s+/g, ' ').trim()
|
|
2522
|
+
rest.appendChild(p)
|
|
2523
|
+
drewRest = true
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
if (ctx.impact) {
|
|
2528
|
+
head(rest, 'Touches')
|
|
2529
|
+
drewRest = true
|
|
2530
|
+
if (ctx.impact.rows) {
|
|
2531
|
+
var table = document.createElement('table')
|
|
2532
|
+
var hr = document.createElement('tr')
|
|
2533
|
+
;['Surface', 'Change', 'Detail'].forEach(function (label) {
|
|
2534
|
+
var th = document.createElement('th')
|
|
2535
|
+
th.textContent = label
|
|
2536
|
+
hr.appendChild(th)
|
|
2537
|
+
})
|
|
2538
|
+
table.appendChild(hr)
|
|
2539
|
+
ctx.impact.rows.forEach(function (row) {
|
|
2540
|
+
var tr = document.createElement('tr')
|
|
2541
|
+
;[row.surface, row.change, row.detail].forEach(function (cell) {
|
|
2542
|
+
var td = document.createElement('td')
|
|
2543
|
+
td.textContent = cell
|
|
2544
|
+
tr.appendChild(td)
|
|
2545
|
+
})
|
|
2546
|
+
table.appendChild(tr)
|
|
2547
|
+
})
|
|
2548
|
+
rest.appendChild(table)
|
|
2549
|
+
} else {
|
|
2550
|
+
var prose = document.createElement('p')
|
|
2551
|
+
prose.textContent = ctx.impact.prose
|
|
2552
|
+
rest.appendChild(prose)
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
document.getElementById('context').hidden = false
|
|
2557
|
+
more.hidden = !drewRest
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
function showFallback(json, why) {
|
|
2561
|
+
copyOut.hidden = false
|
|
2562
|
+
copyOut.value = json
|
|
2563
|
+
copyOut.focus()
|
|
2564
|
+
copyOut.select()
|
|
2565
|
+
copyHint.hidden = false
|
|
2566
|
+
copyHint.textContent = why
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
/**
|
|
2570
|
+
* Put the whole recovery command in the failure callout, with its Copy control.
|
|
2571
|
+
*
|
|
2572
|
+
* THE SAME CONTROL THE `file://` PAGE ALREADY USES — `/spec-reviewed <verdict>`
|
|
2573
|
+
* — so there is one command row rather than a second kind. It reaches the
|
|
2574
|
+
* agent regardless of any server, which is the only reason a recovery can be
|
|
2575
|
+
* offered from a failed send at all.
|
|
2576
|
+
*
|
|
2577
|
+
* `canCopy` decides whether there is a button, exactly as `showCommand` does:
|
|
2578
|
+
* a control that does nothing on tap reads as a broken page, and one that is
|
|
2579
|
+
* missing where it would have worked reads as a page that never offered it.
|
|
2580
|
+
*/
|
|
2581
|
+
function showFailCommand(cmd) {
|
|
2582
|
+
failCmd.hidden = false
|
|
2583
|
+
failText.textContent = cmd
|
|
2584
|
+
var copyable = canCopy()
|
|
2585
|
+
failCopy.hidden = !copyable
|
|
2586
|
+
failCopy.textContent = 'Copy'
|
|
2587
|
+
failLead.textContent = copyable
|
|
2588
|
+
? 'Your verdict is not lost — run this where Claude is:'
|
|
2589
|
+
: selectNode(failText)
|
|
2590
|
+
? 'Your verdict is not lost — run this where Claude is (selected — copy it):'
|
|
2591
|
+
: 'Your verdict is not lost — run this where Claude is — copy it:'
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
if (failCopy) {
|
|
2595
|
+
failCopy.addEventListener('click', function () {
|
|
2596
|
+
copyText(failText.textContent, failText).then(function () {
|
|
2597
|
+
failCopy.textContent = 'Copied'
|
|
2598
|
+
}, function () {
|
|
2599
|
+
failCopy.textContent = selectNode(failText) ? 'Copy failed — selected' : 'Copy failed'
|
|
2600
|
+
})
|
|
2601
|
+
})
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
/** Take the failure panel down — a fresh attempt, or one that landed. */
|
|
2605
|
+
function clearSendFailure() {
|
|
2606
|
+
failBox.hidden = true
|
|
2607
|
+
failCmd.hidden = true
|
|
2608
|
+
failWhat.textContent = ''
|
|
2609
|
+
failNote.textContent = ''
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/**
|
|
2613
|
+
* Say that a send did not land, as loudly as a send that did.
|
|
2614
|
+
*
|
|
2615
|
+
* THE ONE OUTCOME THE READER MUST ACT ON used to be reported more quietly than
|
|
2616
|
+
* the one they need do nothing about: a small grey line in the footer, under a
|
|
2617
|
+
* verdict bar that had just closed. It gets the panel at the top of the column
|
|
2618
|
+
* now, in the delete palette.
|
|
2619
|
+
*
|
|
2620
|
+
* IT DOES NOT DECIDE THE PAGE. Nothing was delivered, so the diff stays and
|
|
2621
|
+
* the verdict buttons stay live — a page that closed its controls over a
|
|
2622
|
+
* failed send would strand the reader with no way to try again.
|
|
2623
|
+
*
|
|
2624
|
+
* A pass carrying MARKS still goes to the clipboard, because a command line
|
|
2625
|
+
* cannot carry notes and a failed send must not become the moment they are
|
|
2626
|
+
* silently dropped.
|
|
2627
|
+
*/
|
|
2628
|
+
function sendFailed(json, verdict, what, note) {
|
|
2629
|
+
copyHint.hidden = true
|
|
2630
|
+
copyHint.textContent = ''
|
|
2631
|
+
failBox.hidden = false
|
|
2632
|
+
failWhat.textContent = what
|
|
2633
|
+
if (pending() > 0) {
|
|
2634
|
+
failCmd.hidden = true
|
|
2635
|
+
failNote.textContent =
|
|
2636
|
+
note +
|
|
2637
|
+
' You have marked things up, and a command line cannot carry notes — the whole pass is ' +
|
|
2638
|
+
'below. Copy it and paste it to Claude.'
|
|
2639
|
+
copyOut.hidden = false
|
|
2640
|
+
copyOut.value = json
|
|
2641
|
+
copyOut.focus()
|
|
2642
|
+
copyOut.select()
|
|
2643
|
+
} else {
|
|
2644
|
+
failNote.textContent = note
|
|
2645
|
+
showFailCommand('/spec-reviewed ' + verdict)
|
|
2646
|
+
}
|
|
2647
|
+
// WHERE THE READER IS LOOKING. The verdict bar is at the foot of the page
|
|
2648
|
+
// and this panel is at the head of it, so a failure announced without this
|
|
2649
|
+
// is a failure announced off-screen — which is the shape of the bug being
|
|
2650
|
+
// fixed, one scroll position removed.
|
|
2651
|
+
if (failBox.scrollIntoView) failBox.scrollIntoView()
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// One action per decision: the verdict button IS the copy button, so the blob
|
|
2655
|
+
// can never carry a verdict other than the one just pressed.
|
|
2656
|
+
/**
|
|
2657
|
+
* Send the pass back over the wire, to the page's own URL.
|
|
2658
|
+
*
|
|
2659
|
+
* The blob never passes through the model this way: the engine holds it and
|
|
2660
|
+
* you read six digits out, where a paste costs context in proportion to how
|
|
2661
|
+
* much you wrote.
|
|
2662
|
+
*/
|
|
2663
|
+
function post(json, verdict) {
|
|
2664
|
+
clearSendFailure()
|
|
2665
|
+
copyHint.hidden = false
|
|
2666
|
+
copyHint.textContent = 'Sending…'
|
|
2667
|
+
fetch(location.pathname, {
|
|
2668
|
+
method: 'POST',
|
|
2669
|
+
headers: { 'content-type': 'application/json' },
|
|
2670
|
+
body: json,
|
|
2671
|
+
}).then(function (res) {
|
|
2672
|
+
return res.text().then(function (text) {
|
|
2673
|
+
if (!res.ok) {
|
|
2674
|
+
// A pass the server REFUSED must never read as sent — and WHICH
|
|
2675
|
+
// refusal decides where the reader goes next, so the two are named
|
|
2676
|
+
// separately rather than collapsed into one "not sent".
|
|
2677
|
+
if (res.status === 404) {
|
|
2678
|
+
// THE PORT MOVED, or another repo's daemon holds this one. The token
|
|
2679
|
+
// in this URL means nothing there, so re-opening the page from the
|
|
2680
|
+
// session that produced it is the fix — the link is stale, the pass
|
|
2681
|
+
// is fine.
|
|
2682
|
+
sendFailed(
|
|
2683
|
+
json,
|
|
2684
|
+
verdict,
|
|
2685
|
+
'Not sent — this page\'s server is gone.',
|
|
2686
|
+
'Nothing is answering at this address any more: the review server moved to another ' +
|
|
2687
|
+
'port, or a different repo holds this one. Re-open the page from the Claude Code ' +
|
|
2688
|
+
'session that produced it and the buttons below will send.',
|
|
2689
|
+
)
|
|
2690
|
+
} else if (res.status === 422) {
|
|
2691
|
+
// THE ENGINE READ IT AND SAID NO. Its message names the entry that
|
|
2692
|
+
// was wrong, so it is relayed verbatim — paraphrasing is how a
|
|
2693
|
+
// reader ends up hunting for a problem the tool had already named.
|
|
2694
|
+
// AND NO "re-open": the page is fine, and re-opening would only be
|
|
2695
|
+
// refused again.
|
|
2696
|
+
sendFailed(
|
|
2697
|
+
json,
|
|
2698
|
+
verdict,
|
|
2699
|
+
'Not sent — the engine refused this pass.',
|
|
2700
|
+
text || 'It answered ' + res.status + ' without saying why.',
|
|
2701
|
+
)
|
|
2702
|
+
} else {
|
|
2703
|
+
sendFailed(
|
|
2704
|
+
json,
|
|
2705
|
+
verdict,
|
|
2706
|
+
'Not sent — the server answered ' + res.status + '.',
|
|
2707
|
+
text || 'It gave no reason.',
|
|
2708
|
+
)
|
|
2709
|
+
}
|
|
2710
|
+
return
|
|
2711
|
+
}
|
|
2712
|
+
clearSendFailure()
|
|
2713
|
+
var code = ''
|
|
2714
|
+
try { code = JSON.parse(text).code } catch (e) {}
|
|
2715
|
+
// NAME THE COMMAND. Not "claim it with 418207", which instructs a
|
|
2716
|
+
// transcription nobody needs to make, and not "tell Claude it is
|
|
2717
|
+
// waiting", which describes the arrangement `/spec-reviewed` replaced —
|
|
2718
|
+
// narrating that a pass exists and hoping the agent looks. The code
|
|
2719
|
+
// stays, as the thing to CHECK: it is the one part of a waiting pass
|
|
2720
|
+
// that tells yours apart from anyone else's.
|
|
2721
|
+
if (code) {
|
|
2722
|
+
// SENT, AND NOW ASK WHAT BECAME OF IT. The page used to name the
|
|
2723
|
+
// command here unconditionally — the same sentence whether a session
|
|
2724
|
+
// was waiting to claim the pass or nothing was — because it had no
|
|
2725
|
+
// way to find out. It has one now.
|
|
2726
|
+
// Set BEFORE the draw, so the first render of the decided panel is
|
|
2727
|
+
// already the waiting shape rather than the pessimistic one.
|
|
2728
|
+
asking = true
|
|
2729
|
+
markDecided(verdict, '/spec-reviewed ' + code, null)
|
|
2730
|
+
watchPass(code, verdict)
|
|
2731
|
+
} else {
|
|
2732
|
+
// NO CODE, NO COMMAND. A `/spec-reviewed` with nothing after it still
|
|
2733
|
+
// works — it picks up the single waiting pass — but the page cannot
|
|
2734
|
+
// claim the server named THIS one, so it does not put words in its
|
|
2735
|
+
// mouth. Bare is what the reader is told, because bare is what is true.
|
|
2736
|
+
copyHint.textContent = 'Sent — run /spec-reviewed to pick it up.'
|
|
2737
|
+
markDecided(verdict, null, null)
|
|
2738
|
+
}
|
|
2739
|
+
})
|
|
2740
|
+
}, function () {
|
|
2741
|
+
// THE REQUEST NEVER ARRIVED — the network, or a server that has stopped.
|
|
2742
|
+
// Distinct from both refusals above: there is no status and no message,
|
|
2743
|
+
// because nothing answered. Re-opening may fix it and the command
|
|
2744
|
+
// certainly will, so both are offered.
|
|
2745
|
+
sendFailed(
|
|
2746
|
+
json,
|
|
2747
|
+
verdict,
|
|
2748
|
+
'Not sent — could not reach the server.',
|
|
2749
|
+
'The request did not arrive: the server has stopped, or this device cannot reach it. ' +
|
|
2750
|
+
'Re-open the page from the session that produced it to try again.',
|
|
2751
|
+
)
|
|
2752
|
+
})
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
/* ==========================================================================
|
|
2756
|
+
* The command list — a verdict bar for a page that cannot deliver one
|
|
2757
|
+
*
|
|
2758
|
+
* A `file://` page has no server and no store, so its buttons never sent
|
|
2759
|
+
* anything: they built a JSON blob and put it on the clipboard to paste. That
|
|
2760
|
+
* is still the only way a pass carrying MARKS can travel, and it stays. What
|
|
2761
|
+
* changes is the common case — a reader who ticked nothing and wrote nothing,
|
|
2762
|
+
* whose whole pass is one word — where a command they run in the terminal
|
|
2763
|
+
* beside them beats a wall of text every time.
|
|
2764
|
+
*
|
|
2765
|
+
* WHICH SHAPE IS LIVE IS DECIDED BY WHAT THE PASS CARRIES, and it is
|
|
2766
|
+
* re-decided on every mark. Tick one accept and the rows stand down, because
|
|
2767
|
+
* a command line cannot carry someone's notes and dropping them silently
|
|
2768
|
+
* would be worse than asking for the paste. Clear it again and they return.
|
|
2769
|
+
* ======================================================================== */
|
|
2770
|
+
|
|
2771
|
+
// Only a `file://` page. A served page posts; a published one writes to its
|
|
2772
|
+
// store. Both can deliver, so both keep their buttons — decided from what the
|
|
2773
|
+
// page IS, exactly as `send` decides.
|
|
2774
|
+
var NO_TRANSPORT = location.protocol === 'file:' && !isArtifact()
|
|
2775
|
+
var cmdList = document.getElementById('cmd-list')
|
|
2776
|
+
var cmdLead = document.getElementById('cmd-lead')
|
|
2777
|
+
var cmdRows = {}
|
|
2778
|
+
|
|
2779
|
+
function buildCmdRows() {
|
|
2780
|
+
if (!NO_TRANSPORT || !cmdList) return
|
|
2781
|
+
// WHERE, not just what. "Run one of these where it is" assumed the reader
|
|
2782
|
+
// knew which terminal counts, and there is usually more than one open. The
|
|
2783
|
+
// answer is specific and worth saying: the session that produced this page
|
|
2784
|
+
// is the one holding the review.
|
|
2785
|
+
cmdLead.textContent =
|
|
2786
|
+
'This page has no way to reach Claude. Copy one of these and paste it into the ' +
|
|
2787
|
+
'Claude Code session that produced this review — that is the one waiting for your verdict.'
|
|
2788
|
+
// THE SAME LIST THE BAR USES. `OFFERED` already answers which verdicts this
|
|
2789
|
+
// render offers — a mid-run page lists Continue and never the committing
|
|
2790
|
+
// pair — so deriving the rows from it is what keeps the two from drifting.
|
|
2791
|
+
OFFERED.forEach(function (verdict) {
|
|
2792
|
+
var row = el('div', 'cmd-row')
|
|
2793
|
+
row.setAttribute('data-verdict', verdict)
|
|
2794
|
+
var label = el('span', 'cmd-label', LABELS[verdict])
|
|
2795
|
+
var cmd = el('code', 'cmd-text', '/spec-reviewed ' + verdict)
|
|
2796
|
+
row.appendChild(label)
|
|
2797
|
+
row.appendChild(cmd)
|
|
2798
|
+
// NEVER A BUTTON THAT CANNOT COPY — the same rule `showCommand` follows,
|
|
2799
|
+
// through the same `canCopy`. A control that does nothing on tap reads as
|
|
2800
|
+
// a broken page; a control that is MISSING where it would have worked
|
|
2801
|
+
// reads as a page that never offered it, which is the failure this shares
|
|
2802
|
+
// its capability check to avoid.
|
|
2803
|
+
if (canCopy()) {
|
|
2804
|
+
var copy = el('button', 'cmd-copy', 'Copy')
|
|
2805
|
+
copy.setAttribute('type', 'button')
|
|
2806
|
+
copy.addEventListener('click', function () {
|
|
2807
|
+
copyText(cmd.textContent, cmd).then(function () {
|
|
2808
|
+
// COPIED IS DECIDED. The reader has the command; what happens next
|
|
2809
|
+
// happens in their terminal, and the page has nothing left to do
|
|
2810
|
+
// but say what they chose — with the command repeated, because a
|
|
2811
|
+
// copy can fail silently and an hour later this box is the only
|
|
2812
|
+
// record of it.
|
|
2813
|
+
markDecided(verdict, cmd.textContent, false)
|
|
2814
|
+
}, function () {
|
|
2815
|
+
copy.textContent = selectNode(cmd) ? 'Copy failed — selected' : 'Copy failed'
|
|
2816
|
+
})
|
|
2817
|
+
})
|
|
2818
|
+
row.appendChild(copy)
|
|
2819
|
+
}
|
|
2820
|
+
cmdList.appendChild(row)
|
|
2821
|
+
cmdRows[verdict] = row
|
|
2822
|
+
})
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
/**
|
|
2826
|
+
* Decide, now, which of the two shapes this page is offering.
|
|
2827
|
+
*
|
|
2828
|
+
* Called from `refresh`, so it re-answers on every mark rather than once at
|
|
2829
|
+
* load: the reader who ticks an accept half-way through has changed what
|
|
2830
|
+
* their pass contains, and the page has to change with them.
|
|
2831
|
+
*/
|
|
2832
|
+
function drawCmdList() {
|
|
2833
|
+
if (!NO_TRANSPORT || !cmdList) return
|
|
2834
|
+
var marked = pending() > 0
|
|
2835
|
+
cmdList.hidden = marked
|
|
2836
|
+
Object.keys(cmdRows).forEach(function (k) { cmdRows[k].hidden = marked })
|
|
2837
|
+
// The bar comes back to carry what a command cannot, and SAYS SO. A reader
|
|
2838
|
+
// whose notes had been dropped would not find out until the review came
|
|
2839
|
+
// back without them.
|
|
2840
|
+
verdictEl.hidden = !marked
|
|
2841
|
+
if (marked) {
|
|
2842
|
+
copyHint.hidden = false
|
|
2843
|
+
copyHint.textContent =
|
|
2844
|
+
'You have marked something up, and a command line cannot carry notes — ' +
|
|
2845
|
+
'use a verdict button below and paste what it copies.'
|
|
2846
|
+
} else if (copyHint.textContent.indexOf('cannot carry notes') !== -1) {
|
|
2847
|
+
copyHint.hidden = true
|
|
2848
|
+
copyHint.textContent = ''
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// How long to keep asking, and how often. A claim is a file-watch waking a
|
|
2853
|
+
// session that is already sitting there, so the answer normally arrives in
|
|
2854
|
+
// well under a second; these are sized for the case where it does not.
|
|
2855
|
+
var PASS_POLL_MS = 900
|
|
2856
|
+
var PASS_POLL_ROUNDS = 8
|
|
2857
|
+
|
|
2858
|
+
/**
|
|
2859
|
+
* Ask the engine what became of the pass we just sent, and end on the answer.
|
|
2860
|
+
*
|
|
2861
|
+
* THE PAGE STOPPED GUESSING. It used to hand over `/spec-reviewed <code>` on
|
|
2862
|
+
* every successful POST, because it could not tell an auto-claimed pass from
|
|
2863
|
+
* one nobody was listening for — so the reader was told to run a command they
|
|
2864
|
+
* almost never needed, in the same words used when running it was the only
|
|
2865
|
+
* thing that would save their review. Both sentences are now earned.
|
|
2866
|
+
*
|
|
2867
|
+
* ONE ANSWER TAKES THE COMMAND AWAY, and it is the positive one: `claimed`,
|
|
2868
|
+
* which the engine reads off a decision log that NAMES this code. Everything
|
|
2869
|
+
* else — `waiting`, `unknown`, a server that never replied, a body that did
|
|
2870
|
+
* not parse, an older daemon with no lookup, the rounds running out — leaves
|
|
2871
|
+
* the command exactly where it is. A command nobody needed to run costs a
|
|
2872
|
+
* glance; a pass nobody claims costs the review.
|
|
2873
|
+
*
|
|
2874
|
+
* WHAT WOULD FOOL A LOOSER VERSION: treating a failed poll as "probably fine".
|
|
2875
|
+
* The failures that make a poll fail are the same ones that make a claim fail
|
|
2876
|
+
* — the machine sleeping, the daemon restarting, the network going — so the
|
|
2877
|
+
* moment this is least able to see is the moment its answer matters most.
|
|
2878
|
+
*/
|
|
2879
|
+
function watchPass(code, verdict) {
|
|
2880
|
+
var rounds = 0
|
|
2881
|
+
// `?pass=` on the page's own path, so the serve token in the URL guards
|
|
2882
|
+
// this exactly as it guards everything else and there is no second address.
|
|
2883
|
+
var url = location.pathname + '?pass=' + encodeURIComponent(code)
|
|
2884
|
+
|
|
2885
|
+
function stop(picked) {
|
|
2886
|
+
// Every exit from the poll comes through here — claimed, out of rounds,
|
|
2887
|
+
// a server that stopped answering — so this is the one place the spinner
|
|
2888
|
+
// can be retired without listing the ways it might end.
|
|
2889
|
+
asking = false
|
|
2890
|
+
markDecided(verdict, '/spec-reviewed ' + code, picked)
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
function ask() {
|
|
2894
|
+
rounds++
|
|
2895
|
+
fetch(url, { method: 'GET', headers: { accept: 'application/json' } }).then(function (res) {
|
|
2896
|
+
if (!res || !res.ok) return again()
|
|
2897
|
+
return res.text().then(function (text) {
|
|
2898
|
+
var state = null
|
|
2899
|
+
try { state = JSON.parse(text).state } catch (e) {}
|
|
2900
|
+
if (state === 'claimed') return stop(true)
|
|
2901
|
+
return again()
|
|
2902
|
+
})
|
|
2903
|
+
}, again)
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
function again() {
|
|
2907
|
+
// Out of rounds is a CANNOT-TELL, not a no. Nobody is obliged to claim a
|
|
2908
|
+
// pass inside seven seconds, and a page that concluded "abandoned" would
|
|
2909
|
+
// be inventing an answer out of its own impatience.
|
|
2910
|
+
if (rounds >= PASS_POLL_ROUNDS) return stop(false)
|
|
2911
|
+
window.setTimeout(ask, PASS_POLL_MS)
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
ask()
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
/**
|
|
2918
|
+
* Is this page running as a published Artifact?
|
|
2919
|
+
*
|
|
2920
|
+
* A CAPABILITY CHECK, not a hostname one. `window.claude.use` is the whole
|
|
2921
|
+
* contract for reaching anything the viewer grants, and it exists only where
|
|
2922
|
+
* a viewer is there to grant it — so its presence is what distinguishes the
|
|
2923
|
+
* published page from the two the engine serves, without this file knowing
|
|
2924
|
+
* anything about where it was published.
|
|
2925
|
+
*/
|
|
2926
|
+
function isArtifact() {
|
|
2927
|
+
return Boolean(window.claude && typeof window.claude.use === 'function')
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
/**
|
|
2931
|
+
* Hand the pass to the artifact's own store.
|
|
2932
|
+
*
|
|
2933
|
+
* THE THIRD TRANSPORT, for the reader no local server can reach: a phone on
|
|
2934
|
+
* mobile data, or anyone away from the LAN the engine serves on. The pass
|
|
2935
|
+
* lands in a document Claude reads back and deletes, so it is consumed
|
|
2936
|
+
* exactly like a claimed one.
|
|
2937
|
+
*
|
|
2938
|
+
* `use` is asked only once the reader has pressed something. Asking at load
|
|
2939
|
+
* would spend a consent prompt on every render, including the ones nobody
|
|
2940
|
+
* ends in a verdict.
|
|
2941
|
+
*/
|
|
2942
|
+
function sendToStore(json, verdict) {
|
|
2943
|
+
copyHint.hidden = false
|
|
2944
|
+
copyHint.textContent = 'Sending…'
|
|
2945
|
+
var landed = false
|
|
2946
|
+
window.claude.use('db').then(function (db) {
|
|
2947
|
+
// `null` means this view cannot run db — not granted, not served, failed
|
|
2948
|
+
// to load, all indistinguishable by design. The clipboard is still there,
|
|
2949
|
+
// so the pass is recoverable rather than lost.
|
|
2950
|
+
if (!db) {
|
|
2951
|
+
showFallback(json, 'Could not reach the store. Copy this, then paste it to Claude.')
|
|
2952
|
+
return
|
|
2953
|
+
}
|
|
2954
|
+
return db
|
|
2955
|
+
.collection('passes')
|
|
2956
|
+
.add({ spec: data.spec, at: new Date().toISOString(), render: data.generatedAt, blob: JSON.parse(json) })
|
|
2957
|
+
.then(function () {
|
|
2958
|
+
landed = true
|
|
2959
|
+
// NO CODE HERE, and none invented. Six digits are the engine's, minted
|
|
2960
|
+
// by the holding area this pass never went through — so the page says
|
|
2961
|
+
// the one thing that is true and actionable.
|
|
2962
|
+
copyHint.textContent = 'Sent. Run this where Claude is:'
|
|
2963
|
+
showCommand('/spec-reviewed')
|
|
2964
|
+
// A PUBLISHED PAGE CANNOT ASK. The pass went into the artifact's own
|
|
2965
|
+
// store, which the engine only reads when someone tells it to, so
|
|
2966
|
+
// nothing here could ever come back `true` — and the command is the
|
|
2967
|
+
// whole ending rather than a fallback within it.
|
|
2968
|
+
markDecided(verdict, '/spec-reviewed', null)
|
|
2969
|
+
})
|
|
2970
|
+
}).then(null, function (err) {
|
|
2971
|
+
if (landed) return
|
|
2972
|
+
showFallback(
|
|
2973
|
+
json,
|
|
2974
|
+
'Could not store the pass' + (err && err.code ? ' (' + err.code + ')' : '') +
|
|
2975
|
+
'. Copy this, then paste it to Claude.',
|
|
2976
|
+
)
|
|
2977
|
+
})
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
function send(verdict) {
|
|
2981
|
+
var json = JSON.stringify(buildBlob(verdict), null, 2)
|
|
2982
|
+
// DECIDED FROM WHAT THE PAGE IS, not from what it can guess. A published
|
|
2983
|
+
// page has a store and no server; a page opened over http was served by the
|
|
2984
|
+
// engine and can hand the pass straight back; a `file://` page has no server
|
|
2985
|
+
// to talk to and never will.
|
|
2986
|
+
//
|
|
2987
|
+
// NOT "try the POST and fall back on failure": a failed POST and an absent
|
|
2988
|
+
// one look identical to the reader, so the fallback would hide a server
|
|
2989
|
+
// that is refusing passes behind a message about copying. The published page
|
|
2990
|
+
// is exactly that trap — its POST would go to claude.ai and fail — which is
|
|
2991
|
+
// why it is answered first, by what the page IS rather than by what failed.
|
|
2992
|
+
if (isArtifact()) {
|
|
2993
|
+
sendToStore(json, verdict)
|
|
2994
|
+
return
|
|
2995
|
+
}
|
|
2996
|
+
if (location.protocol !== 'file:') {
|
|
2997
|
+
post(json, verdict)
|
|
2998
|
+
return
|
|
2999
|
+
}
|
|
3000
|
+
// `file://` is not a secure context everywhere, and the clipboard API is
|
|
3001
|
+
// absent or rejects there — so the textarea is a shipping requirement, not
|
|
3002
|
+
// a nicety. It is the path a local page usually takes.
|
|
3003
|
+
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
|
3004
|
+
showFallback(json, 'Copy this, then paste it to Claude.')
|
|
3005
|
+
return
|
|
3006
|
+
}
|
|
3007
|
+
navigator.clipboard.writeText(json).then(function () {
|
|
3008
|
+
copyOut.hidden = true
|
|
3009
|
+
copyHint.hidden = false
|
|
3010
|
+
copyHint.textContent = 'Copied — paste it to Claude to pick up this review.'
|
|
3011
|
+
}, function () {
|
|
3012
|
+
showFallback(json, 'Could not reach the clipboard. Copy this, then paste it to Claude.')
|
|
3013
|
+
})
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
/**
|
|
3017
|
+
* The surfaces strip: where this review lives, and whether it is also running.
|
|
3018
|
+
*
|
|
3019
|
+
* AN ACTION IS NOT A VERDICT, and the whole strip exists to make that visible.
|
|
3020
|
+
* A verdict is the reader's conclusion, consumed once. These change what is
|
|
3021
|
+
* RUNNING (or which tiers are permitted) and hand the reader straight back to
|
|
3022
|
+
* the same page with the same options — so pressing one must not close the
|
|
3023
|
+
* page into its decided state, and must not clear a gate.
|
|
3024
|
+
*
|
|
3025
|
+
* ENABLE-ONLY for the tiers. Turning `network` off from a page reached over
|
|
3026
|
+
* the network kills the page doing the turning, and the reader gains nothing
|
|
3027
|
+
* they could not get by typing the command — so the engine's `tierAction`
|
|
3028
|
+
* never offers a disable and this renders whatever it is given.
|
|
3029
|
+
*/
|
|
3030
|
+
var ACTION_LABEL = {
|
|
3031
|
+
// NAMES THE COMMIT IT PERFORMS. `live take` refuses a dirty worktree, so
|
|
3032
|
+
// the action commits the phase before taking the instance — and a press
|
|
3033
|
+
// that commits behind a label reading only "put it live" is a side effect
|
|
3034
|
+
// nobody agreed to. The reader has still approved nothing: this is a
|
|
3035
|
+
// precondition of the thing they asked for, not a verdict on the diff.
|
|
3036
|
+
'live-on': '▶ Commit & put it live',
|
|
3037
|
+
'allow-network': '▶ Allow network',
|
|
3038
|
+
'allow-remote': '▶ Allow remote',
|
|
3039
|
+
}
|
|
3040
|
+
var ACTION_CMD = {
|
|
3041
|
+
'live-on': '/spec-live',
|
|
3042
|
+
'allow-network': 'skitterspec spec-env review allow network',
|
|
3043
|
+
'allow-remote': '/spec-remote-review',
|
|
3044
|
+
}
|
|
3045
|
+
var SURFACE_LABEL = { live: 'live', network: 'network', remote: 'remote' }
|
|
3046
|
+
|
|
3047
|
+
function surfaceStateText(row) {
|
|
3048
|
+
if (row.kind === 'live') {
|
|
3049
|
+
if (row.state === 'on') return row.url ? 'on — running at ' + row.url : 'on'
|
|
3050
|
+
if (row.state === 'held') return row.reason || 'held by another spec'
|
|
3051
|
+
return 'off'
|
|
3052
|
+
}
|
|
3053
|
+
if (row.state === 'on') return row.url ? 'on — ' + row.url : 'on'
|
|
3054
|
+
return row.state === 'off' ? 'off' : 'unavailable'
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
function buildSurfaces() {
|
|
3058
|
+
var section = document.getElementById('surfaces')
|
|
3059
|
+
var host = document.getElementById('surface-rows')
|
|
3060
|
+
// ABSENT, NOT EMPTY. A render that carried no surfaces gets no strip and no
|
|
3061
|
+
// line explaining the absence — a `--docs` page has no branch to put live,
|
|
3062
|
+
// and that is a healthy page rather than a deprived one.
|
|
3063
|
+
if (!section || !host || !data.surfaces || !data.surfaces.length) return
|
|
3064
|
+
section.hidden = false
|
|
3065
|
+
data.surfaces.forEach(function (row) {
|
|
3066
|
+
var div = document.createElement('div')
|
|
3067
|
+
div.className = 'surface-row'
|
|
3068
|
+
var label = document.createElement('span')
|
|
3069
|
+
label.className = 'surface-label'
|
|
3070
|
+
label.textContent = SURFACE_LABEL[row.kind === 'live' ? 'live' : row.tier] || ''
|
|
3071
|
+
div.appendChild(label)
|
|
3072
|
+
var state = document.createElement('span')
|
|
3073
|
+
state.className = 'surface-state'
|
|
3074
|
+
state.textContent = surfaceStateText(row)
|
|
3075
|
+
div.appendChild(state)
|
|
3076
|
+
if (row.action) {
|
|
3077
|
+
if (NO_TRANSPORT) {
|
|
3078
|
+
// No server and no store, so a button here would build an instruction
|
|
3079
|
+
// with nowhere to go. The command is the honest control — exactly the
|
|
3080
|
+
// trade the verdict bar already makes on a `file://` page.
|
|
3081
|
+
var cmd = document.createElement('code')
|
|
3082
|
+
cmd.className = 'surface-cmd'
|
|
3083
|
+
cmd.textContent = ACTION_CMD[row.action] || ''
|
|
3084
|
+
div.appendChild(cmd)
|
|
3085
|
+
} else {
|
|
3086
|
+
var btn = document.createElement('button')
|
|
3087
|
+
btn.type = 'button'
|
|
3088
|
+
btn.textContent = ACTION_LABEL[row.action] || row.action
|
|
3089
|
+
btn.setAttribute('data-action', row.action)
|
|
3090
|
+
btn.addEventListener('click', function () {
|
|
3091
|
+
if (btn.disabled) return
|
|
3092
|
+
sendAction(row.action, btn)
|
|
3093
|
+
})
|
|
3094
|
+
div.appendChild(btn)
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
// THE COMMAND FOR A DIRECTION THE PAGE DOES NOT OFFER. A live spec shows
|
|
3098
|
+
// no press — handing the instance back to `main` is a workspace decision,
|
|
3099
|
+
// not this review's — so the line names `/spec-live main` instead, and the
|
|
3100
|
+
// reader is never left knowing what they want and not what to type.
|
|
3101
|
+
if (row.command && !row.action) {
|
|
3102
|
+
var cmdOnly = document.createElement('code')
|
|
3103
|
+
cmdOnly.className = 'surface-cmd'
|
|
3104
|
+
cmdOnly.textContent = row.command
|
|
3105
|
+
div.appendChild(cmdOnly)
|
|
3106
|
+
}
|
|
3107
|
+
if (row.note) {
|
|
3108
|
+
var note = document.createElement('span')
|
|
3109
|
+
note.className = 'surface-note'
|
|
3110
|
+
note.textContent = row.note
|
|
3111
|
+
div.appendChild(note)
|
|
3112
|
+
}
|
|
3113
|
+
host.appendChild(div)
|
|
3114
|
+
})
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
/**
|
|
3118
|
+
* Send an action, and leave the page exactly as live as it was.
|
|
3119
|
+
*
|
|
3120
|
+
* IT CARRIES NO MARKS. An action blob is an instruction, not a review pass —
|
|
3121
|
+
* so the accepts and comments stay on the page and travel with the verdict
|
|
3122
|
+
* the reader eventually chooses. Sending them here would merge them early and
|
|
3123
|
+
* then send them again, which is one reader's review counted twice.
|
|
3124
|
+
*
|
|
3125
|
+
* AND IT NEVER CALLS `markDecided`. The reader still owes a verdict; closing
|
|
3126
|
+
* the page over an action would be the record-and-do-nothing ending this
|
|
3127
|
+
* whole contract is against.
|
|
3128
|
+
*/
|
|
3129
|
+
function sendAction(action, btn) {
|
|
3130
|
+
var blob = {
|
|
3131
|
+
version: 1,
|
|
3132
|
+
spec: data.spec,
|
|
3133
|
+
generatedAt: data.generatedAt,
|
|
3134
|
+
accepted: [],
|
|
3135
|
+
unaccepted: [],
|
|
3136
|
+
comments: [],
|
|
3137
|
+
action: action,
|
|
3138
|
+
}
|
|
3139
|
+
btn.disabled = true
|
|
3140
|
+
var was = btn.textContent
|
|
3141
|
+
btn.textContent = 'Sending…'
|
|
3142
|
+
fetch(location.pathname, {
|
|
3143
|
+
method: 'POST',
|
|
3144
|
+
headers: { 'content-type': 'application/json' },
|
|
3145
|
+
body: JSON.stringify(blob),
|
|
3146
|
+
}).then(function (res) {
|
|
3147
|
+
if (!res || !res.ok) {
|
|
3148
|
+
btn.disabled = false
|
|
3149
|
+
btn.textContent = was
|
|
3150
|
+
return
|
|
3151
|
+
}
|
|
3152
|
+
return res.text().then(function (text) {
|
|
3153
|
+
var code = null
|
|
3154
|
+
try { code = JSON.parse(text).code } catch (e) {}
|
|
3155
|
+
btn.textContent = 'Sent'
|
|
3156
|
+
// RELOAD WHEN IT HAS BEEN ACTED ON, not on a timer. The agent re-renders
|
|
3157
|
+
// after the action, so the page the reader wants is the next one — and
|
|
3158
|
+
// `?pass=` already answers whether this exact code was claimed. Running
|
|
3159
|
+
// out of rounds leaves the page alone rather than reloading on a guess.
|
|
3160
|
+
if (code) watchAction(code, btn)
|
|
3161
|
+
})
|
|
3162
|
+
}, function () {
|
|
3163
|
+
btn.disabled = false
|
|
3164
|
+
btn.textContent = was
|
|
3165
|
+
})
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
function watchAction(code, btn) {
|
|
3169
|
+
var rounds = 0
|
|
3170
|
+
var url = location.pathname + '?pass=' + encodeURIComponent(code)
|
|
3171
|
+
function ask() {
|
|
3172
|
+
rounds++
|
|
3173
|
+
fetch(url, { method: 'GET', headers: { accept: 'application/json' } }).then(function (res) {
|
|
3174
|
+
if (!res || !res.ok) return again()
|
|
3175
|
+
return res.text().then(function (text) {
|
|
3176
|
+
var state = null
|
|
3177
|
+
try { state = JSON.parse(text).state } catch (e) {}
|
|
3178
|
+
if (state === 'claimed') return window.location.reload()
|
|
3179
|
+
return again()
|
|
3180
|
+
})
|
|
3181
|
+
}, again)
|
|
3182
|
+
}
|
|
3183
|
+
function again() {
|
|
3184
|
+
// CANNOT TELL, so change nothing. Nobody is obliged to act on an action
|
|
3185
|
+
// inside a few seconds, and a page that reloaded out of impatience would
|
|
3186
|
+
// throw away marks the reader had not sent yet.
|
|
3187
|
+
if (rounds >= PASS_POLL_ROUNDS) {
|
|
3188
|
+
btn.textContent = 'Sent — reload when it is done'
|
|
3189
|
+
return
|
|
3190
|
+
}
|
|
3191
|
+
window.setTimeout(ask, PASS_POLL_MS)
|
|
3192
|
+
}
|
|
3193
|
+
ask()
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
/**
|
|
3197
|
+
* TEXT STILL IN AN EDITOR — the one thing on this page a verdict destroys.
|
|
3198
|
+
*
|
|
3199
|
+
* Typing in the box is not a note; `Add note` is. So a reader who writes three
|
|
3200
|
+
* lines and then presses a verdict sends a pass that does not contain them,
|
|
3201
|
+
* `drawDecided` takes the diff away, and the text goes with it. Nothing said
|
|
3202
|
+
* so, and there is nothing to go back to.
|
|
3203
|
+
*
|
|
3204
|
+
* WARNED, NOT REFUSED, and the distinction is the whole design. Discarding it
|
|
3205
|
+
* is often exactly what the reader means — half a sentence, a note they
|
|
3206
|
+
* thought better of — and this page makes ONE refusal on purpose (an open
|
|
3207
|
+
* note blocks a committing verdict, mirroring `judgeVerdict`) and must not
|
|
3208
|
+
* grow a second one it invented. So the first press says what would be lost
|
|
3209
|
+
* and sends nothing; the same press again sends.
|
|
3210
|
+
*
|
|
3211
|
+
* THE CONFIRMATION IS OF WHAT THE READER WAS SHOWN, which is why the text
|
|
3212
|
+
* itself is remembered and not just the verdict. Otherwise a warning raised
|
|
3213
|
+
* over one note would silently carry away a second one written after it.
|
|
3214
|
+
*/
|
|
3215
|
+
var warned = null
|
|
3216
|
+
|
|
3217
|
+
function unsentEditors() {
|
|
3218
|
+
var out = []
|
|
3219
|
+
Array.prototype.forEach.call(document.querySelectorAll('.note-input'), function (b) {
|
|
3220
|
+
if (String(b.value || '').trim()) out.push(b)
|
|
3221
|
+
})
|
|
3222
|
+
return out
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
function clearWarn() {
|
|
3226
|
+
warned = null
|
|
3227
|
+
if (!verdictWarn) return
|
|
3228
|
+
verdictWarn.hidden = true
|
|
3229
|
+
verdictWarn.textContent = ''
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
function mayLose(verdict) {
|
|
3233
|
+
var open = unsentEditors()
|
|
3234
|
+
// Whitespace is not unfinished work, and neither is an editor opened and
|
|
3235
|
+
// left empty. A page that stopped on those would teach the reader to press
|
|
3236
|
+
// everything twice, which is how a warning stops being read.
|
|
3237
|
+
if (!open.length) { clearWarn(); return true }
|
|
3238
|
+
var text = open.map(function (b) { return b.value }).join('\u0000')
|
|
3239
|
+
if (warned && warned.verdict === verdict && warned.text === text) { clearWarn(); return true }
|
|
3240
|
+
warned = { verdict: verdict, text: text }
|
|
3241
|
+
if (verdictWarn) {
|
|
3242
|
+
verdictWarn.hidden = false
|
|
3243
|
+
verdictWarn.textContent =
|
|
3244
|
+
open.length + (open.length === 1 ? ' note is' : ' notes are') +
|
|
3245
|
+
' still in the box and not added. Press “Add note” to keep ' +
|
|
3246
|
+
(open.length === 1 ? 'it' : 'them') + ', or press “' + (LABELS[verdict] || verdict) +
|
|
3247
|
+
'” again to send without ' + (open.length === 1 ? 'it' : 'them') + '.'
|
|
3248
|
+
}
|
|
3249
|
+
// Take the reader to the first one. The bar is at the foot of the page and
|
|
3250
|
+
// the editor can be a screen away, so a line naming a note they cannot see
|
|
3251
|
+
// is only half an answer.
|
|
3252
|
+
open[0].scrollIntoView()
|
|
3253
|
+
open[0].focus()
|
|
3254
|
+
return false
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
Object.keys(verdictBtns).forEach(function (verdict) {
|
|
3258
|
+
verdictBtns[verdict].addEventListener('click', function () {
|
|
3259
|
+
// A disabled or hidden button does not fire in a browser; the guard is
|
|
3260
|
+
// for every other caller, and it keeps both the block and the button set
|
|
3261
|
+
// facts rather than styles — a verdict this render never offered must not
|
|
3262
|
+
// be reachable by dispatching at it.
|
|
3263
|
+
if (verdictBtns[verdict].disabled || verdictBtns[verdict].hidden) return
|
|
3264
|
+
if (!mayLose(verdict)) return
|
|
3265
|
+
send(verdict)
|
|
3266
|
+
})
|
|
3267
|
+
})
|
|
3268
|
+
|
|
3269
|
+
// Said only when the engine reported one. A page from a render that could not
|
|
3270
|
+
// read its own version says nothing rather than claiming "unknown" — an
|
|
3271
|
+
// absence here is about the lookup, not about the page.
|
|
3272
|
+
if (data.engine) {
|
|
3273
|
+
// `0.0.0` is what the unpublished source package carries, so a page drawn
|
|
3274
|
+
// from a working copy would otherwise claim a version that has never been
|
|
3275
|
+
// released. This line exists to answer "which engine drew this" — and a
|
|
3276
|
+
// string that LOOKS like a version and is not one answers it wrongly, which
|
|
3277
|
+
// is worse than not answering. The field itself is untouched: the serve
|
|
3278
|
+
// daemon compares it to spot a stale renderer, and it must stay a version.
|
|
3279
|
+
document.getElementById('drawn-by').textContent =
|
|
3280
|
+
'rendered by skitterspec ' + (data.engine === '0.0.0' ? '(unreleased build)' : data.engine)
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
restore()
|
|
3284
|
+
buildCmdRows()
|
|
3285
|
+
buildSurfaces()
|
|
3286
|
+
drawContext()
|
|
3287
|
+
drawLog()
|
|
3288
|
+
drawDecided()
|
|
3289
|
+
wireChecks()
|
|
3290
|
+
wireGotos()
|
|
3291
|
+
state.forEach(function (st) {
|
|
3292
|
+
var btn = st.details && st.details.querySelector('.accept')
|
|
3293
|
+
if (btn) btn.setAttribute('aria-pressed', String(st.accept))
|
|
3294
|
+
if (st.rendered) renderFile(st)
|
|
3295
|
+
redrawStrip(st)
|
|
3296
|
+
})
|
|
3297
|
+
refresh()
|
|
3298
|
+
|
|
3299
|
+
// The [data-theme] blocks are unreachable in a local file without this — a
|
|
3300
|
+
// browser only ever supplies prefers-color-scheme.
|
|
3301
|
+
var themeBtn = document.getElementById('theme')
|
|
3302
|
+
themeBtn.addEventListener('click', function () {
|
|
3303
|
+
var root = document.documentElement
|
|
3304
|
+
var now = root.getAttribute('data-theme')
|
|
3305
|
+
var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
3306
|
+
var next = now === 'dark' ? 'light' : now === 'light' ? 'dark' : dark ? 'light' : 'dark'
|
|
3307
|
+
root.setAttribute('data-theme', next)
|
|
3308
|
+
themeBtn.textContent = next === 'dark' ? 'Theme: dark' : 'Theme: light'
|
|
3309
|
+
})
|
|
3310
|
+
|
|
3311
|
+
// --- header --------------------------------------------------------------
|
|
3312
|
+
var titleEl = document.getElementById('title')
|
|
3313
|
+
titleEl.textContent = data.title
|
|
3314
|
+
/**
|
|
3315
|
+
* The tracker ticket, beside the title and linked where there is a url.
|
|
3316
|
+
*
|
|
3317
|
+
* `href` IS SET ONLY FROM A URL THE ENGINE ALREADY VETTED as http(s) — see
|
|
3318
|
+
* `readTicket`. The value originates in a spec file someone edits, and this
|
|
3319
|
+
* page is served over the network, so a `javascript:` url in an href would be
|
|
3320
|
+
* script execution. An id with no usable url still renders; it just does not
|
|
3321
|
+
* become a link, which is the harmless half of the pair.
|
|
3322
|
+
*/
|
|
3323
|
+
;(function () {
|
|
3324
|
+
var tk = data.context && data.context.ticket
|
|
3325
|
+
if (!tk || !tk.id) return
|
|
3326
|
+
var node
|
|
3327
|
+
if (tk.url) {
|
|
3328
|
+
node = document.createElement('a')
|
|
3329
|
+
node.setAttribute('href', tk.url)
|
|
3330
|
+
node.setAttribute('target', '_blank')
|
|
3331
|
+
node.setAttribute('rel', 'noopener noreferrer')
|
|
3332
|
+
} else {
|
|
3333
|
+
node = document.createElement('span')
|
|
3334
|
+
}
|
|
3335
|
+
node.className = 'ticket'
|
|
3336
|
+
node.textContent = tk.id
|
|
3337
|
+
titleEl.appendChild(document.createTextNode(' '))
|
|
3338
|
+
titleEl.appendChild(node)
|
|
3339
|
+
})()
|
|
3340
|
+
|
|
3341
|
+
/**
|
|
3342
|
+
* An ISO timestamp is a machine's format. The header showed
|
|
3343
|
+
* `2026-09-16T13:53:58.370Z` to a person deciding whether a page was stale,
|
|
3344
|
+
* which is the one question it is there to answer and the hardest way to ask
|
|
3345
|
+
* it. Rendered in the READER's locale and zone, because that is the clock
|
|
3346
|
+
* they are comparing against; the raw value stays as the `title` so nothing
|
|
3347
|
+
* precise is lost, and it survives a browser that throws on either call.
|
|
3348
|
+
*/
|
|
3349
|
+
function whenRendered(iso) {
|
|
3350
|
+
try {
|
|
3351
|
+
var d = new Date(iso)
|
|
3352
|
+
if (isNaN(d.getTime())) return iso
|
|
3353
|
+
return d.toLocaleString(undefined, {
|
|
3354
|
+
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
3355
|
+
})
|
|
3356
|
+
} catch (e) {
|
|
3357
|
+
return iso
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
var t = data.totals
|
|
3361
|
+
var sub = document.getElementById('sub')
|
|
3362
|
+
function part(text, cls) { sub.appendChild(el('span', cls, text)) }
|
|
3363
|
+
function sep() { sub.appendChild(el('span', 'sep', '·')) }
|
|
3364
|
+
part(data.branch)
|
|
3365
|
+
sep()
|
|
3366
|
+
part(data.mode === 'branch'
|
|
3367
|
+
? 'everything since ' + data.base + (data.fellBack ? ' (working tree clean)' : '')
|
|
3368
|
+
: data.mode === 'docs'
|
|
3369
|
+
? "the spec's own documents"
|
|
3370
|
+
: 'uncommitted work')
|
|
3371
|
+
sep()
|
|
3372
|
+
part(t.files + ' file' + (t.files === 1 ? '' : 's'))
|
|
3373
|
+
sep()
|
|
3374
|
+
part('+' + t.additions, 'a')
|
|
3375
|
+
part(' ')
|
|
3376
|
+
part('-' + t.deletions, 'd')
|
|
3377
|
+
sep()
|
|
3378
|
+
var when = el('span', null, whenRendered(data.generatedAt))
|
|
3379
|
+
when.setAttribute('title', data.generatedAt)
|
|
3380
|
+
sub.appendChild(when)
|
|
3381
|
+
})()
|
|
3382
|
+
</script>
|
|
3383
|
+
</body>
|
|
3384
|
+
</html>
|