reviewzy 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.env.example +5 -0
  2. package/README.md +29 -10
  3. package/package.json +1 -1
  4. package/src/config.ts +4 -0
  5. package/src/dashboard/mount.ts +118 -1
  6. package/src/dashboard/repo-link.ts +126 -0
  7. package/src/dashboard/static/components.css +6780 -534
  8. package/src/dashboard/static/cursor-rules.js +83 -0
  9. package/src/dashboard/static/cursor.css +105 -0
  10. package/src/dashboard/static/cursor.js +358 -0
  11. package/src/dashboard/static/dashboard.css +139 -77
  12. package/src/dashboard/static/dashboard.js +147 -29
  13. package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  14. package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  15. package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
  16. package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
  17. package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
  18. package/src/dashboard/static/icons.svg +184 -0
  19. package/src/dashboard/static/tokens.css +421 -110
  20. package/src/dashboard/static/ui.js +1992 -0
  21. package/src/dashboard/views.tsx +600 -282
  22. package/src/dashboard/word-diff.ts +89 -0
  23. package/src/db/human-save.ts +112 -0
  24. package/src/db/migrations.ts +38 -0
  25. package/src/db/queries.ts +36 -13
  26. package/src/db/sweep.ts +2 -2
  27. package/src/mcp/await-approved.ts +17 -10
  28. package/src/mcp/fetch-approved.ts +11 -6
  29. package/src/mcp/file-entries.ts +95 -20
  30. package/src/mcp/list-entries.ts +54 -29
  31. package/src/mcp/mark-applied.ts +6 -6
  32. package/src/mcp/route.ts +6 -9
  33. package/src/mcp/server.ts +8 -3
  34. package/src/shim/daemon.ts +23 -16
  35. package/src/shim/main.ts +26 -5
@@ -2,170 +2,232 @@
2
2
  reviewzy — dashboard page styles
3
3
  ============================================================ */
4
4
 
5
- /* ---- Page shell (navbar shell per cloudy-ui) ---- */
5
+ /* The one override that retunes the column: the navbar's card and inner
6
+ column, the page width and this shell all read it. */
7
+ :root { --page-w: 1400px; }
8
+
9
+ /* ---- Page shell (navbar shell) ---- */
6
10
  .app-shell { min-height: 100vh; display: flex; flex-direction: column; }
7
- .main { padding: var(--space-10) var(--space-12); max-width: 1400px; margin: 0 auto; width: 100%; }
11
+ .main { padding: var(--space-10) var(--space-8); max-width: var(--page-w); margin: 0 auto; width: 100%; }
8
12
 
9
- /* ---- Page header: the eyebrow / title / lede triplet ---- */
10
- .page-header { margin-bottom: var(--space-8); padding-bottom: var(--space-5); border-bottom: 1px solid var(--line); }
11
- .page-eyebrow { color: var(--accent); margin-bottom: var(--space-2); }
12
- .page-title { font: 550 var(--text-2xl)/1.15 var(--font-sans); color: var(--text); letter-spacing: -0.02em; margin-bottom: var(--space-3); }
13
- .page-lede { font-size: var(--text-base); color: var(--text-dim); max-width: 540px; }
13
+ /* ---- Page header: the eyebrow / title / lede triplet (login/editor pages) ---- */
14
+ .page-eyebrow { color: var(--accent-text); margin-bottom: var(--space-2); }
15
+ .page-lede { font-size: var(--t-3); color: var(--text-3); max-width: 540px; }
14
16
 
15
17
  /* ---- Filter form ---- */
16
- .filters { display: grid; grid-template-columns: minmax(0, 2fr) 200px 200px auto; gap: var(--space-3); align-items: end; margin-bottom: var(--space-8); }
18
+ .filters { display: grid; grid-template-columns: minmax(0, 2fr) 200px 200px; gap: var(--space-3); align-items: end; margin-bottom: var(--space-8); }
17
19
  @media (max-width: 768px) { .filters { grid-template-columns: 1fr; } }
18
20
 
19
21
  /* ---- Entries region ---- */
20
22
  /* The loading strip is hidden until htmx marks the request in flight (hx-indicator adds the
21
23
  .htmx-request class); it sits beside the swap target, so a swap never removes it. */
22
- #entries-loading { display: none; align-items: center; gap: var(--space-2); font-size: var(--text-sm); color: var(--text-dim); padding: var(--space-2) 0; }
24
+ #entries-loading { display: none; align-items: center; gap: var(--space-2); font-size: var(--t-2); color: var(--text-3); padding: var(--space-2) 0; }
23
25
  #entries-loading.htmx-request { display: flex; }
24
- /* The spinner reuses the @keyframes spin components.css ships for .btn-loading. */
25
- .spinner {
26
- width: 12px; height: 12px;
27
- border: 2px solid var(--line-strong);
28
- border-top-color: var(--accent);
29
- border-radius: 50%;
30
- animation: spin 0.6s linear infinite;
31
- flex-shrink: 0;
32
- }
33
26
 
34
27
  /* ---- Partial state ---- */
35
28
  .partial-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-4); }
36
- .partial-count { font-size: var(--text-sm); color: var(--text-dim); }
29
+ .partial-count { font-size: var(--t-2); color: var(--text-3); }
37
30
 
38
31
  /* ---- Project groups ---- */
39
32
  .project-group { margin-bottom: var(--space-10); }
40
33
  .project-header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-4); }
41
- .project-name { font: 500 var(--text-lg)/1.2 var(--font-mono); color: var(--text); letter-spacing: -0.01em; }
34
+ .project-name { font: 500 var(--t-5)/1.2 var(--font-mono); color: var(--text); letter-spacing: -0.01em; }
42
35
  .batch { margin-bottom: var(--space-6); }
43
36
  .batch-header { display: flex; align-items: baseline; gap: var(--space-3); margin-bottom: var(--space-2); }
44
- .batch-id { font: 400 var(--text-xs)/1 var(--font-mono); color: var(--text-faint); }
37
+ .batch-meta { font: 400 var(--t-2)/1 var(--font-sans); color: var(--text-4); }
45
38
 
46
39
  /* ---- Entry table cells ----
47
- Paths, anchors, and constraint summaries are machine data, so they set the mono face;
48
- long values ellipsize in place and keep the full value in the title attribute. */
49
- .cell-file { max-width: 380px; }
50
- .cell-anchor { max-width: 320px; }
51
- .cell-text { max-width: 560px; }
52
- .file-path, .anchor-text {
53
- display: block;
54
- font-family: var(--font-mono);
55
- font-size: var(--text-xs);
56
- line-height: 1.4;
57
- color: var(--text-muted);
40
+ The table lays out fixed with a colgroup so the title column takes the remainder and the row
41
+ never overflows; select, status, file, and actions are the fixed columns. The status pill has
42
+ its own column, so the title truncates at one consistent point and the pill stays tight. */
43
+ .col-select { width: 48px; }
44
+ .col-status { width: 106px; }
45
+ .col-file { width: 200px; }
46
+ .col-actions { width: 120px; }
47
+ .entry-head {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: var(--space-2);
51
+ }
52
+ .entry-title {
53
+ flex: 1 1 auto;
54
+ min-width: 0;
55
+ font: 400 var(--t-3)/1.4 var(--font-sans);
56
+ color: var(--text);
58
57
  white-space: nowrap;
59
58
  overflow: hidden;
60
59
  text-overflow: ellipsis;
61
60
  }
62
- .entry-text {
63
- display: -webkit-box;
64
- -webkit-line-clamp: 2;
65
- -webkit-box-orient: vertical;
61
+ .entry-filer {
62
+ flex-shrink: 0;
63
+ font-size: var(--t-2);
64
+ color: var(--text-4);
65
+ white-space: nowrap;
66
+ }
67
+ .cell-file {
68
+ font-family: var(--font-mono);
69
+ font-size: var(--t-2);
70
+ color: var(--text-4);
71
+ white-space: nowrap;
66
72
  overflow: hidden;
73
+ text-overflow: ellipsis;
67
74
  }
68
- .cell-filedby { color: var(--text-dim); white-space: nowrap; }
69
- .constraint-summary { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-dim); white-space: nowrap; }
70
75
 
71
76
  /* ---- Row actions and batch approve ----
72
77
  A control mid-request ignores further activations: htmx marks the requesting element with
73
78
  .htmx-request while the request is in flight, and pointer-events drops the duplicate click a
74
79
  double-click would otherwise fire. */
75
80
  .htmx-request { pointer-events: none; }
76
- .cell-select { width: 44px; }
77
- .cell-select input[type="checkbox"] { accent-color: var(--accent); }
78
- .cell-actions { white-space: nowrap; }
79
- .cell-actions .btn + .btn { margin-left: var(--space-2); }
80
- .batch-bar { display: flex; align-items: center; margin-bottom: var(--space-4); }
81
+ .cell-actions { white-space: nowrap; display: inline-flex; align-items: center; gap: var(--space-1); }
82
+ .table-wrap tbody td.cell-actions { padding-left: var(--space-1); padding-right: var(--space-1); }
83
+ .batch-bar {
84
+ display: flex;
85
+ align-items: center;
86
+ margin-bottom: var(--space-4);
87
+ /* Sticks just below the navbar so the approve button stays reachable while rows scroll
88
+ under it; the background and z-index stop table rows from showing through the bar. */
89
+ position: sticky;
90
+ top: var(--navbar-h);
91
+ z-index: 10;
92
+ padding: var(--space-2) 0;
93
+ background: var(--bg);
94
+ }
95
+ .batch-header .checkbox-label { align-self: center; }
96
+ /* ---- Collapsible project sections and batches ----
97
+ The chevron points down expanded and turns when collapsed; the state is applied by dashboard.js
98
+ from localStorage, so the server ships everything open and the viewer's choice re-applies. */
99
+ .collapse-toggle { align-self: center; }
100
+ .collapse-toggle svg { transition: transform var(--dur-tint) var(--ease); }
101
+ .collapse-toggle[aria-expanded="false"] svg { transform: rotate(-90deg); }
102
+ .project-header .collapse-toggle, .batch-header .collapse-toggle { margin-left: auto; }
81
103
  .notice-list { margin: 0; padding-left: var(--space-5); display: flex; flex-direction: column; gap: var(--space-1); }
82
104
 
83
- /* ---- Diff view: the anchored snippet vs the entry's text, mono on sunken, anchor line distinct ---- */
105
+ /* ---- Diff view: the anchored snippet vs the entry's text, mono on the well, anchor distinct ---- */
84
106
  .diff-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); margin-bottom: var(--space-8); }
85
107
  @media (max-width: 900px) { .diff-grid { grid-template-columns: 1fr; } }
86
108
  .diff-code {
87
- font: 400 var(--text-sm)/1.6 var(--font-mono);
88
- background: var(--bg-sunken);
109
+ font: 400 var(--t-2)/1.6 var(--font-mono);
110
+ background: var(--well);
89
111
  border: 1px solid var(--line);
90
- border-radius: var(--radius);
112
+ border-radius: var(--r-1);
91
113
  padding: var(--space-4);
92
114
  white-space: pre-wrap;
93
115
  word-break: break-word;
94
- color: var(--text-muted);
116
+ color: var(--text-2);
95
117
  margin: 0;
96
118
  }
97
119
  .diff-line { display: block; }
98
120
  .diff-anchor {
99
- background: var(--accent-soft);
121
+ background: var(--accent-quiet);
100
122
  border-left: 2px solid var(--accent);
101
123
  color: var(--text);
102
124
  padding-left: var(--space-2);
103
125
  }
104
126
 
105
- /* ---- Empty states ---- */
106
- .empty-state { text-align: center; padding: var(--space-12) var(--space-6); }
107
- .empty-icon { margin: 0 auto var(--space-4); width: 32px; height: 32px; color: var(--text-faint); display: flex; align-items: center; justify-content: center; }
108
- .empty-state h3 { font: 500 var(--text-lg)/1.3 var(--font-sans); color: var(--text); margin-bottom: var(--space-2); }
109
- .empty-state p { font-size: var(--text-sm); color: var(--text-dim); max-width: 420px; margin: 0 auto var(--space-4); }
127
+ /* ---- Empty state actions: the shipped .empty-state lays out one button, this row holds two ---- */
128
+ .empty-actions { display: flex; gap: var(--space-2); justify-content: center; }
110
129
 
111
130
  /* ---- Error box (cloned from #error-box by dashboard.js) ---- */
112
131
  .callout-content { min-width: 0; }
113
132
  .callout-actions { margin-top: var(--space-2); }
114
133
 
115
134
  /* ---- Entry editor ---- */
116
- /* The one live region: present in the DOM, absent from layout, and never focusable. */
117
- .visually-hidden {
118
- position: absolute;
119
- width: 1px;
120
- height: 1px;
121
- margin: -1px;
122
- padding: 0;
123
- overflow: hidden;
124
- clip: rect(0 0 0 0);
125
- white-space: nowrap;
126
- border: 0;
127
- }
128
- .editor-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--space-8); align-items: start; }
135
+ /* The one live region, kept out of sight by the shipped .sr-only utility. */
136
+ .editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-8); align-items: start; }
129
137
  .editor-main { min-width: 0; }
130
138
  .editor-rail { display: flex; flex-direction: column; gap: var(--space-6); position: sticky; top: var(--space-8); }
131
139
  @media (max-width: 900px) {
132
140
  .editor-grid { grid-template-columns: 1fr; }
133
141
  .editor-rail { position: static; }
134
142
  }
143
+ /* The editor's title card header: the title falls back to the anchor text, which can be one long
144
+ unbroken string, so it wraps inside the card instead of overflowing or shoving the status pill. */
145
+ .editor-title { min-width: 0; overflow-wrap: anywhere; }
146
+ /* The entry text ships verbatim, often carrying markdown backticks and significant whitespace;
147
+ mono gives every glyph, space included, one full cell so both stay countable while editing. */
148
+ #editor-text { font-family: var(--font-mono); }
149
+
150
+ /* ---- Editor prev/next rails ----
151
+ One full-height fixed strip per side, below the navbar, so the previous and next entries are
152
+ a single click away while reading. The strip sits inside the page's inline padding, clear of content. */
153
+ .editor-nav-btn {
154
+ position: fixed;
155
+ top: var(--navbar-h);
156
+ bottom: 0;
157
+ width: 44px;
158
+ display: flex;
159
+ align-items: center;
160
+ justify-content: center;
161
+ color: var(--text-3);
162
+ text-decoration: none;
163
+ z-index: 40;
164
+ transition: background var(--dur-tint) var(--ease), color var(--dur-tint) var(--ease);
165
+ }
166
+ .editor-nav-prev { left: 0; }
167
+ .editor-nav-next { right: 0; }
168
+ .editor-nav-btn:hover { background: var(--surface-2); color: var(--text); }
169
+ .editor-nav-btn.is-disabled { color: var(--text-4); opacity: 0.4; pointer-events: none; }
170
+ .editor-nav-btn svg { width: 18px; height: 18px; }
171
+ @media (max-width: 900px) { .editor-nav { display: none; } }
135
172
  #editor-region > .callout { margin-bottom: var(--space-4); }
136
173
  #editor-region > .btn { margin-top: var(--space-4); }
137
174
  .editor-actions { margin-top: var(--space-4); display: flex; align-items: center; gap: var(--space-2); }
138
175
  /* The in-flight indicator for an approve or reject from the editor: htmx's own rules fade it in. */
139
- .editor-actions .htmx-indicator { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); color: var(--text-dim); }
176
+ .editor-actions .htmx-indicator { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--t-2); color: var(--text-3); }
177
+
178
+ /* The notes card: the agent's row reuses the constraint row shape; the human's textarea and its
179
+ save button sit below it. */
180
+ .notes-field { margin-top: var(--space-2); }
181
+ .notes-actions { margin-top: var(--space-2); display: flex; justify-content: flex-end; }
140
182
 
141
183
  /* Constraint panel: live count and placeholder checklist; the server owns the refusal. */
142
184
  .constraint-row { display: flex; gap: var(--space-3); padding: 6px 0; }
143
185
  .constraint-row + .constraint-row { border-top: 1px solid var(--line); }
144
- .constraint-label { flex-shrink: 0; font: 450 11px/1.4 var(--font-sans); letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); width: 96px; padding-top: 2px; }
145
- .constraint-value { min-width: 0; color: var(--text-muted); font-size: var(--text-sm); }
146
- .char-count { color: var(--text-muted); font-size: var(--text-sm); }
186
+ .constraint-label { flex-shrink: 0; font: 500 11px/1.4 var(--font-sans); letter-spacing: var(--caps); text-transform: uppercase; color: var(--text-3); width: 96px; padding-top: 2px; }
187
+ .constraint-value { min-width: 0; color: var(--text-2); font-size: var(--t-2); }
188
+ .char-count { color: var(--text-2); font-size: var(--t-2); }
147
189
  .char-count.over { color: var(--danger); }
148
190
  .placeholder-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
149
191
  .placeholder-item { display: flex; align-items: center; gap: var(--space-2); }
150
- .placeholder-state { font-size: var(--text-xs); color: var(--text-dim); min-width: 52px; }
192
+ .placeholder-state { font-size: var(--t-2); color: var(--text-3); min-width: 52px; }
151
193
  .placeholder-state.is-present { color: var(--success); }
152
- .placeholder-token { font: 400 var(--text-sm)/1 var(--font-mono); color: var(--text); background: var(--bg-sunken); border: 1px solid var(--line); border-radius: var(--radius); padding: 2px 6px; }
194
+
195
+ /* The context card's secondary block: the provenance rows (hash, filer, stale note) collapsed
196
+ behind a summary, so the anchor and the agent-supplied context rows lead the card. */
197
+ .context-secondary { margin-top: var(--space-3); }
198
+ .context-secondary summary { cursor: pointer; font-size: var(--t-2); color: var(--text-3); }
199
+ .context-secondary .info-table { margin-top: var(--space-2); }
200
+
201
+ /* Repo row link: the brand icon beside the shortened owner/repo path, linking out to the host. */
202
+ .repo-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text); text-decoration: none; }
203
+ .repo-link:hover { color: var(--accent-text); }
204
+ .repo-icon { width: 14px; height: 14px; flex-shrink: 0; fill: currentColor; opacity: var(--icon-dim); }
205
+ .placeholder-token { font: 400 var(--t-2)/1 var(--font-mono); color: var(--text); background: var(--well); border: 1px solid var(--line); border-radius: var(--r-1); padding: 2px 6px; }
206
+
207
+ /* Entry images: one image per row at the card's width, a remove form below each, and the
208
+ one-file upload form closing the card. The file input flexes to whatever width remains. */
209
+ .image-list { list-style: none; margin: 0 0 var(--space-3); padding: 0; display: flex; flex-direction: column; gap: var(--space-3); }
210
+ .entry-image { display: block; max-width: 100%; height: auto; border: 1px solid var(--line); border-radius: var(--r-1); }
211
+ .image-skip { margin: 0; font-size: var(--t-2); color: var(--text-3); }
212
+ .image-remove { margin-top: var(--space-1); }
213
+ .image-upload { display: flex; gap: var(--space-2); align-items: center; }
214
+ .image-upload .input { flex: 1; min-width: 0; }
153
215
 
154
216
  /* Revision history: newest first; the text clamps like the list's entry text. */
155
- .history-empty { font-size: var(--text-sm); color: var(--text-faint); padding: var(--space-2) 0; }
217
+ .history-empty { font-size: var(--t-2); color: var(--text-4); padding: var(--space-2) 0; }
156
218
  .revision-list { list-style: none; margin: 0; padding: 0; }
157
219
  .revision { padding: var(--space-3) 0; }
158
220
  .revision + .revision { border-top: 1px solid var(--line); }
159
221
  .revision-head { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-2); }
160
- .revision-time { font-size: var(--text-xs); color: var(--text-faint); }
222
+ .revision-time { font-size: var(--t-2); color: var(--text-4); }
161
223
  .revision-head .btn { margin-left: auto; }
162
224
  .revision-text {
163
225
  display: -webkit-box;
164
226
  -webkit-line-clamp: 2;
165
227
  -webkit-box-orient: vertical;
166
228
  overflow: hidden;
167
- font-size: var(--text-sm);
168
- color: var(--text-muted);
229
+ font-size: var(--t-2);
230
+ color: var(--text-2);
169
231
  }
170
232
 
171
233
  /* ---- Login page: the navbar shell, then one centered card ---- */
@@ -1,35 +1,11 @@
1
- // reviewzy dashboard behaviors. Load after htmx.min.js.
1
+ // reviewzy dashboard behaviors. Loads as a module after htmx.min.js.
2
+ // The navbar's ink, its dock state and the table header label boxes come from ui.js;
3
+ // initTables is re-run after every htmx swap so a freshly rendered table head gets its boxes.
4
+ import { initTables } from "./ui.js";
5
+
2
6
  (() => {
3
7
  "use strict";
4
8
 
5
- // --- Navbar ink (cloudy-ui navbar component) ---
6
- // Positions the sliding underline under the active link; re-positions on resize without animating.
7
- const navbarNav = document.getElementById("navbar-nav");
8
- const navbarInk = document.getElementById("navbar-ink");
9
-
10
- function positionNavbarInk() {
11
- if (!navbarNav || !navbarInk) return;
12
- const active = navbarNav.querySelector(".navbar-link.active");
13
- if (!active) {
14
- navbarInk.style.opacity = "0";
15
- return;
16
- }
17
- const navRect = navbarNav.getBoundingClientRect();
18
- const linkRect = active.getBoundingClientRect();
19
- navbarInk.style.left = `${linkRect.left - navRect.left}px`;
20
- navbarInk.style.width = `${linkRect.width}px`;
21
- navbarInk.style.opacity = "1";
22
- }
23
-
24
- window.addEventListener("resize", () => {
25
- navbarInk.style.transition = "none";
26
- positionNavbarInk();
27
- requestAnimationFrame(() => {
28
- navbarInk.style.transition = "";
29
- });
30
- });
31
- requestAnimationFrame(positionNavbarInk);
32
-
33
9
  // --- Filter errors ---
34
10
  // A failed filter request (HTTP error or network error) replaces the list with the error box
35
11
  // from the page's #error-box template; the retry button re-submits the filter form.
@@ -60,6 +36,34 @@
60
36
  form.addEventListener("htmx:sendError", showFilterError);
61
37
  }
62
38
 
39
+ // --- Filter auto-submit ---
40
+ // The Apply button is gone: changing a select or typing in the search fires the filter request
41
+ // itself. requestSubmit() reaches htmx's submit listener on the form; the debounce collapses a
42
+ // burst of keystrokes into one request. Only the change and input events below trigger a submit,
43
+ // and both fire on user action alone, so a programmatic value reset (which sets .value directly
44
+ // and emits no event) never re-fires.
45
+ function submitFilters() {
46
+ const target = document.getElementById("filters");
47
+ if (target && typeof target.requestSubmit === "function") {
48
+ target.requestSubmit();
49
+ }
50
+ }
51
+
52
+ let searchTimer;
53
+ function scheduleSearchSubmit() {
54
+ clearTimeout(searchTimer);
55
+ searchTimer = setTimeout(submitFilters, 300);
56
+ }
57
+
58
+ if (form) {
59
+ const statusSelect = document.getElementById("status");
60
+ const projectSelect = document.getElementById("project");
61
+ const searchInput = document.getElementById("q");
62
+ statusSelect?.addEventListener("change", submitFilters);
63
+ projectSelect?.addEventListener("change", submitFilters);
64
+ searchInput?.addEventListener("input", scheduleSearchSubmit);
65
+ }
66
+
63
67
  // --- Style guide save errors ---
64
68
  // A failed save (HTTP error or network error) puts the error box from the page's
65
69
  // #style-guide-error-box template above the form; the retry button re-submits the save form.
@@ -166,4 +170,118 @@
166
170
  refreshEditorMetrics();
167
171
  textarea.focus();
168
172
  });
173
+
174
+ // --- Batch select-all and live selection count ---
175
+ // The list region is swapped wholesale on every filter and action round, so no listener bound to
176
+ // a checkbox or the button survives a swap: change events are delegated on document, and one pass
177
+ // after each swap recomputes the select-all states and the approve button's count. All of it is
178
+ // progressive enhancement — without JS the approve button keeps its static label, stays enabled,
179
+ // and the native form still submits the checked rows.
180
+ const DRAFT_SELECTOR = 'input[type="checkbox"][name="id"]';
181
+ const SELECT_ALL_SELECTOR = 'input[type="checkbox"].select-all';
182
+
183
+ function selectionCount() {
184
+ return document.querySelectorAll(`#batch-form ${DRAFT_SELECTOR}:checked`).length;
185
+ }
186
+
187
+ function updateApproveButton() {
188
+ const button = document.querySelector('#batch-form .batch-bar button[type="submit"]');
189
+ if (!button) return;
190
+ const count = selectionCount();
191
+ button.textContent = count === 0 ? "Approve selected" : `Approve ${count}`;
192
+ button.disabled = count === 0;
193
+ }
194
+
195
+ function updateSelectAll(selectAll) {
196
+ const batch = selectAll.closest(".batch");
197
+ if (!batch) return;
198
+ const drafts = batch.querySelectorAll(DRAFT_SELECTOR);
199
+ const checked = batch.querySelectorAll(`${DRAFT_SELECTOR}:checked`).length;
200
+ selectAll.checked = drafts.length > 0 && checked === drafts.length;
201
+ selectAll.indeterminate = checked > 0 && checked < drafts.length;
202
+ }
203
+
204
+ function refreshSelection() {
205
+ for (const selectAll of document.querySelectorAll(SELECT_ALL_SELECTOR)) {
206
+ updateSelectAll(selectAll);
207
+ }
208
+ updateApproveButton();
209
+ }
210
+
211
+ document.addEventListener("change", (event) => {
212
+ const target = event.target instanceof Element ? event.target : null;
213
+ if (!target) return;
214
+ if (target.matches(SELECT_ALL_SELECTOR)) {
215
+ const batch = target.closest(".batch");
216
+ if (batch) {
217
+ for (const draft of batch.querySelectorAll(DRAFT_SELECTOR)) {
218
+ draft.checked = target.checked;
219
+ }
220
+ }
221
+ }
222
+ refreshSelection();
223
+ });
224
+
225
+ document.addEventListener("htmx:afterSwap", refreshSelection);
226
+
227
+ // --- Collapsible project sections and batches ---
228
+ // The server renders every section and batch open; JS collapses the ones this viewer collapsed
229
+ // before, from localStorage, and persists each toggle. Re-applied after every swap because a
230
+ // filter or action round replaces the whole list region. Without JS the toggles stay inert and
231
+ // everything stays open.
232
+ const COLLAPSE_STORAGE = "reviewzy.list-collapsed";
233
+
234
+ function readCollapsed() {
235
+ try {
236
+ const raw = localStorage.getItem(COLLAPSE_STORAGE);
237
+ const parsed = raw ? JSON.parse(raw) : {};
238
+ // A parsed array is not a key map: property sets on it would survive only until the next
239
+ // JSON.stringify, which drops them, so persistence would silently stop.
240
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
241
+ } catch {
242
+ return {};
243
+ }
244
+ }
245
+
246
+ function persistCollapsed(key, collapsed) {
247
+ const map = readCollapsed();
248
+ if (collapsed) {
249
+ map[key] = true;
250
+ } else {
251
+ delete map[key];
252
+ }
253
+ try {
254
+ localStorage.setItem(COLLAPSE_STORAGE, JSON.stringify(map));
255
+ } catch {
256
+ // Storage can be unavailable (private windows); the toggle still works for the page.
257
+ }
258
+ }
259
+
260
+ function applyCollapseState() {
261
+ const collapsed = readCollapsed();
262
+ for (const toggle of document.querySelectorAll("[data-collapse-toggle]")) {
263
+ const key = toggle.dataset.collapseKey ?? "";
264
+ const target = document.getElementById(toggle.dataset.collapseTarget ?? "");
265
+ if (!target) continue;
266
+ const hidden = collapsed[key] === true;
267
+ target.hidden = hidden;
268
+ toggle.setAttribute("aria-expanded", String(!hidden));
269
+ }
270
+ }
271
+
272
+ document.addEventListener("click", (event) => {
273
+ const target = event.target instanceof Element ? event.target : null;
274
+ const toggle = target ? target.closest("[data-collapse-toggle]") : null;
275
+ if (!toggle) return;
276
+ const el = document.getElementById(toggle.dataset.collapseTarget ?? "");
277
+ if (!el) return;
278
+ const hide = !el.hidden;
279
+ el.hidden = hide;
280
+ toggle.setAttribute("aria-expanded", String(!hide));
281
+ persistCollapsed(toggle.dataset.collapseKey ?? "", hide);
282
+ });
283
+
284
+ applyCollapseState();
285
+ document.addEventListener("htmx:afterSwap", applyCollapseState);
286
+ document.addEventListener("htmx:afterSwap", initTables);
169
287
  })();