reviewzy 0.1.0 → 0.2.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.
@@ -6,14 +6,16 @@
6
6
  .app-shell { min-height: 100vh; display: flex; flex-direction: column; }
7
7
  .main { padding: var(--space-10) var(--space-12); max-width: 1400px; margin: 0 auto; width: 100%; }
8
8
 
9
- /* ---- Page header: the eyebrow / title / lede triplet ---- */
9
+ /* ---- Page header: the eyebrow / title / lede triplet (login/editor pages) ---- */
10
10
  .page-header { margin-bottom: var(--space-8); padding-bottom: var(--space-5); border-bottom: 1px solid var(--line); }
11
11
  .page-eyebrow { color: var(--accent); margin-bottom: var(--space-2); }
12
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
13
  .page-lede { font-size: var(--text-base); color: var(--text-dim); max-width: 540px; }
14
+ /* The entries header is just the title plus the total count, no eyebrow or lede. */
15
+ .page-count { font-size: var(--text-sm); color: var(--text-dim); }
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 ---- */
@@ -35,52 +37,141 @@
35
37
  .partial-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-4); }
36
38
  .partial-count { font-size: var(--text-sm); color: var(--text-dim); }
37
39
 
40
+ /* ---- Screen-reader-only ---- */
41
+ /* Present in the DOM and the accessibility tree, absent from layout, and never focusable. */
42
+ .visually-hidden {
43
+ position: absolute;
44
+ width: 1px;
45
+ height: 1px;
46
+ margin: -1px;
47
+ padding: 0;
48
+ overflow: hidden;
49
+ clip: rect(0 0 0 0);
50
+ white-space: nowrap;
51
+ border: 0;
52
+ }
53
+
38
54
  /* ---- Project groups ---- */
39
55
  .project-group { margin-bottom: var(--space-10); }
40
56
  .project-header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-4); }
41
57
  .project-name { font: 500 var(--text-lg)/1.2 var(--font-mono); color: var(--text); letter-spacing: -0.01em; }
42
58
  .batch { margin-bottom: var(--space-6); }
43
59
  .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); }
60
+ .batch-meta { font: 400 var(--text-xs)/1 var(--font-sans); color: var(--text-faint); }
45
61
 
46
62
  /* ---- 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);
63
+ The table lays out fixed with a colgroup so the title column takes the remainder and the row
64
+ never overflows; select, status, file, and actions are the fixed columns. The status pill has
65
+ its own column, so the title truncates at one consistent point and the pill stays tight. */
66
+ .col-select { width: 48px; }
67
+ .col-status { width: 106px; }
68
+ .col-file { width: 200px; }
69
+ .col-actions { width: 120px; }
70
+ .entry-head {
71
+ display: flex;
72
+ align-items: center;
73
+ gap: var(--space-2);
74
+ }
75
+ .entry-title {
76
+ flex: 1 1 auto;
77
+ min-width: 0;
78
+ font: 450 var(--text-base)/1.4 var(--font-sans);
79
+ color: var(--text);
58
80
  white-space: nowrap;
59
81
  overflow: hidden;
60
82
  text-overflow: ellipsis;
61
83
  }
62
- .entry-text {
63
- display: -webkit-box;
64
- -webkit-line-clamp: 2;
65
- -webkit-box-orient: vertical;
84
+ .entry-filer {
85
+ flex-shrink: 0;
86
+ font-size: var(--text-xs);
87
+ color: var(--text-faint);
88
+ white-space: nowrap;
89
+ }
90
+ .cell-file {
91
+ font-family: var(--font-mono);
92
+ font-size: var(--text-xs);
93
+ color: var(--text-faint);
94
+ white-space: nowrap;
66
95
  overflow: hidden;
96
+ text-overflow: ellipsis;
67
97
  }
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
98
 
71
99
  /* ---- Row actions and batch approve ----
72
100
  A control mid-request ignores further activations: htmx marks the requesting element with
73
101
  .htmx-request while the request is in flight, and pointer-events drops the duplicate click a
74
102
  double-click would otherwise fire. */
75
103
  .htmx-request { pointer-events: none; }
76
- .cell-select { width: 44px; }
77
- .cell-select input[type="checkbox"] { accent-color: var(--accent); }
104
+ /* The cloudy-ui custom checkbox: an appearance:none box on the sunk plane, accent-filled with a
105
+ clip-path check when checked. The select-all also takes a dash in its indeterminate state (a
106
+ partially selected batch), which the native accent-color box could not express. */
107
+ .cell-select input[type="checkbox"],
108
+ .batch-header .select-all {
109
+ width: 15px;
110
+ height: 15px;
111
+ appearance: none;
112
+ background: var(--bg-sunken);
113
+ border: 1px solid var(--line);
114
+ border-radius: calc(var(--radius) * 0.5);
115
+ cursor: pointer;
116
+ flex-shrink: 0;
117
+ position: relative;
118
+ transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
119
+ }
120
+ .cell-select input[type="checkbox"]:hover,
121
+ .batch-header .select-all:hover { border-color: var(--line-strong); }
122
+ .cell-select input[type="checkbox"]:checked,
123
+ .batch-header .select-all:checked,
124
+ .cell-select input[type="checkbox"]:indeterminate,
125
+ .batch-header .select-all:indeterminate {
126
+ background: var(--accent);
127
+ border-color: var(--accent);
128
+ }
129
+ .cell-select input[type="checkbox"]:checked::after,
130
+ .batch-header .select-all:checked::after {
131
+ content: '';
132
+ position: absolute;
133
+ inset: 3px;
134
+ background: var(--text-on-accent);
135
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
136
+ }
137
+ .cell-select input[type="checkbox"]:indeterminate::after,
138
+ .batch-header .select-all:indeterminate::after {
139
+ content: '';
140
+ position: absolute;
141
+ left: 3px;
142
+ right: 3px;
143
+ top: 50%;
144
+ height: 2px;
145
+ transform: translateY(-50%);
146
+ background: var(--text-on-accent);
147
+ border-radius: 1px;
148
+ }
78
149
  .cell-actions { white-space: nowrap; }
79
150
  .cell-actions .btn + .btn { margin-left: var(--space-2); }
80
- .batch-bar { display: flex; align-items: center; margin-bottom: var(--space-4); }
151
+ .table-wrap tbody td.cell-actions { padding-left: var(--space-1); padding-right: var(--space-1); }
152
+ .batch-bar {
153
+ display: flex;
154
+ align-items: center;
155
+ margin-bottom: var(--space-4);
156
+ /* Sticks just below the 56px navbar so the approve button stays reachable while rows scroll
157
+ under it; the background and z-index stop table rows from showing through the bar. */
158
+ position: sticky;
159
+ top: 56px;
160
+ z-index: 10;
161
+ padding: var(--space-2) 0;
162
+ background: var(--bg);
163
+ }
164
+ .batch-header .select-all { align-self: center; }
165
+ /* ---- Collapsible project sections and batches ----
166
+ The chevron points down expanded and turns when collapsed; the state is applied by dashboard.js
167
+ from localStorage, so the server ships everything open and the viewer's choice re-applies. */
168
+ .collapse-toggle { align-self: center; }
169
+ .collapse-toggle svg { transition: transform var(--dur-fast) var(--ease); }
170
+ .collapse-toggle[aria-expanded="false"] svg { transform: rotate(-90deg); }
171
+ .project-header .collapse-toggle, .batch-header .collapse-toggle { margin-left: auto; }
81
172
  .notice-list { margin: 0; padding-left: var(--space-5); display: flex; flex-direction: column; gap: var(--space-1); }
82
173
 
83
- /* ---- Diff view: the anchored snippet vs the entry's text, mono on sunken, anchor line distinct ---- */
174
+ /* ---- Diff view: the anchored snippet vs the entry's text, mono on sunken, anchor distinct ---- */
84
175
  .diff-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); margin-bottom: var(--space-8); }
85
176
  @media (max-width: 900px) { .diff-grid { grid-template-columns: 1fr; } }
86
177
  .diff-code {
@@ -107,37 +198,61 @@
107
198
  .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
199
  .empty-state h3 { font: 500 var(--text-lg)/1.3 var(--font-sans); color: var(--text); margin-bottom: var(--space-2); }
109
200
  .empty-state p { font-size: var(--text-sm); color: var(--text-dim); max-width: 420px; margin: 0 auto var(--space-4); }
201
+ .empty-actions { display: flex; gap: var(--space-2); justify-content: center; }
110
202
 
111
203
  /* ---- Error box (cloned from #error-box by dashboard.js) ---- */
112
204
  .callout-content { min-width: 0; }
113
205
  .callout-actions { margin-top: var(--space-2); }
114
206
 
115
207
  /* ---- 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; }
208
+ /* The one live region, kept out of sight by the shared .visually-hidden utility above. */
209
+ .editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-8); align-items: start; }
129
210
  .editor-main { min-width: 0; }
130
211
  .editor-rail { display: flex; flex-direction: column; gap: var(--space-6); position: sticky; top: var(--space-8); }
131
212
  @media (max-width: 900px) {
132
213
  .editor-grid { grid-template-columns: 1fr; }
133
214
  .editor-rail { position: static; }
134
215
  }
216
+ /* The editor's title card header: the title falls back to the anchor text, which can be one long
217
+ unbroken string, so it wraps inside the card instead of overflowing or shoving the status pill. */
218
+ .editor-title { min-width: 0; overflow-wrap: anywhere; }
219
+ /* The entry text ships verbatim, often carrying markdown backticks and significant whitespace;
220
+ mono gives every glyph, space included, one full cell so both stay countable while editing. */
221
+ #editor-text { font-family: var(--font-mono); }
222
+
223
+ /* ---- Editor prev/next rails ----
224
+ One full-height fixed strip per side, below the 56px navbar, so the previous and next entries are
225
+ a single click away while reading. The strip sits inside the 48px page padding, clear of content. */
226
+ .editor-nav-btn {
227
+ position: fixed;
228
+ top: 56px;
229
+ bottom: 0;
230
+ width: 44px;
231
+ display: flex;
232
+ align-items: center;
233
+ justify-content: center;
234
+ color: var(--text-dim);
235
+ text-decoration: none;
236
+ z-index: 40;
237
+ transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
238
+ }
239
+ .editor-nav-prev { left: 0; }
240
+ .editor-nav-next { right: 0; }
241
+ .editor-nav-btn:hover { background: var(--bg-hover); color: var(--text); }
242
+ .editor-nav-btn.is-disabled { color: var(--text-faint); opacity: 0.4; pointer-events: none; }
243
+ .editor-nav-btn svg { width: 18px; height: 18px; }
244
+ @media (max-width: 900px) { .editor-nav { display: none; } }
135
245
  #editor-region > .callout { margin-bottom: var(--space-4); }
136
246
  #editor-region > .btn { margin-top: var(--space-4); }
137
247
  .editor-actions { margin-top: var(--space-4); display: flex; align-items: center; gap: var(--space-2); }
138
248
  /* The in-flight indicator for an approve or reject from the editor: htmx's own rules fade it in. */
139
249
  .editor-actions .htmx-indicator { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); color: var(--text-dim); }
140
250
 
251
+ /* The notes card: the agent's row reuses the constraint row shape; the human's textarea and its
252
+ save button sit below it. */
253
+ .notes-field { margin-top: var(--space-2); }
254
+ .notes-actions { margin-top: var(--space-2); display: flex; justify-content: flex-end; }
255
+
141
256
  /* Constraint panel: live count and placeholder checklist; the server owns the refusal. */
142
257
  .constraint-row { display: flex; gap: var(--space-3); padding: 6px 0; }
143
258
  .constraint-row + .constraint-row { border-top: 1px solid var(--line); }
@@ -149,8 +264,28 @@
149
264
  .placeholder-item { display: flex; align-items: center; gap: var(--space-2); }
150
265
  .placeholder-state { font-size: var(--text-xs); color: var(--text-dim); min-width: 52px; }
151
266
  .placeholder-state.is-present { color: var(--success); }
267
+
268
+ /* The context card's secondary block: the provenance rows (hash, filer, stale note) collapsed
269
+ behind a summary, so the anchor and the agent-supplied context rows lead the card. */
270
+ .context-secondary { margin-top: var(--space-3); }
271
+ .context-secondary summary { cursor: pointer; font-size: var(--text-xs); color: var(--text-dim); }
272
+ .context-secondary .info-table { margin-top: var(--space-2); }
273
+
274
+ /* Repo row link: the brand icon beside the shortened owner/repo path, linking out to the host. */
275
+ .repo-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text); text-decoration: none; }
276
+ .repo-link:hover { color: var(--accent); }
277
+ .repo-icon { width: 14px; height: 14px; flex-shrink: 0; fill: currentColor; opacity: 0.7; }
152
278
  .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; }
153
279
 
280
+ /* Entry images: one image per row at the card's width, a remove form below each, and the
281
+ one-file upload form closing the card. The file input flexes to whatever width remains. */
282
+ .image-list { list-style: none; margin: 0 0 var(--space-3); padding: 0; display: flex; flex-direction: column; gap: var(--space-3); }
283
+ .entry-image { display: block; max-width: 100%; height: auto; border: 1px solid var(--line); border-radius: var(--radius); }
284
+ .image-skip { margin: 0; font-size: var(--text-xs); color: var(--text-dim); }
285
+ .image-remove { margin-top: var(--space-1); }
286
+ .image-upload { display: flex; gap: var(--space-2); align-items: center; }
287
+ .image-upload .input { flex: 1; min-width: 0; }
288
+
154
289
  /* Revision history: newest first; the text clamps like the list's entry text. */
155
290
  .history-empty { font-size: var(--text-sm); color: var(--text-faint); padding: var(--space-2) 0; }
156
291
  .revision-list { list-style: none; margin: 0; padding: 0; }
@@ -60,6 +60,34 @@
60
60
  form.addEventListener("htmx:sendError", showFilterError);
61
61
  }
62
62
 
63
+ // --- Filter auto-submit ---
64
+ // The Apply button is gone: changing a select or typing in the search fires the filter request
65
+ // itself. requestSubmit() reaches htmx's submit listener on the form; the debounce collapses a
66
+ // burst of keystrokes into one request. Only the change and input events below trigger a submit,
67
+ // and both fire on user action alone, so a programmatic value reset (which sets .value directly
68
+ // and emits no event) never re-fires.
69
+ function submitFilters() {
70
+ const target = document.getElementById("filters");
71
+ if (target && typeof target.requestSubmit === "function") {
72
+ target.requestSubmit();
73
+ }
74
+ }
75
+
76
+ let searchTimer;
77
+ function scheduleSearchSubmit() {
78
+ clearTimeout(searchTimer);
79
+ searchTimer = setTimeout(submitFilters, 300);
80
+ }
81
+
82
+ if (form) {
83
+ const statusSelect = document.getElementById("status");
84
+ const projectSelect = document.getElementById("project");
85
+ const searchInput = document.getElementById("q");
86
+ statusSelect?.addEventListener("change", submitFilters);
87
+ projectSelect?.addEventListener("change", submitFilters);
88
+ searchInput?.addEventListener("input", scheduleSearchSubmit);
89
+ }
90
+
63
91
  // --- Style guide save errors ---
64
92
  // A failed save (HTTP error or network error) puts the error box from the page's
65
93
  // #style-guide-error-box template above the form; the retry button re-submits the save form.
@@ -166,4 +194,117 @@
166
194
  refreshEditorMetrics();
167
195
  textarea.focus();
168
196
  });
197
+
198
+ // --- Batch select-all and live selection count ---
199
+ // The list region is swapped wholesale on every filter and action round, so no listener bound to
200
+ // a checkbox or the button survives a swap: change events are delegated on document, and one pass
201
+ // after each swap recomputes the select-all states and the approve button's count. All of it is
202
+ // progressive enhancement — without JS the approve button keeps its static label, stays enabled,
203
+ // and the native form still submits the checked rows.
204
+ const DRAFT_SELECTOR = 'input[type="checkbox"][name="id"]';
205
+ const SELECT_ALL_SELECTOR = 'input[type="checkbox"].select-all';
206
+
207
+ function selectionCount() {
208
+ return document.querySelectorAll(`#batch-form ${DRAFT_SELECTOR}:checked`).length;
209
+ }
210
+
211
+ function updateApproveButton() {
212
+ const button = document.querySelector('#batch-form .batch-bar button[type="submit"]');
213
+ if (!button) return;
214
+ const count = selectionCount();
215
+ button.textContent = count === 0 ? "Approve selected" : `Approve ${count}`;
216
+ button.disabled = count === 0;
217
+ }
218
+
219
+ function updateSelectAll(selectAll) {
220
+ const batch = selectAll.closest(".batch");
221
+ if (!batch) return;
222
+ const drafts = batch.querySelectorAll(DRAFT_SELECTOR);
223
+ const checked = batch.querySelectorAll(`${DRAFT_SELECTOR}:checked`).length;
224
+ selectAll.checked = drafts.length > 0 && checked === drafts.length;
225
+ selectAll.indeterminate = checked > 0 && checked < drafts.length;
226
+ }
227
+
228
+ function refreshSelection() {
229
+ for (const selectAll of document.querySelectorAll(SELECT_ALL_SELECTOR)) {
230
+ updateSelectAll(selectAll);
231
+ }
232
+ updateApproveButton();
233
+ }
234
+
235
+ document.addEventListener("change", (event) => {
236
+ const target = event.target instanceof Element ? event.target : null;
237
+ if (!target) return;
238
+ if (target.matches(SELECT_ALL_SELECTOR)) {
239
+ const batch = target.closest(".batch");
240
+ if (batch) {
241
+ for (const draft of batch.querySelectorAll(DRAFT_SELECTOR)) {
242
+ draft.checked = target.checked;
243
+ }
244
+ }
245
+ }
246
+ refreshSelection();
247
+ });
248
+
249
+ document.addEventListener("htmx:afterSwap", refreshSelection);
250
+
251
+ // --- Collapsible project sections and batches ---
252
+ // The server renders every section and batch open; JS collapses the ones this viewer collapsed
253
+ // before, from localStorage, and persists each toggle. Re-applied after every swap because a
254
+ // filter or action round replaces the whole list region. Without JS the toggles stay inert and
255
+ // everything stays open.
256
+ const COLLAPSE_STORAGE = "reviewzy.list-collapsed";
257
+
258
+ function readCollapsed() {
259
+ try {
260
+ const raw = localStorage.getItem(COLLAPSE_STORAGE);
261
+ const parsed = raw ? JSON.parse(raw) : {};
262
+ // A parsed array is not a key map: property sets on it would survive only until the next
263
+ // JSON.stringify, which drops them, so persistence would silently stop.
264
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
265
+ } catch {
266
+ return {};
267
+ }
268
+ }
269
+
270
+ function persistCollapsed(key, collapsed) {
271
+ const map = readCollapsed();
272
+ if (collapsed) {
273
+ map[key] = true;
274
+ } else {
275
+ delete map[key];
276
+ }
277
+ try {
278
+ localStorage.setItem(COLLAPSE_STORAGE, JSON.stringify(map));
279
+ } catch {
280
+ // Storage can be unavailable (private windows); the toggle still works for the page.
281
+ }
282
+ }
283
+
284
+ function applyCollapseState() {
285
+ const collapsed = readCollapsed();
286
+ for (const toggle of document.querySelectorAll("[data-collapse-toggle]")) {
287
+ const key = toggle.dataset.collapseKey ?? "";
288
+ const target = document.getElementById(toggle.dataset.collapseTarget ?? "");
289
+ if (!target) continue;
290
+ const hidden = collapsed[key] === true;
291
+ target.hidden = hidden;
292
+ toggle.setAttribute("aria-expanded", String(!hidden));
293
+ }
294
+ }
295
+
296
+ document.addEventListener("click", (event) => {
297
+ const target = event.target instanceof Element ? event.target : null;
298
+ const toggle = target ? target.closest("[data-collapse-toggle]") : null;
299
+ if (!toggle) return;
300
+ const el = document.getElementById(toggle.dataset.collapseTarget ?? "");
301
+ if (!el) return;
302
+ const hide = !el.hidden;
303
+ el.hidden = hide;
304
+ toggle.setAttribute("aria-expanded", String(!hide));
305
+ persistCollapsed(toggle.dataset.collapseKey ?? "", hide);
306
+ });
307
+
308
+ applyCollapseState();
309
+ document.addEventListener("htmx:afterSwap", applyCollapseState);
169
310
  })();