pi-midcompact 0.3.0 → 0.4.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/README.md +106 -40
- package/README.zh-CN.md +227 -0
- package/figures/review-tui.png +0 -0
- package/figures/review-webui.png +0 -0
- package/package.json +2 -1
- package/skills/midcompact/SKILL.md +1 -1
- package/src/index.ts +56 -28
- package/src/review-ui.ts +190 -147
- package/src/review-webui.html +846 -0
- package/src/review-webui.ts +212 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="dark light">
|
|
7
|
+
<title>Midcompact Review</title>
|
|
8
|
+
<script>
|
|
9
|
+
// Apply theme before first paint to avoid flash.
|
|
10
|
+
try {
|
|
11
|
+
var t = localStorage.getItem('midcompact-theme');
|
|
12
|
+
if (!t) t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
|
13
|
+
document.documentElement.setAttribute('data-theme', t);
|
|
14
|
+
} catch (e) {}
|
|
15
|
+
</script>
|
|
16
|
+
<style>
|
|
17
|
+
:root {
|
|
18
|
+
--bg: #0b0e14;
|
|
19
|
+
--panel: #11151f;
|
|
20
|
+
--panel-2: #161b28;
|
|
21
|
+
--elev: #1d2333;
|
|
22
|
+
--line: #232a3d;
|
|
23
|
+
--line-soft: #1a2032;
|
|
24
|
+
--fg: #e6e9f2;
|
|
25
|
+
--fg-dim: #8f9ab4;
|
|
26
|
+
--fg-mute: #5f6a84;
|
|
27
|
+
--accent: #7aa2f7;
|
|
28
|
+
--accent-2: #bb9af7;
|
|
29
|
+
--keep: #9ece6a;
|
|
30
|
+
--range: #7dcfff;
|
|
31
|
+
--warn: #e0af68;
|
|
32
|
+
--danger: #f7768e;
|
|
33
|
+
--mark: rgba(224,175,104,.28);
|
|
34
|
+
--shadow: 0 10px 34px rgba(0,0,0,.45);
|
|
35
|
+
--ring: rgba(122,162,247,.45);
|
|
36
|
+
--r: 10px;
|
|
37
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
38
|
+
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", sans-serif;
|
|
39
|
+
}
|
|
40
|
+
[data-theme="light"] {
|
|
41
|
+
--bg: #f3f5fa;
|
|
42
|
+
--panel: #ffffff;
|
|
43
|
+
--panel-2: #f7f9fc;
|
|
44
|
+
--elev: #eaeef6;
|
|
45
|
+
--line: #dbe1ee;
|
|
46
|
+
--line-soft: #e8ecf5;
|
|
47
|
+
--fg: #1a1f2b;
|
|
48
|
+
--fg-dim: #5b6479;
|
|
49
|
+
--fg-mute: #8a93a8;
|
|
50
|
+
--accent: #3b6ef6;
|
|
51
|
+
--accent-2: #7c3aed;
|
|
52
|
+
--keep: #17914f;
|
|
53
|
+
--range: #0d84c4;
|
|
54
|
+
--warn: #b45309;
|
|
55
|
+
--danger: #d43b52;
|
|
56
|
+
--mark: rgba(224,175,104,.42);
|
|
57
|
+
--shadow: 0 10px 30px rgba(31,41,55,.12);
|
|
58
|
+
--ring: rgba(59,110,246,.35);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
* { box-sizing: border-box; }
|
|
62
|
+
html, body { height: 100%; margin: 0; }
|
|
63
|
+
body {
|
|
64
|
+
background: var(--bg); color: var(--fg);
|
|
65
|
+
font: 13px/1.55 var(--sans);
|
|
66
|
+
display: flex; flex-direction: column;
|
|
67
|
+
-webkit-font-smoothing: antialiased;
|
|
68
|
+
}
|
|
69
|
+
button, input, textarea { font: inherit; color: inherit; }
|
|
70
|
+
:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
|
|
71
|
+
::selection { background: var(--ring); }
|
|
72
|
+
.mono { font-family: var(--mono); }
|
|
73
|
+
|
|
74
|
+
/* ---------- header ---------- */
|
|
75
|
+
header {
|
|
76
|
+
background: var(--panel); border-bottom: 1px solid var(--line);
|
|
77
|
+
padding: 12px 18px 14px; flex: 0 0 auto;
|
|
78
|
+
}
|
|
79
|
+
.bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
80
|
+
.brand { font-size: 15px; font-weight: 650; letter-spacing: -.01em; margin: 0; }
|
|
81
|
+
.brand b { color: var(--accent); font-weight: 750; }
|
|
82
|
+
.chip {
|
|
83
|
+
background: var(--panel-2); border: 1px solid var(--line); color: var(--fg-dim);
|
|
84
|
+
padding: 3px 9px; border-radius: 99px; font-size: 11px; font-family: var(--mono); white-space: nowrap;
|
|
85
|
+
}
|
|
86
|
+
.chip.dirty { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
|
|
87
|
+
.spacer { flex: 1 1 auto; }
|
|
88
|
+
|
|
89
|
+
.btn {
|
|
90
|
+
border: 1px solid var(--line); background: var(--panel-2); color: var(--fg);
|
|
91
|
+
padding: 6px 12px; border-radius: 8px; cursor: pointer;
|
|
92
|
+
transition: background .13s, border-color .13s, transform .06s, opacity .13s;
|
|
93
|
+
display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
|
|
94
|
+
}
|
|
95
|
+
.btn:hover { background: var(--elev); border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); }
|
|
96
|
+
.btn:active { transform: translateY(1px); }
|
|
97
|
+
.btn[disabled] { opacity: .42; cursor: not-allowed; transform: none; }
|
|
98
|
+
.btn-primary { background: var(--accent); border-color: var(--accent); color: #0b0e14; font-weight: 600; }
|
|
99
|
+
[data-theme="light"] .btn-primary { color: #fff; }
|
|
100
|
+
.btn-primary:hover { filter: brightness(1.07); background: var(--accent); }
|
|
101
|
+
.btn-danger { background: transparent; border-color: color-mix(in srgb, var(--danger) 55%, var(--line)); color: var(--danger); }
|
|
102
|
+
.btn-danger:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); border-color: var(--danger); }
|
|
103
|
+
.btn-danger.armed { background: var(--danger); border-color: var(--danger); color: #fff; }
|
|
104
|
+
.btn-sm { padding: 4px 9px; font-size: 12px; border-radius: 7px; }
|
|
105
|
+
.btn-icon { padding: 6px 9px; }
|
|
106
|
+
|
|
107
|
+
.metrics { display: grid; grid-template-columns: 2.1fr 1fr 1fr; gap: 10px; margin-top: 12px; }
|
|
108
|
+
.metric {
|
|
109
|
+
background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r); padding: 9px 12px 10px;
|
|
110
|
+
}
|
|
111
|
+
.metric > label {
|
|
112
|
+
display: block; font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--fg-mute);
|
|
113
|
+
}
|
|
114
|
+
.metric .val { font-family: var(--mono); font-size: 14px; margin-top: 3px; }
|
|
115
|
+
.metric .val small { color: var(--fg-dim); font-size: 11px; }
|
|
116
|
+
.track {
|
|
117
|
+
position: relative; height: 7px; background: var(--elev); border-radius: 99px; margin-top: 8px; overflow: hidden;
|
|
118
|
+
}
|
|
119
|
+
.track i { position: absolute; inset: 0 auto 0 0; border-radius: 99px; display: block; }
|
|
120
|
+
.track .anchor { background: color-mix(in srgb, var(--warn) 55%, transparent); }
|
|
121
|
+
.track .proj { background: var(--accent); }
|
|
122
|
+
.legend { display: flex; gap: 12px; margin-top: 6px; font-size: 10.5px; color: var(--fg-dim); flex-wrap: wrap; }
|
|
123
|
+
.legend b { font-weight: 500; font-family: var(--mono); color: var(--fg); }
|
|
124
|
+
.dotm { width: 7px; height: 7px; border-radius: 99px; display: inline-block; margin-right: 4px; vertical-align: baseline; }
|
|
125
|
+
|
|
126
|
+
/* ---------- layout ---------- */
|
|
127
|
+
main {
|
|
128
|
+
flex: 1 1 auto; min-height: 0;
|
|
129
|
+
display: grid; gap: 1px; background: var(--line);
|
|
130
|
+
grid-template-columns: 276px minmax(0, 1fr) 388px;
|
|
131
|
+
grid-template-areas: "ranges timeline editor";
|
|
132
|
+
}
|
|
133
|
+
.pane { background: var(--bg); min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
134
|
+
.pane.ranges { grid-area: ranges; background: var(--panel); }
|
|
135
|
+
.pane.timeline { grid-area: timeline; }
|
|
136
|
+
.pane.editor { grid-area: editor; background: var(--panel); }
|
|
137
|
+
.pane-head {
|
|
138
|
+
flex: 0 0 auto; padding: 10px 12px; border-bottom: 1px solid var(--line-soft);
|
|
139
|
+
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
140
|
+
}
|
|
141
|
+
.pane-title { font-size: 11px; letter-spacing: .07em; text-transform: uppercase; color: var(--fg-mute); }
|
|
142
|
+
.scroll { flex: 1 1 auto; overflow: auto; min-height: 0; scrollbar-width: thin; }
|
|
143
|
+
.scroll::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
144
|
+
.scroll::-webkit-scrollbar-thumb { background: var(--elev); border-radius: 99px; border: 3px solid transparent; background-clip: content-box; }
|
|
145
|
+
.scroll::-webkit-scrollbar-thumb:hover { background: var(--line); background-clip: content-box; }
|
|
146
|
+
|
|
147
|
+
.search { position: relative; flex: 1 1 140px; }
|
|
148
|
+
.search input {
|
|
149
|
+
width: 100%; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
|
|
150
|
+
padding: 6px 26px 6px 28px; font-size: 12px;
|
|
151
|
+
}
|
|
152
|
+
.search input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
|
|
153
|
+
.search .ico { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); color: var(--fg-mute); font-size: 12px; }
|
|
154
|
+
.search .clr {
|
|
155
|
+
position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
|
|
156
|
+
background: none; border: 0; color: var(--fg-mute); cursor: pointer; padding: 2px 5px; border-radius: 6px;
|
|
157
|
+
}
|
|
158
|
+
.search .clr:hover { color: var(--fg); background: var(--elev); }
|
|
159
|
+
|
|
160
|
+
.seg { display: inline-flex; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 2px; }
|
|
161
|
+
.seg button {
|
|
162
|
+
border: 0; background: none; color: var(--fg-dim); padding: 3px 9px; border-radius: 6px; cursor: pointer; font-size: 11.5px;
|
|
163
|
+
}
|
|
164
|
+
.seg button[aria-pressed="true"] { background: var(--elev); color: var(--fg); }
|
|
165
|
+
|
|
166
|
+
/* ---------- range list ---------- */
|
|
167
|
+
.rlist { padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
|
168
|
+
.ritem {
|
|
169
|
+
width: 100%; text-align: left; background: var(--panel-2); border: 1px solid var(--line);
|
|
170
|
+
border-left: 3px solid var(--line); border-radius: 9px; padding: 8px 10px; cursor: pointer;
|
|
171
|
+
display: grid; gap: 3px; transition: background .13s, border-color .13s;
|
|
172
|
+
}
|
|
173
|
+
.ritem:hover { background: var(--elev); }
|
|
174
|
+
.ritem[aria-selected="true"] { border-color: var(--range); border-left-color: var(--range); background: color-mix(in srgb, var(--range) 12%, var(--panel-2)); }
|
|
175
|
+
.ritem .r1 { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 12px; }
|
|
176
|
+
.ritem .rid { color: var(--range); font-weight: 700; }
|
|
177
|
+
.ritem .refs { color: var(--fg-mute); font-size: 11px; }
|
|
178
|
+
.ritem .save { margin-left: auto; color: var(--keep); font-size: 11px; }
|
|
179
|
+
.ritem .rsum {
|
|
180
|
+
color: var(--fg-dim); font-size: 11.5px; line-height: 1.45;
|
|
181
|
+
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
|
182
|
+
}
|
|
183
|
+
.ritem .meta { color: var(--fg-mute); font-size: 10.5px; font-family: var(--mono); }
|
|
184
|
+
.udot { width: 6px; height: 6px; border-radius: 99px; background: var(--warn); display: inline-block; }
|
|
185
|
+
|
|
186
|
+
/* ---------- timeline ---------- */
|
|
187
|
+
.tl { padding: 10px 12px 40px; display: flex; flex-direction: column; gap: 8px; }
|
|
188
|
+
.group { border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; background: var(--panel); }
|
|
189
|
+
.group.keepg { background: transparent; border-color: var(--line-soft); }
|
|
190
|
+
.ghead {
|
|
191
|
+
position: sticky; top: 0; z-index: 2; width: 100%; text-align: left; cursor: pointer;
|
|
192
|
+
display: grid; grid-template-columns: 18px auto 1fr auto; align-items: center; gap: 8px;
|
|
193
|
+
padding: 8px 10px; background: var(--panel-2); border: 0; border-bottom: 1px solid var(--line-soft);
|
|
194
|
+
}
|
|
195
|
+
.ghead:hover { background: var(--elev); }
|
|
196
|
+
.group.sel { border-color: var(--range); box-shadow: 0 0 0 1px color-mix(in srgb, var(--range) 40%, transparent); }
|
|
197
|
+
.group.sel .ghead { background: color-mix(in srgb, var(--range) 13%, var(--panel-2)); }
|
|
198
|
+
.chev { color: var(--fg-mute); transition: transform .15s; font-size: 11px; text-align: center; }
|
|
199
|
+
.group.open .chev { transform: rotate(90deg); }
|
|
200
|
+
.ghead .gid { font-family: var(--mono); font-weight: 700; color: var(--range); font-size: 12px; }
|
|
201
|
+
.group.keepg .ghead .gid { color: var(--keep); }
|
|
202
|
+
.ghead .gsum { color: var(--fg-dim); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
203
|
+
.ghead .gstat { font-family: var(--mono); font-size: 11px; color: var(--fg-mute); white-space: nowrap; }
|
|
204
|
+
.ghead .gstat em { color: var(--keep); font-style: normal; }
|
|
205
|
+
.gbody { display: none; padding: 4px; }
|
|
206
|
+
.group.open .gbody { display: block; }
|
|
207
|
+
|
|
208
|
+
.atom {
|
|
209
|
+
display: grid; grid-template-columns: 74px 84px 58px minmax(0, 1fr); gap: 10px; align-items: start;
|
|
210
|
+
padding: 7px 8px; border-radius: 8px; border: 1px solid transparent;
|
|
211
|
+
}
|
|
212
|
+
.atom + .atom { border-top: 1px solid var(--line-soft); border-radius: 0; }
|
|
213
|
+
.atom:hover { background: var(--panel-2); }
|
|
214
|
+
.atom .aref { font-family: var(--mono); font-size: 11.5px; color: var(--accent-2); }
|
|
215
|
+
.atom .akind { font-family: var(--mono); font-size: 10.5px; color: var(--fg-dim); overflow: hidden; text-overflow: ellipsis; }
|
|
216
|
+
.atom .atok { font-family: var(--mono); font-size: 11px; color: var(--fg-mute); text-align: right; }
|
|
217
|
+
.atom .abody {
|
|
218
|
+
font-family: var(--mono); font-size: 11.5px; line-height: 1.5; white-space: pre-wrap; word-break: break-word;
|
|
219
|
+
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; cursor: zoom-in;
|
|
220
|
+
}
|
|
221
|
+
.atom.open .abody { -webkit-line-clamp: unset; cursor: zoom-out; }
|
|
222
|
+
.atom .tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
|
|
223
|
+
.tag {
|
|
224
|
+
font-family: var(--mono); font-size: 10px; padding: 0 5px; border-radius: 5px;
|
|
225
|
+
background: var(--elev); color: var(--fg-dim); border: 1px solid var(--line-soft);
|
|
226
|
+
}
|
|
227
|
+
.tag.lock { color: var(--warn); }
|
|
228
|
+
mark { background: var(--mark); color: inherit; border-radius: 3px; padding: 0 1px; }
|
|
229
|
+
|
|
230
|
+
/* ---------- editor ---------- */
|
|
231
|
+
.epad { padding: 14px; display: flex; flex-direction: column; gap: 14px; }
|
|
232
|
+
.etitle { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
|
233
|
+
.etitle .eid { font-family: var(--mono); font-size: 15px; font-weight: 700; color: var(--range); }
|
|
234
|
+
.etitle .erefs { font-family: var(--mono); font-size: 11.5px; color: var(--fg-mute); }
|
|
235
|
+
.estats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
236
|
+
.estat { background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; }
|
|
237
|
+
.estat label { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-mute); }
|
|
238
|
+
.estat span { font-family: var(--mono); font-size: 12.5px; }
|
|
239
|
+
.field label {
|
|
240
|
+
display: flex; align-items: center; gap: 8px; font-size: 10.5px; text-transform: uppercase;
|
|
241
|
+
letter-spacing: .06em; color: var(--fg-mute); margin-bottom: 5px;
|
|
242
|
+
}
|
|
243
|
+
.field label .count { margin-left: auto; text-transform: none; letter-spacing: 0; font-family: var(--mono); }
|
|
244
|
+
.field input, .field textarea {
|
|
245
|
+
width: 100%; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
|
|
246
|
+
padding: 8px 10px; resize: vertical; font-family: var(--mono); font-size: 12px; line-height: 1.6;
|
|
247
|
+
}
|
|
248
|
+
.field textarea { min-height: 200px; }
|
|
249
|
+
.field input:focus, .field textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
|
|
250
|
+
.field.invalid input, .field.invalid textarea { border-color: var(--danger); }
|
|
251
|
+
.erow { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
252
|
+
.hint { color: var(--fg-mute); font-size: 11px; }
|
|
253
|
+
kbd {
|
|
254
|
+
font-family: var(--mono); font-size: 10.5px; background: var(--elev); border: 1px solid var(--line);
|
|
255
|
+
border-bottom-width: 2px; border-radius: 5px; padding: 0 4px; color: var(--fg-dim);
|
|
256
|
+
}
|
|
257
|
+
.empty { color: var(--fg-mute); text-align: center; padding: 44px 18px; font-size: 12.5px; line-height: 1.7; }
|
|
258
|
+
|
|
259
|
+
/* ---------- overlays ---------- */
|
|
260
|
+
.toast {
|
|
261
|
+
position: fixed; bottom: 18px; left: 50%; transform: translate(-50%, 12px);
|
|
262
|
+
background: var(--panel-2); border: 1px solid var(--line); color: var(--fg);
|
|
263
|
+
padding: 9px 14px; border-radius: 9px; box-shadow: var(--shadow); font-size: 12.5px;
|
|
264
|
+
opacity: 0; transition: opacity .18s, transform .18s; pointer-events: none; z-index: 50; max-width: 70vw;
|
|
265
|
+
}
|
|
266
|
+
.toast.show { opacity: 1; transform: translate(-50%, 0); }
|
|
267
|
+
.toast.err { border-color: var(--danger); color: var(--danger); }
|
|
268
|
+
.toast.ok { border-color: color-mix(in srgb, var(--keep) 60%, var(--line)); }
|
|
269
|
+
.help {
|
|
270
|
+
position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; place-items: center; z-index: 60; padding: 20px;
|
|
271
|
+
}
|
|
272
|
+
.help.show { display: grid; }
|
|
273
|
+
.help-card {
|
|
274
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow);
|
|
275
|
+
padding: 18px 20px; min-width: 300px; max-width: 420px;
|
|
276
|
+
}
|
|
277
|
+
.help-card h3 { margin: 0 0 12px; font-size: 13px; }
|
|
278
|
+
.help-card dl { display: grid; grid-template-columns: auto 1fr; gap: 8px 14px; margin: 0; font-size: 12px; }
|
|
279
|
+
.help-card dt { text-align: right; }
|
|
280
|
+
.help-card dd { margin: 0; color: var(--fg-dim); }
|
|
281
|
+
.closed { display: grid; place-items: center; height: 100%; color: var(--fg-dim); font-size: 13px; text-align: center; padding: 30px; }
|
|
282
|
+
|
|
283
|
+
@media (max-width: 1220px) {
|
|
284
|
+
main {
|
|
285
|
+
grid-template-columns: 250px minmax(0, 1fr);
|
|
286
|
+
grid-template-rows: minmax(0, 1fr) minmax(180px, 42vh);
|
|
287
|
+
grid-template-areas: "ranges timeline" "editor editor";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
@media (max-width: 860px) {
|
|
291
|
+
.metrics { grid-template-columns: 1fr; }
|
|
292
|
+
main {
|
|
293
|
+
grid-template-columns: minmax(0, 1fr);
|
|
294
|
+
grid-template-rows: minmax(120px, 24vh) minmax(220px, 1fr) auto;
|
|
295
|
+
grid-template-areas: "ranges" "timeline" "editor";
|
|
296
|
+
}
|
|
297
|
+
.pane.editor { max-height: 60vh; }
|
|
298
|
+
.atom { grid-template-columns: 70px 1fr auto; }
|
|
299
|
+
.atom .abody { grid-column: 1 / -1; }
|
|
300
|
+
}
|
|
301
|
+
</style>
|
|
302
|
+
</head>
|
|
303
|
+
<body>
|
|
304
|
+
<header>
|
|
305
|
+
<div class="bar">
|
|
306
|
+
<h1 class="brand"><b>Midcompact</b> Review</h1>
|
|
307
|
+
<span class="chip" id="draft-badge"></span>
|
|
308
|
+
<span class="chip dirty" id="dirty-badge" hidden></span>
|
|
309
|
+
<div class="spacer"></div>
|
|
310
|
+
<button class="btn btn-sm" id="save-all" hidden>Save all</button>
|
|
311
|
+
<button class="btn btn-sm btn-icon" id="refresh" title="Reload state (r)" aria-label="Reload state">⟳</button>
|
|
312
|
+
<button class="btn btn-sm btn-icon" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme"></button>
|
|
313
|
+
<button class="btn btn-sm btn-icon" id="help-btn" title="Shortcuts (?)" aria-label="Shortcuts">?</button>
|
|
314
|
+
<button class="btn btn-sm btn-primary" id="close">Close & return</button>
|
|
315
|
+
</div>
|
|
316
|
+
<div class="metrics" id="metrics"></div>
|
|
317
|
+
</header>
|
|
318
|
+
|
|
319
|
+
<main>
|
|
320
|
+
<section class="pane ranges" aria-label="Compression ranges">
|
|
321
|
+
<div class="pane-head">
|
|
322
|
+
<div class="search">
|
|
323
|
+
<span class="ico">⌕</span>
|
|
324
|
+
<input id="q" type="search" placeholder="Filter ranges & atoms (/)" autocomplete="off" spellcheck="false">
|
|
325
|
+
<button class="clr" id="q-clear" hidden aria-label="Clear filter">✕</button>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
<div class="scroll"><div class="rlist" id="range-list" role="listbox" aria-label="Ranges"></div></div>
|
|
329
|
+
</section>
|
|
330
|
+
|
|
331
|
+
<section class="pane timeline" aria-label="Anchor timeline">
|
|
332
|
+
<div class="pane-head">
|
|
333
|
+
<span class="pane-title">Timeline</span>
|
|
334
|
+
<span class="chip" id="tl-stat"></span>
|
|
335
|
+
<div class="spacer"></div>
|
|
336
|
+
<div class="seg" role="group" aria-label="Policy filter">
|
|
337
|
+
<button data-filter="all" aria-pressed="true">All</button>
|
|
338
|
+
<button data-filter="range" aria-pressed="false">Compressed</button>
|
|
339
|
+
<button data-filter="keep" aria-pressed="false">Kept</button>
|
|
340
|
+
</div>
|
|
341
|
+
<button class="btn btn-sm" id="toggle-all">Expand all</button>
|
|
342
|
+
</div>
|
|
343
|
+
<div class="scroll" id="tl-scroll"><div class="tl" id="timeline"></div></div>
|
|
344
|
+
</section>
|
|
345
|
+
|
|
346
|
+
<aside class="pane editor" aria-label="Range editor">
|
|
347
|
+
<div class="scroll"><div id="editor"></div></div>
|
|
348
|
+
</aside>
|
|
349
|
+
</main>
|
|
350
|
+
|
|
351
|
+
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
352
|
+
<div class="help" id="help" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">
|
|
353
|
+
<div class="help-card">
|
|
354
|
+
<h3>Keyboard shortcuts</h3>
|
|
355
|
+
<dl>
|
|
356
|
+
<dt><kbd>j</kbd> / <kbd>k</kbd></dt><dd>Next / previous range</dd>
|
|
357
|
+
<dt><kbd>/</kbd></dt><dd>Focus filter</dd>
|
|
358
|
+
<dt><kbd>e</kbd></dt><dd>Focus summary editor</dd>
|
|
359
|
+
<dt><kbd>⌘/Ctrl</kbd>+<kbd>S</kbd></dt><dd>Save current range</dd>
|
|
360
|
+
<dt><kbd>r</kbd></dt><dd>Reload state from server</dd>
|
|
361
|
+
<dt><kbd>x</kbd></dt><dd>Expand / collapse selected group</dd>
|
|
362
|
+
<dt><kbd>Esc</kbd></dt><dd>Blur field / close dialog</dd>
|
|
363
|
+
</dl>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
<script id="state" type="application/json"><!--MIDCOMPACT_STATE--></script>
|
|
368
|
+
<script type="module">
|
|
369
|
+
const EMPTY = { atoms: [], draft: { revision: 0, ranges: [] }, telemetry: {} };
|
|
370
|
+
let state = (() => {
|
|
371
|
+
try {
|
|
372
|
+
const s = JSON.parse(document.getElementById('state').textContent);
|
|
373
|
+
return { atoms: s.atoms ?? [], draft: { revision: s.draft?.revision ?? 0, ranges: s.draft?.ranges ?? [] }, telemetry: s.telemetry ?? {} };
|
|
374
|
+
} catch { return EMPTY; }
|
|
375
|
+
})();
|
|
376
|
+
|
|
377
|
+
let selectedId = state.draft.ranges[0]?.id ?? null;
|
|
378
|
+
let query = '';
|
|
379
|
+
let policy = 'all';
|
|
380
|
+
const edits = new Map(); // rangeId -> { topic, summary }
|
|
381
|
+
const collapsed = new Set(); // group keys currently collapsed
|
|
382
|
+
const openAtoms = new Set(); // atom refs expanded to full preview
|
|
383
|
+
let lastScrolled = null;
|
|
384
|
+
let removeArmed = null;
|
|
385
|
+
let closeArmed = false;
|
|
386
|
+
|
|
387
|
+
state.draft.ranges.forEach(r => { if (r.id !== selectedId) collapsed.add(r.id); });
|
|
388
|
+
|
|
389
|
+
/* ---------- helpers ---------- */
|
|
390
|
+
const $ = id => document.getElementById(id);
|
|
391
|
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
392
|
+
const fmt = n => n == null || Number.isNaN(n) ? '—'
|
|
393
|
+
: Math.abs(n) >= 1000 ? (n / 1000).toFixed(Math.abs(n) % 1000 ? 1 : 0) + 'k' : String(Math.round(n));
|
|
394
|
+
const pct = n => n == null || Number.isNaN(n) ? '—' : n.toFixed(n % 1 ? 1 : 0) + '%';
|
|
395
|
+
const clamp = n => Math.max(0, Math.min(100, n));
|
|
396
|
+
const rangeById = id => state.draft.ranges.find(r => r.id === id);
|
|
397
|
+
const saveOf = r => Math.max(0, (r.originalApproxTokens ?? 0) - (r.compressedApproxTokens ?? 0));
|
|
398
|
+
|
|
399
|
+
function hl(text, q) {
|
|
400
|
+
const out = esc(text);
|
|
401
|
+
if (!q) return out;
|
|
402
|
+
const re = new RegExp(esc(q).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
403
|
+
return out.replace(re, m => '<mark>' + m + '</mark>');
|
|
404
|
+
}
|
|
405
|
+
function atomText(a) {
|
|
406
|
+
return [a.ref, a.kind, a.preview, ...(a.toolNames ?? []), ...(a.roles ?? [])].join(' ').toLowerCase();
|
|
407
|
+
}
|
|
408
|
+
function rangeText(r) {
|
|
409
|
+
return [r.id, r.startRef, r.endRef, r.topic ?? '', r.summary].join(' ').toLowerCase();
|
|
410
|
+
}
|
|
411
|
+
function draftOf(r) {
|
|
412
|
+
const e = edits.get(r.id);
|
|
413
|
+
return { topic: e ? e.topic : (r.topic ?? ''), summary: e ? e.summary : r.summary };
|
|
414
|
+
}
|
|
415
|
+
function isDirty(r) {
|
|
416
|
+
const e = edits.get(r.id);
|
|
417
|
+
return !!e && (e.topic !== (r.topic ?? '') || e.summary !== r.summary);
|
|
418
|
+
}
|
|
419
|
+
const dirtyIds = () => state.draft.ranges.filter(isDirty).map(r => r.id);
|
|
420
|
+
|
|
421
|
+
/* ---------- render ---------- */
|
|
422
|
+
function render() {
|
|
423
|
+
const n = state.draft.ranges.length;
|
|
424
|
+
$('draft-badge').textContent = `draft v${state.draft.revision} · ${n} range${n === 1 ? '' : 's'}`;
|
|
425
|
+
const d = dirtyIds();
|
|
426
|
+
$('dirty-badge').hidden = d.length === 0;
|
|
427
|
+
$('dirty-badge').textContent = `${d.length} unsaved`;
|
|
428
|
+
$('save-all').hidden = d.length < 2;
|
|
429
|
+
$('save-all').textContent = `Save all (${d.length})`;
|
|
430
|
+
renderMetrics();
|
|
431
|
+
renderRanges();
|
|
432
|
+
renderTimeline();
|
|
433
|
+
renderEditor();
|
|
434
|
+
if (selectedId && selectedId !== lastScrolled) {
|
|
435
|
+
lastScrolled = selectedId;
|
|
436
|
+
document.querySelector(`[data-group-key="${cssEsc(selectedId)}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
437
|
+
document.querySelector(`[data-range="${cssEsc(selectedId)}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const cssEsc = s => (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&');
|
|
441
|
+
|
|
442
|
+
function renderMetrics() {
|
|
443
|
+
const t = state.telemetry ?? {};
|
|
444
|
+
const cw = t.contextWindow;
|
|
445
|
+
const aw = cw && t.anchorTokens != null ? clamp(t.anchorTokens / cw * 100) : 0;
|
|
446
|
+
const pw = cw && t.projectedTokens != null ? clamp(t.projectedTokens / cw * 100) : 0;
|
|
447
|
+
const totalSave = state.draft.ranges.reduce((s, r) => s + saveOf(r), 0);
|
|
448
|
+
const atomsCovered = state.draft.ranges.reduce((s, r) => s + (r.atomCount ?? 0), 0);
|
|
449
|
+
$('metrics').innerHTML = `
|
|
450
|
+
<div class="metric">
|
|
451
|
+
<label>context window</label>
|
|
452
|
+
<div class="val">${fmt(t.projectedTokens)} <small>projected of ${fmt(cw)}</small></div>
|
|
453
|
+
<div class="track"><i class="anchor" style="width:${aw}%"></i><i class="proj" style="width:${pw}%"></i></div>
|
|
454
|
+
<div class="legend">
|
|
455
|
+
<span><i class="dotm" style="background:color-mix(in srgb,var(--warn) 55%,transparent)"></i>anchor <b>${fmt(t.anchorTokens)}</b> (${pct(t.anchorPercent)})</span>
|
|
456
|
+
<span><i class="dotm" style="background:var(--accent)"></i>projected <b>${fmt(t.projectedTokens)}</b> (${pct(t.projectedPercent)})</span>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
<div class="metric">
|
|
460
|
+
<label>estimated save</label>
|
|
461
|
+
<div class="val" style="color:var(--keep)">−${fmt(t.estimatedSavedTokens ?? totalSave)}</div>
|
|
462
|
+
<div class="legend"><span>~${fmt(t.selectedOriginalApproxTokens)} → ~${fmt(t.selectedCompressedApproxTokens)}</span></div>
|
|
463
|
+
</div>
|
|
464
|
+
<div class="metric">
|
|
465
|
+
<label>coverage</label>
|
|
466
|
+
<div class="val">${atomsCovered} <small>/ ${state.atoms.length} atoms</small></div>
|
|
467
|
+
<div class="legend"><span>across ${state.draft.ranges.length} range(s) · −${fmt(totalSave)} tok</span></div>
|
|
468
|
+
</div>`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function renderRanges() {
|
|
472
|
+
const list = $('range-list');
|
|
473
|
+
const rs = state.draft.ranges.filter(r => !query || rangeText(r).includes(query));
|
|
474
|
+
if (!state.draft.ranges.length) {
|
|
475
|
+
list.innerHTML = '<div class="empty">No compression ranges in this draft.</div>';
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!rs.length) { list.innerHTML = '<div class="empty">No range matches the filter.</div>'; return; }
|
|
479
|
+
list.innerHTML = rs.map(r => {
|
|
480
|
+
const d = draftOf(r);
|
|
481
|
+
const ratio = r.originalApproxTokens ? Math.round((1 - r.compressedApproxTokens / r.originalApproxTokens) * 100) : 0;
|
|
482
|
+
return `<div class="ritem" role="option" tabindex="0" data-range="${esc(r.id)}" aria-selected="${r.id === selectedId}">
|
|
483
|
+
<div class="r1">
|
|
484
|
+
<span class="rid">${hl(r.id, query)}</span>
|
|
485
|
+
<span class="refs">${esc(r.startRef)}→${esc(r.endRef)}</span>
|
|
486
|
+
${isDirty(r) ? '<span class="udot" title="unsaved edits"></span>' : ''}
|
|
487
|
+
<span class="save">−${fmt(saveOf(r))}</span>
|
|
488
|
+
</div>
|
|
489
|
+
${d.topic ? `<div class="meta" style="color:var(--accent-2)">${hl(d.topic, query)}</div>` : ''}
|
|
490
|
+
<div class="rsum">${hl(d.summary, query)}</div>
|
|
491
|
+
<div class="meta">${r.atomCount} atoms · ~${fmt(r.originalApproxTokens)}→~${fmt(r.compressedApproxTokens)} (−${ratio}%)</div>
|
|
492
|
+
</div>`;
|
|
493
|
+
}).join('');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function buildGroups() {
|
|
497
|
+
const groups = [];
|
|
498
|
+
let cur = null;
|
|
499
|
+
state.atoms.forEach((a, i) => {
|
|
500
|
+
const key = a.owningRangeId ?? null;
|
|
501
|
+
if (!cur || cur.key !== key) { cur = { key, idx: i, atoms: [] }; groups.push(cur); }
|
|
502
|
+
cur.atoms.push(a);
|
|
503
|
+
});
|
|
504
|
+
return groups;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function renderTimeline() {
|
|
508
|
+
const tl = $('timeline');
|
|
509
|
+
if (!state.atoms.length) { tl.innerHTML = '<div class="empty">No atoms in anchor snapshot.</div>'; $('tl-stat').textContent = ''; return; }
|
|
510
|
+
|
|
511
|
+
let shown = 0;
|
|
512
|
+
const html = buildGroups().map(g => {
|
|
513
|
+
if (policy === 'range' && !g.key) return '';
|
|
514
|
+
if (policy === 'keep' && g.key) return '';
|
|
515
|
+
const r = g.key ? rangeById(g.key) : null;
|
|
516
|
+
const gkey = g.key ?? ('keep-' + g.idx);
|
|
517
|
+
const groupMatch = r ? rangeText(r).includes(query) : false;
|
|
518
|
+
const atoms = query && !groupMatch ? g.atoms.filter(a => atomText(a).includes(query)) : g.atoms;
|
|
519
|
+
if (query && !groupMatch && !atoms.length) return '';
|
|
520
|
+
shown += atoms.length;
|
|
521
|
+
|
|
522
|
+
const tok = g.atoms.reduce((s, a) => s + (a.approxTokens ?? 0), 0);
|
|
523
|
+
const open = !collapsed.has(gkey) || (query && !groupMatch);
|
|
524
|
+
const body = atoms.map(a => atomRow(a)).join('');
|
|
525
|
+
|
|
526
|
+
if (!r) {
|
|
527
|
+
return `<section class="group keepg ${open ? 'open' : ''}" data-group-key="${esc(gkey)}">
|
|
528
|
+
<button class="ghead" data-toggle="${esc(gkey)}" aria-expanded="${open}">
|
|
529
|
+
<span class="chev">▶</span>
|
|
530
|
+
<span class="gid">KEEP</span>
|
|
531
|
+
<span class="gsum">${esc(g.atoms[0].ref)} → ${esc(g.atoms[g.atoms.length - 1].ref)} verbatim</span>
|
|
532
|
+
<span class="gstat">${g.atoms.length} atoms · ~${fmt(tok)}</span>
|
|
533
|
+
</button>
|
|
534
|
+
<div class="gbody">${body}</div>
|
|
535
|
+
</section>`;
|
|
536
|
+
}
|
|
537
|
+
const d = draftOf(r);
|
|
538
|
+
return `<section class="group ${open ? 'open' : ''} ${r.id === selectedId ? 'sel' : ''}" data-group-key="${esc(gkey)}">
|
|
539
|
+
<button class="ghead" data-toggle="${esc(gkey)}" data-select="${esc(r.id)}" aria-expanded="${open}">
|
|
540
|
+
<span class="chev">▶</span>
|
|
541
|
+
<span class="gid">${esc(r.id)}</span>
|
|
542
|
+
<span class="gsum">${hl(d.topic || d.summary, query)}</span>
|
|
543
|
+
<span class="gstat">${r.atomCount} atoms · ~${fmt(r.originalApproxTokens)}→~${fmt(r.compressedApproxTokens)} <em>−${fmt(saveOf(r))}</em></span>
|
|
544
|
+
</button>
|
|
545
|
+
<div class="gbody">${body}</div>
|
|
546
|
+
</section>`;
|
|
547
|
+
}).join('');
|
|
548
|
+
|
|
549
|
+
tl.innerHTML = html || '<div class="empty">Nothing matches the current filter.</div>';
|
|
550
|
+
$('tl-stat').textContent = query || policy !== 'all' ? `${shown}/${state.atoms.length} atoms` : `${state.atoms.length} atoms`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function atomRow(a) {
|
|
554
|
+
const tags = [
|
|
555
|
+
...(a.toolNames ?? []).slice(0, 3).map(t => `<span class="tag">${esc(t)}</span>`),
|
|
556
|
+
...(a.roles ?? []).slice(0, 2).map(t => `<span class="tag">${esc(t)}</span>`),
|
|
557
|
+
a.compressible === false ? '<span class="tag lock">not compressible</span>' : '',
|
|
558
|
+
a.protocolClosed === false ? '<span class="tag lock">open protocol</span>' : '',
|
|
559
|
+
a.compressedBlockId ? `<span class="tag">blk ${esc(a.compressedBlockId)}</span>` : '',
|
|
560
|
+
].filter(Boolean).join('');
|
|
561
|
+
const open = openAtoms.has(a.ref);
|
|
562
|
+
return `<div class="atom ${open ? 'open' : ''}" data-atom="${esc(a.ref)}">
|
|
563
|
+
<span class="aref">${hl(a.ref, query)}</span>
|
|
564
|
+
<span class="akind">${hl(a.kind ?? '', query)}</span>
|
|
565
|
+
<span class="atok">~${fmt(a.approxTokens)}</span>
|
|
566
|
+
<div>
|
|
567
|
+
<div class="abody">${hl(a.preview ?? '', query)}</div>
|
|
568
|
+
${tags ? `<div class="tags">${tags}</div>` : ''}
|
|
569
|
+
</div>
|
|
570
|
+
</div>`;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function renderEditor() {
|
|
574
|
+
const panel = $('editor');
|
|
575
|
+
const r = selectedId ? rangeById(selectedId) : null;
|
|
576
|
+
if (!r) {
|
|
577
|
+
panel.innerHTML = `<div class="empty">${state.draft.ranges.length
|
|
578
|
+
? 'Select a range on the left to edit its topic and summary.'
|
|
579
|
+
: 'No compression ranges to review.<br>Close and return to the terminal.'}</div>`;
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const d = draftOf(r);
|
|
583
|
+
const dirty = isDirty(r);
|
|
584
|
+
const ratio = r.originalApproxTokens ? Math.round((1 - r.compressedApproxTokens / r.originalApproxTokens) * 100) : 0;
|
|
585
|
+
panel.innerHTML = `<div class="epad">
|
|
586
|
+
<div>
|
|
587
|
+
<div class="etitle">
|
|
588
|
+
<span class="eid">${esc(r.id)}</span>
|
|
589
|
+
<span class="erefs">${esc(r.startRef)} → ${esc(r.endRef)}</span>
|
|
590
|
+
</div>
|
|
591
|
+
<div class="estats">
|
|
592
|
+
<div class="estat"><label>atoms</label><span>${r.atomCount}</span></div>
|
|
593
|
+
<div class="estat"><label>tokens</label><span>~${fmt(r.originalApproxTokens)}→~${fmt(r.compressedApproxTokens)}</span></div>
|
|
594
|
+
<div class="estat"><label>save</label><span style="color:var(--keep)">−${fmt(saveOf(r))} (${ratio}%)</span></div>
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
<div class="field" id="f-topic">
|
|
598
|
+
<label for="topic">Topic <span class="count">optional</span></label>
|
|
599
|
+
<input id="topic" value="${esc(d.topic)}" placeholder="short label, e.g. auth refactor" autocomplete="off" spellcheck="false">
|
|
600
|
+
</div>
|
|
601
|
+
<div class="field ${d.summary.trim() ? '' : 'invalid'}" id="f-summary">
|
|
602
|
+
<label for="summary">Summary <span class="count" id="sum-count"></span></label>
|
|
603
|
+
<textarea id="summary" spellcheck="false">${esc(d.summary)}</textarea>
|
|
604
|
+
</div>
|
|
605
|
+
<div class="erow">
|
|
606
|
+
<button class="btn btn-primary" id="save-btn" ${dirty ? '' : 'disabled'}>Save</button>
|
|
607
|
+
<button class="btn" id="revert-btn" ${dirty ? '' : 'disabled'}>Revert</button>
|
|
608
|
+
<div class="spacer"></div>
|
|
609
|
+
<button class="btn btn-danger ${removeArmed === r.id ? 'armed' : ''}" id="remove-btn">
|
|
610
|
+
${removeArmed === r.id ? 'Confirm remove' : 'Remove range'}
|
|
611
|
+
</button>
|
|
612
|
+
</div>
|
|
613
|
+
<div class="hint">Topic and summary are saved together (<kbd>⌘/Ctrl</kbd>+<kbd>S</kbd>). Removing a range keeps its atoms verbatim.</div>
|
|
614
|
+
</div>`;
|
|
615
|
+
updateCount();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function updateCount() {
|
|
619
|
+
const ta = $('summary');
|
|
620
|
+
if (!ta) return;
|
|
621
|
+
const len = ta.value.length;
|
|
622
|
+
$('sum-count').textContent = `${len} chars · ~${Math.ceil(len / 4)} tok`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/* ---------- state mutation ---------- */
|
|
626
|
+
function select(id) {
|
|
627
|
+
if (!id || id === selectedId) return;
|
|
628
|
+
selectedId = id;
|
|
629
|
+
collapsed.delete(id);
|
|
630
|
+
removeArmed = null;
|
|
631
|
+
render();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function post(path, body) {
|
|
635
|
+
const res = await fetch(path, {
|
|
636
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}),
|
|
637
|
+
});
|
|
638
|
+
if (!res.ok) {
|
|
639
|
+
let msg = `request failed (${res.status})`;
|
|
640
|
+
try { const e = await res.json(); if (e && e.error) msg = e.error; } catch {}
|
|
641
|
+
throw new Error(msg);
|
|
642
|
+
}
|
|
643
|
+
return res.json();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function saveRange(id) {
|
|
647
|
+
const r = rangeById(id);
|
|
648
|
+
const e = edits.get(id);
|
|
649
|
+
if (!r || !e) return false;
|
|
650
|
+
if (!e.summary.trim()) { toast('Summary cannot be empty', 'err'); $('summary')?.focus(); return false; }
|
|
651
|
+
const base = `/api/range/${encodeURIComponent(id)}`;
|
|
652
|
+
if (e.topic.trim() !== (r.topic ?? '').trim()) state = await post(`${base}/edit-topic`, { value: e.topic.trim() });
|
|
653
|
+
if (e.summary.trim() !== r.summary) state = await post(`${base}/edit-summary`, { value: e.summary.trim() });
|
|
654
|
+
edits.delete(id);
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function saveCurrent() {
|
|
659
|
+
if (!selectedId || !isDirty(rangeById(selectedId) ?? {})) return;
|
|
660
|
+
try { if (await saveRange(selectedId)) { render(); toast('Saved ' + selectedId, 'ok'); } }
|
|
661
|
+
catch (err) { toast(String(err.message ?? err), 'err'); }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function refresh() {
|
|
665
|
+
try {
|
|
666
|
+
const res = await fetch('/api/state');
|
|
667
|
+
if (!res.ok) throw new Error('state fetch failed');
|
|
668
|
+
state = await res.json();
|
|
669
|
+
if (!rangeById(selectedId)) selectedId = state.draft.ranges[0]?.id ?? null;
|
|
670
|
+
render();
|
|
671
|
+
toast('Reloaded', 'ok');
|
|
672
|
+
} catch (err) { toast(String(err.message ?? err), 'err'); }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/* ---------- events ---------- */
|
|
676
|
+
$('range-list').addEventListener('click', e => {
|
|
677
|
+
const item = e.target.closest('[data-range]');
|
|
678
|
+
if (item) select(item.dataset.range);
|
|
679
|
+
});
|
|
680
|
+
$('range-list').addEventListener('keydown', e => {
|
|
681
|
+
const item = e.target.closest('[data-range]');
|
|
682
|
+
if (item && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(item.dataset.range); }
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
$('timeline').addEventListener('click', e => {
|
|
686
|
+
const atom = e.target.closest('[data-atom]');
|
|
687
|
+
if (atom && e.target.closest('.abody')) {
|
|
688
|
+
const ref = atom.dataset.atom;
|
|
689
|
+
openAtoms.has(ref) ? openAtoms.delete(ref) : openAtoms.add(ref);
|
|
690
|
+
atom.classList.toggle('open');
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const head = e.target.closest('[data-toggle]');
|
|
694
|
+
if (!head) return;
|
|
695
|
+
const key = head.dataset.toggle;
|
|
696
|
+
const chevron = e.target.closest('.chev');
|
|
697
|
+
if (head.dataset.select && !chevron && head.dataset.select !== selectedId) { select(head.dataset.select); return; }
|
|
698
|
+
collapsed.has(key) ? collapsed.delete(key) : collapsed.add(key);
|
|
699
|
+
head.closest('.group').classList.toggle('open');
|
|
700
|
+
head.setAttribute('aria-expanded', String(!collapsed.has(key)));
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
$('toggle-all').addEventListener('click', () => {
|
|
704
|
+
if (collapsed.size) { collapsed.clear(); $('toggle-all').textContent = 'Collapse all'; }
|
|
705
|
+
else {
|
|
706
|
+
buildGroups().forEach((g, i) => collapsed.add(g.key ?? ('keep-' + g.idx)));
|
|
707
|
+
$('toggle-all').textContent = 'Expand all';
|
|
708
|
+
}
|
|
709
|
+
renderTimeline();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
document.querySelectorAll('.seg [data-filter]').forEach(b => b.addEventListener('click', () => {
|
|
713
|
+
policy = b.dataset.filter;
|
|
714
|
+
document.querySelectorAll('.seg [data-filter]').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
|
|
715
|
+
renderTimeline();
|
|
716
|
+
}));
|
|
717
|
+
|
|
718
|
+
$('q').addEventListener('input', () => {
|
|
719
|
+
query = $('q').value.trim().toLowerCase();
|
|
720
|
+
$('q-clear').hidden = !query;
|
|
721
|
+
renderRanges(); renderTimeline();
|
|
722
|
+
});
|
|
723
|
+
$('q-clear').addEventListener('click', () => { $('q').value = ''; $('q').dispatchEvent(new Event('input')); $('q').focus(); });
|
|
724
|
+
|
|
725
|
+
$('editor').addEventListener('input', e => {
|
|
726
|
+
const r = rangeById(selectedId);
|
|
727
|
+
if (!r || !$('topic') || !$('summary')) return;
|
|
728
|
+
edits.set(r.id, { topic: $('topic').value, summary: $('summary').value });
|
|
729
|
+
if (!isDirty(r)) edits.delete(r.id);
|
|
730
|
+
const dirty = isDirty(r);
|
|
731
|
+
$('save-btn').disabled = !dirty;
|
|
732
|
+
$('revert-btn').disabled = !dirty;
|
|
733
|
+
$('f-summary').classList.toggle('invalid', !$('summary').value.trim());
|
|
734
|
+
updateCount();
|
|
735
|
+
renderRanges();
|
|
736
|
+
const d = dirtyIds();
|
|
737
|
+
$('dirty-badge').hidden = d.length === 0;
|
|
738
|
+
$('dirty-badge').textContent = `${d.length} unsaved`;
|
|
739
|
+
$('save-all').hidden = d.length < 2;
|
|
740
|
+
$('save-all').textContent = `Save all (${d.length})`;
|
|
741
|
+
if (e.target.id === 'topic') renderTimeline();
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
$('editor').addEventListener('click', async e => {
|
|
745
|
+
const r = rangeById(selectedId);
|
|
746
|
+
if (!r) return;
|
|
747
|
+
if (e.target.closest('#save-btn')) { await saveCurrent(); return; }
|
|
748
|
+
if (e.target.closest('#revert-btn')) { edits.delete(r.id); render(); toast('Reverted'); return; }
|
|
749
|
+
if (e.target.closest('#remove-btn')) {
|
|
750
|
+
if (removeArmed !== r.id) {
|
|
751
|
+
removeArmed = r.id;
|
|
752
|
+
renderEditor();
|
|
753
|
+
setTimeout(() => { if (removeArmed === r.id) { removeArmed = null; renderEditor(); } }, 4000);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
removeArmed = null;
|
|
757
|
+
try {
|
|
758
|
+
state = await post(`/api/range/${encodeURIComponent(r.id)}/remove`, {});
|
|
759
|
+
edits.delete(r.id);
|
|
760
|
+
selectedId = state.draft.ranges[0]?.id ?? null;
|
|
761
|
+
lastScrolled = null;
|
|
762
|
+
render();
|
|
763
|
+
toast('Removed ' + r.id, 'ok');
|
|
764
|
+
} catch (err) { toast(String(err.message ?? err), 'err'); }
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
$('save-all').addEventListener('click', async () => {
|
|
769
|
+
try {
|
|
770
|
+
for (const id of dirtyIds()) await saveRange(id);
|
|
771
|
+
render(); toast('All changes saved', 'ok');
|
|
772
|
+
} catch (err) { render(); toast(String(err.message ?? err), 'err'); }
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
$('refresh').addEventListener('click', refresh);
|
|
776
|
+
|
|
777
|
+
$('close').addEventListener('click', async () => {
|
|
778
|
+
const d = dirtyIds();
|
|
779
|
+
if (d.length && !closeArmed) {
|
|
780
|
+
closeArmed = true;
|
|
781
|
+
$('close').textContent = `Discard ${d.length} unsaved & close`;
|
|
782
|
+
setTimeout(() => { closeArmed = false; $('close').textContent = 'Close & return'; }, 4000);
|
|
783
|
+
toast('Unsaved edits — click again to discard and close', 'err');
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
await post('/api/close', {});
|
|
788
|
+
document.body.innerHTML = '<div class="closed">Review closed.<br>Return to your terminal.</div>';
|
|
789
|
+
} catch (err) { toast(String(err.message ?? err), 'err'); }
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
/* theme */
|
|
793
|
+
function themeIcon() {
|
|
794
|
+
$('theme-toggle').textContent = document.documentElement.getAttribute('data-theme') === 'light' ? '☀' : '☾';
|
|
795
|
+
}
|
|
796
|
+
$('theme-toggle').addEventListener('click', () => {
|
|
797
|
+
const next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
|
|
798
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
799
|
+
try { localStorage.setItem('midcompact-theme', next); } catch {}
|
|
800
|
+
themeIcon();
|
|
801
|
+
});
|
|
802
|
+
themeIcon();
|
|
803
|
+
|
|
804
|
+
/* help + shortcuts */
|
|
805
|
+
const help = $('help');
|
|
806
|
+
$('help-btn').addEventListener('click', () => help.classList.toggle('show'));
|
|
807
|
+
help.addEventListener('click', e => { if (e.target === help) help.classList.remove('show'); });
|
|
808
|
+
|
|
809
|
+
function moveSel(delta) {
|
|
810
|
+
const ids = state.draft.ranges.map(r => r.id);
|
|
811
|
+
if (!ids.length) return;
|
|
812
|
+
const i = ids.indexOf(selectedId);
|
|
813
|
+
select(ids[Math.max(0, Math.min(ids.length - 1, i < 0 ? 0 : i + delta))]);
|
|
814
|
+
}
|
|
815
|
+
document.addEventListener('keydown', e => {
|
|
816
|
+
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
|
|
817
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveCurrent(); return; }
|
|
818
|
+
if (e.key === 'Escape') { help.classList.remove('show'); if (typing) e.target.blur(); return; }
|
|
819
|
+
if (typing) return;
|
|
820
|
+
if (e.key === '/') { e.preventDefault(); $('q').focus(); return; }
|
|
821
|
+
if (e.key === '?') { help.classList.toggle('show'); return; }
|
|
822
|
+
if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); moveSel(1); return; }
|
|
823
|
+
if (e.key === 'k' || e.key === 'ArrowUp') { e.preventDefault(); moveSel(-1); return; }
|
|
824
|
+
if (e.key === 'e') { e.preventDefault(); $('summary')?.focus(); return; }
|
|
825
|
+
if (e.key === 'r') { refresh(); return; }
|
|
826
|
+
if (e.key === 'x' && selectedId) {
|
|
827
|
+
collapsed.has(selectedId) ? collapsed.delete(selectedId) : collapsed.add(selectedId);
|
|
828
|
+
renderTimeline();
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
let toastTimer;
|
|
833
|
+
function toast(msg, kind) {
|
|
834
|
+
const t = $('toast');
|
|
835
|
+
t.textContent = msg;
|
|
836
|
+
t.className = 'toast show' + (kind ? ' ' + kind : '');
|
|
837
|
+
clearTimeout(toastTimer);
|
|
838
|
+
toastTimer = setTimeout(() => t.classList.remove('show'), kind === 'err' ? 3200 : 1800);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
window.addEventListener('beforeunload', e => { if (dirtyIds().length) { e.preventDefault(); e.returnValue = ''; } });
|
|
842
|
+
|
|
843
|
+
render();
|
|
844
|
+
</script>
|
|
845
|
+
</body>
|
|
846
|
+
</html>
|