reviewzy 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.
@@ -1,12 +1,14 @@
1
1
  /** @jsxImportSource hono/jsx */
2
2
  import { readFileSync } from "node:fs";
3
3
  import { raw } from "hono/html";
4
+ import type { Child } from "hono/jsx";
4
5
  import type { JSX } from "hono/jsx/jsx-runtime";
5
6
  import { listRevisions, parseConstraints, parseEntryImages } from "../db/human-save.ts";
6
7
  import type { Constraints, HumanSaveRefusal, RevisionRow, TransitionRefusal } from "../db/human-save.ts";
7
8
  import {
8
9
  adjacentEntryIds,
9
10
  countEntries,
11
+ countEntriesByStatus,
10
12
  listEntries,
11
13
  listProjects,
12
14
  projectIdBySlug,
@@ -17,6 +19,7 @@ import type { StyleGuideForm, StyleGuideFormRefusal, StyleGuideInput } from "../
17
19
  import type { Store } from "../db/store.ts";
18
20
  import { parseRepoLink, REPO_ICON } from "./repo-link.ts";
19
21
  import { diffWords } from "./word-diff.ts";
22
+ import type { WordDiffToken } from "./word-diff.ts";
20
23
  import { NAME } from "../version.ts";
21
24
 
22
25
  /**
@@ -35,7 +38,7 @@ const PAGE_SIZE = 200;
35
38
 
36
39
  const STATUSES: readonly EntryStatus[] = ["draft", "approved", "applied", "rejected"];
37
40
 
38
- /** The default Active view: the two statuses that still need a human. */
41
+ /** The default Open view: the two statuses that still need a human. */
39
42
  const ACTIVE_STATUSES: readonly EntryStatus[] = ["draft", "approved"];
40
43
 
41
44
  function isEntryStatus(value: string): value is EntryStatus {
@@ -56,11 +59,20 @@ const STATUS_LABEL: Record<EntryStatus, string> = {
56
59
  rejected: "Rejected",
57
60
  };
58
61
 
59
- type ProjectBatch = { id: string; entries: EntryRow[] };
60
- type ProjectGroup = { slug: string; batches: ProjectBatch[] };
62
+ /** The timeline dot variant per revision status, matching the tag's own semantic. */
63
+ const STATUS_DOT: Record<EntryStatus, string> = {
64
+ draft: "timeline-dot-warning",
65
+ approved: "timeline-dot-success",
66
+ applied: "timeline-dot-accent",
67
+ rejected: "timeline-dot-danger",
68
+ };
69
+
70
+ type ProjectGroup = { slug: string; entries: EntryRow[] };
61
71
 
62
72
  export type ListViewModel = {
63
73
  readonly total: number;
74
+ /** The whole store's counts per status; the status tabs read these, never the filtered rows. */
75
+ readonly counts: Record<EntryStatus, number>;
64
76
  readonly shown: number;
65
77
  readonly groups: readonly ProjectGroup[];
66
78
  readonly projects: readonly { id: string; slug: string }[];
@@ -69,7 +81,7 @@ export type ListViewModel = {
69
81
  readonly status: string;
70
82
  readonly project: string;
71
83
  readonly filtersActive: boolean;
72
- /** Whether the default Active view is what hides every match: a filter found rows, but all of them are applied or rejected. */
84
+ /** Whether the default Open view is what hides every match: a filter found rows, but all of them are applied or rejected. */
73
85
  readonly hiddenByActiveView: boolean;
74
86
  };
75
87
 
@@ -96,63 +108,19 @@ function allEntries(
96
108
  }
97
109
 
98
110
  /**
99
- * Groups the filtered rows by project (slug order) then batch; within a batch the rows keep the
100
- * list query's ascending id order. Within a project, batches split into two partitions: any batch
101
- * holding a draft or approved entry is actionable and leads, terminal-only batches (applied and
102
- * rejected) follow. Each partition sorts newest batch first, with a deterministic order for
103
- * batches minted in the same millisecond, so the same filter always renders the same page.
111
+ * Groups the filtered rows by project, projects in slug order. Within a project the rows keep
112
+ * the list query's ascending id order: ulids sort chronologically, so the oldest filing leads.
104
113
  */
105
114
  function groupRows(rows: readonly EntryRow[], slugOf: Map<string, string>): ProjectGroup[] {
106
115
  const byProject = new Map<string, EntryRow[]>();
107
116
  for (const row of rows) {
108
117
  byProject.set(row.project_id, [...(byProject.get(row.project_id) ?? []), row]);
109
118
  }
110
- const projectIds = [...byProject.keys()].sort((a, b) => (slugOf.get(a) ?? a).localeCompare(slugOf.get(b) ?? b));
111
- return projectIds.map((projectId) => {
112
- const projectRows = byProject.get(projectId)!;
113
- const byBatch = new Map<string, EntryRow[]>();
114
- for (const row of projectRows) {
115
- byBatch.set(row.batch_id, [...(byBatch.get(row.batch_id) ?? []), row]);
116
- }
117
- const batches = [...byBatch.keys()].map((batchId) => ({ id: batchId, entries: byBatch.get(batchId)! }));
118
- const actionable = batches.filter(isActionableBatch).sort(newestBatchFirst);
119
- const terminal = batches.filter((batch) => !isActionableBatch(batch)).sort(newestBatchFirst);
120
- return { slug: slugOf.get(projectId) ?? projectId, batches: [...actionable, ...terminal] };
121
- });
122
- }
123
-
124
- /** A batch is actionable when any of its entries still needs a human: draft or approved. */
125
- function isActionableBatch(batch: ProjectBatch): boolean {
126
- return batch.entries.some((entry) => entry.status === "draft" || entry.status === "approved");
127
- }
128
-
129
- /** Code-unit descending comparison: ulids are fixed-length Crockford base32, so this equals time order. */
130
- function compareDesc(a: string, b: string): number {
131
- return a < b ? 1 : a > b ? -1 : 0;
132
- }
133
-
134
- /** The newest `created_at` in a batch, walked: a batch can hold thousands of entries, and a spread would overflow the call stack. */
135
- function maxCreatedAt(batch: ProjectBatch): number {
136
- let max = 0;
137
- for (const entry of batch.entries) {
138
- if (entry.created_at > max) max = entry.created_at;
139
- }
140
- return max;
141
- }
142
-
143
- /**
144
- * Newest batch first. Batch ulids share a millisecond timestamp prefix, so a descending id is
145
- * newest-first; batches minted in the same millisecond tie on that prefix, and the batch's
146
- * newest `created_at` breaks the tie before the id itself, so a same-ms burst still renders in
147
- * one fixed order.
148
- */
149
- function newestBatchFirst(a: ProjectBatch, b: ProjectBatch): number {
150
- const timeCmp = compareDesc(a.id.slice(0, 10), b.id.slice(0, 10));
151
- if (timeCmp !== 0) return timeCmp;
152
- const aCreated = maxCreatedAt(a);
153
- const bCreated = maxCreatedAt(b);
154
- if (aCreated !== bCreated) return bCreated - aCreated;
155
- return compareDesc(a.id, b.id);
119
+ const groups = [...byProject].map(([projectId, projectRows]) => ({
120
+ slug: slugOf.get(projectId) ?? projectId,
121
+ entries: projectRows,
122
+ }));
123
+ return groups.sort((a, b) => a.slug.localeCompare(b.slug));
156
124
  }
157
125
 
158
126
  function loadList(store: Store, params: ListParams): ListViewModel {
@@ -161,8 +129,8 @@ function loadList(store: Store, params: ListParams): ListViewModel {
161
129
  const statusRaw = params.status;
162
130
  const statusActive = statusRaw !== undefined && statusRaw !== "";
163
131
  // `all` is the explicit "every status" filter value; the default (no status param, or an empty
164
- // one) is the Active view of draft + approved only, which is not a filter and never renders the
165
- // partial header. Both query every status; the Active view narrows the walked rows afterward.
132
+ // one) is the Open view of draft + approved only, which is not a filter and never renders the
133
+ // partial header. Both query every status; the Open view narrows the walked rows afterward.
166
134
  const allStatuses = statusActive && statusRaw === "all";
167
135
  const status = statusActive && !allStatuses && isEntryStatus(statusRaw) ? statusRaw : undefined;
168
136
  const projectRaw = params.project;
@@ -178,6 +146,7 @@ function loadList(store: Store, params: ListParams): ListViewModel {
178
146
  const slugOf = new Map(projects.map((project) => [project.id, project.slug]));
179
147
  return {
180
148
  total: countEntries(store),
149
+ counts: countEntriesByStatus(store),
181
150
  shown: rows.length,
182
151
  groups: groupRows(rows, slugOf),
183
152
  projects,
@@ -207,52 +176,282 @@ function entryTitle(entry: EntryRow): string {
207
176
  return fileBasename(entry.file);
208
177
  }
209
178
 
210
- /** The batch ulid shortened to head+tail, enough to name each batch's select-all checkbox distinctly for a screen reader. */
211
- function shortUlid(id: string): string {
212
- return `${id.slice(0, 6)}…${id.slice(-6)}`;
179
+ /** Whether the displayed title is the agent-supplied one, so the anchor text it stands in for is otherwise unread in the list. */
180
+ function hasOwnTitle(entry: EntryRow): boolean {
181
+ const title = entry.title?.trim();
182
+ return title !== undefined && title !== "";
213
183
  }
214
184
 
215
- /** The section the current page belongs to, marking the matching navbar link. */
216
- type NavSection = "entries" | "style-guide" | "none";
185
+ /** The section the current page belongs to, marking the matching nav link. */
186
+ type NavSection = "entries" | "style-guide";
217
187
 
218
- /** The icon sprite, pasted into every full page's body as its first child, read off the vendored file so the two can never drift. */
188
+ /** The icon sprite, pasted into every full page's body, read off the vendored file so the two can never drift. */
219
189
  const ICON_SPRITE = readFileSync(new URL("./static/icons.svg", import.meta.url), "utf8");
220
190
 
221
- function Navbar({ signedIn, active }: { signedIn: boolean; active: NavSection }) {
191
+ /** The theme toggle, once per shell: ui.js's initTheme wires it and owns the pressed state. */
192
+ function ThemeToggle() {
222
193
  return (
223
- <nav class="navbar">
224
- <div class="navbar-inner">
225
- <div class="navbar-brand">
226
- <div class="logo-name">{NAME}</div>
227
- </div>
228
- <div class="navbar-nav" id="navbar-nav">
229
- <a class={`navbar-link${active === "entries" ? " active" : ""}`} href="/">Entries</a>
230
- <a class={`navbar-link${active === "style-guide" ? " active" : ""}`} href="/style-guide">Style guide</a>
231
- </div>
232
- {signedIn ? (
233
- <form method="post" action="/logout" class="navbar-actions">
234
- <button class="btn btn-secondary btn-sm" type="submit">Sign out</button>
235
- </form>
236
- ) : null}
194
+ <button class="btn btn-icon btn-sm" data-theme-toggle aria-pressed="false" aria-label="Switch to light theme">
195
+ <svg class="icon-sun" aria-hidden="true"><use href="#i-sun" /></svg>
196
+ <svg class="icon-moon" aria-hidden="true"><use href="#i-moon" /></svg>
197
+ </button>
198
+ );
199
+ }
200
+
201
+ /**
202
+ * The queue's draft count. The htmx list fragment re-renders it out of band, so an action round
203
+ * that moves a draft cannot leave the sidebar stale.
204
+ */
205
+ function DraftBadge({ count, oob = false }: { count: number; oob?: boolean }) {
206
+ return (
207
+ <span class="nav-badge" id="entries-badge" hx-swap-oob={oob ? "true" : undefined}>
208
+ {count}
209
+ </span>
210
+ );
211
+ }
212
+
213
+ /**
214
+ * The app shell every full page shares: the mobile topbar and overlay outside the grid, then the
215
+ * sidebar and the page column. Under 768px the same `.sidebar` element is the drawer, which is
216
+ * why there is no second navigation tree and why ui.js's drawer and rail controllers both act on
217
+ * this one node.
218
+ */
219
+ function Shell({
220
+ signedIn,
221
+ active,
222
+ draftCount,
223
+ children,
224
+ }: {
225
+ signedIn: boolean;
226
+ active: NavSection;
227
+ draftCount: number;
228
+ children: Child;
229
+ }) {
230
+ return (
231
+ <>
232
+ <div class="mobile-topbar">
233
+ <div class="logo-name">{NAME}</div>
234
+ <button
235
+ type="button"
236
+ class="hamburger"
237
+ id="hamburger-btn"
238
+ aria-label="Open navigation"
239
+ aria-expanded="false"
240
+ aria-controls="sidebar"
241
+ >
242
+ <svg aria-hidden="true"><use href="#i-menu" /></svg>
243
+ </button>
237
244
  </div>
238
- </nav>
245
+ <div class="sidebar-overlay" id="sidebar-overlay"></div>
246
+ <div class="layout">
247
+ <nav class="sidebar" id="sidebar" aria-label="Dashboard">
248
+ <div class="sidebar-logo">
249
+ <div><div class="logo-name">{NAME}</div></div>
250
+ <button
251
+ class="sidebar-collapse"
252
+ type="button"
253
+ aria-controls="sidebar"
254
+ aria-expanded="true"
255
+ aria-label="Collapse sidebar"
256
+ >
257
+ <svg aria-hidden="true"><use href="#i-chevron-left" /></svg>
258
+ </button>
259
+ </div>
260
+ <div class="sidebar-nav">
261
+ <div class="nav-group">
262
+ <div class="nav-group-label">Queue</div>
263
+ <a
264
+ class={`nav-item${active === "entries" ? " active" : ""}`}
265
+ href="/"
266
+ aria-current={active === "entries" ? "true" : undefined}
267
+ >
268
+ <span>Entries</span>
269
+ <DraftBadge count={draftCount} />
270
+ </a>
271
+ </div>
272
+ <div class="nav-group">
273
+ <div class="nav-group-label">Content</div>
274
+ <a
275
+ class={`nav-item${active === "style-guide" ? " active" : ""}`}
276
+ href="/style-guide"
277
+ aria-current={active === "style-guide" ? "true" : undefined}
278
+ >
279
+ <span>Style guide</span>
280
+ </a>
281
+ </div>
282
+ </div>
283
+ <div class="sidebar-user">
284
+ <ThemeToggle />
285
+ {signedIn ? (
286
+ <form method="post" action="/logout">
287
+ <button class="btn btn-secondary btn-sm" type="submit">Sign out</button>
288
+ </form>
289
+ ) : null}
290
+ </div>
291
+ </nav>
292
+ <main class="page" id="main">{children}</main>
293
+ </div>
294
+ </>
295
+ );
296
+ }
297
+
298
+ /**
299
+ * The queue's header meta: the actionable count, the store total, and the project count. The
300
+ * htmx list fragment re-renders it out of band, so an action round moves the numbers with it.
301
+ */
302
+ function PageMeta({
303
+ draftCount,
304
+ total,
305
+ projects,
306
+ oob = false,
307
+ }: {
308
+ draftCount: number;
309
+ total: number;
310
+ projects: number;
311
+ oob?: boolean;
312
+ }) {
313
+ return (
314
+ <div class="page-meta" id="page-meta" hx-swap-oob={oob ? "true" : undefined}>
315
+ <span class="page-meta-item">
316
+ <span class="num">{draftCount}</span> {draftCount === 1 ? "draft waiting" : "drafts waiting"}
317
+ </span>
318
+ <span class="page-meta-item">
319
+ <span class="num">{total}</span> {total === 1 ? "entry" : "entries"}
320
+ </span>
321
+ <span class="page-meta-item">
322
+ <span class="num">{projects}</span> {projects === 1 ? "project" : "projects"}
323
+ </span>
324
+ </div>
325
+ );
326
+ }
327
+
328
+ /**
329
+ * The one status control: an underline tab strip of submit buttons inside the filters form, so a
330
+ * click submits the form with its `status` value with or without JS. The Open tab is the default
331
+ * view (draft + approved) and carries no filter value; the badge counts come off the store's one
332
+ * grouped read, never the filtered rows. In the htmx fragment the strip rides an out-of-band swap,
333
+ * since the list region is the only main target.
334
+ */
335
+ function StatusTabs({ counts, status, oob = false }: { counts: Record<EntryStatus, number>; status: string; oob?: boolean }) {
336
+ const open = counts.draft + counts.approved;
337
+ return (
338
+ <div class="tabs-wrap status-tabs" id="status-tabs" hx-swap-oob={oob ? "true" : undefined}>
339
+ <div class="tabs" role="group" aria-label="Status">
340
+ <button
341
+ class={`tab${status === "" ? " active" : ""}`}
342
+ type="submit"
343
+ name="status"
344
+ value=""
345
+ aria-current={status === "" ? "true" : undefined}
346
+ >
347
+ <span>Open</span><span class="tab-badge num">{open}</span>
348
+ </button>
349
+ {STATUSES.map((s) => (
350
+ <button
351
+ class={`tab${status === s ? " active" : ""}`}
352
+ type="submit"
353
+ name="status"
354
+ value={s}
355
+ aria-current={status === s ? "true" : undefined}
356
+ >
357
+ <span>{STATUS_LABEL[s]}</span><span class="tab-badge num">{counts[s]}</span>
358
+ </button>
359
+ ))}
360
+ </div>
361
+ </div>
362
+ );
363
+ }
364
+
365
+ /**
366
+ * The toolbar's count line: the visible rows over the store total, right-aligned on the filter
367
+ * row, plus the way back to the default view while a filter is active. The htmx fragment
368
+ * re-renders it out of band with every action round.
369
+ */
370
+ function ListMeta({ vm, oob = false }: { vm: ListViewModel; oob?: boolean }) {
371
+ return (
372
+ <div id="list-meta" class="list-meta" hx-swap-oob={oob ? "true" : undefined}>
373
+ <span class="pagination-count num">
374
+ {vm.shown} of {vm.total} {vm.total === 1 ? "entry" : "entries"}
375
+ </span>
376
+ {vm.filtersActive ? <a class="btn btn-secondary btn-sm" href="/">Clear filters</a> : null}
377
+ </div>
239
378
  );
240
379
  }
241
380
 
242
- function PageHeader({ total }: { total: number }) {
381
+ function PageHeader({ vm }: { vm: ListViewModel }) {
243
382
  return (
244
383
  <header class="page-header">
245
384
  <div class="page-header-body">
246
385
  <h1 class="page-title">Entries</h1>
247
- <div class="page-meta">
248
- <span class="page-meta-item">{total} {total === 1 ? "entry" : "entries"}</span>
249
- </div>
386
+ <PageMeta draftCount={vm.counts.draft} total={vm.total} projects={vm.projects.length} />
250
387
  </div>
251
388
  </header>
252
389
  );
253
390
  }
254
391
 
392
+ /**
393
+ * One dropdown filter as the system `.select`: the trigger shows the current label, the menu
394
+ * carries one option per value, and a hidden input carries the submission. The hidden input ships
395
+ * disabled with its name already on it, so a no-JS submit reads the `<noscript>` native selects
396
+ * instead of a stale hidden value; dashboard.js enables the hidden ones once the widgets are live.
397
+ */
398
+ function FilterSelect({
399
+ id,
400
+ label,
401
+ field,
402
+ current,
403
+ options,
404
+ }: {
405
+ id: string;
406
+ label: string;
407
+ field: string;
408
+ current: string;
409
+ options: readonly { value: string; label: string }[];
410
+ }) {
411
+ const currentLabel = options.find((option) => option.value === current)?.label ?? options[0]!.label;
412
+ return (
413
+ <>
414
+ <span class="sr-only" id={`${id}-label`}>{label}</span>
415
+ <div class="select" data-filter={field}>
416
+ <button
417
+ type="button"
418
+ class="input select-trigger"
419
+ role="combobox"
420
+ aria-expanded="false"
421
+ aria-haspopup="listbox"
422
+ aria-labelledby={`${id}-label`}
423
+ >
424
+ <span class="select-value">{currentLabel}</span>
425
+ <svg class="select-caret" aria-hidden="true"><use href="#i-chevron-down" /></svg>
426
+ </button>
427
+ <div class="select-menu" role="listbox" aria-labelledby={`${id}-label`}>
428
+ {options.map((option) => (
429
+ <div
430
+ class="menu-item"
431
+ role="option"
432
+ aria-selected={String(option.value === current)}
433
+ data-value={option.value}
434
+ >
435
+ {option.label}
436
+ </div>
437
+ ))}
438
+ </div>
439
+ </div>
440
+ <input type="hidden" name={field} value={current} data-filter-field={field} disabled />
441
+ </>
442
+ );
443
+ }
444
+
445
+ /**
446
+ * The queue's controls: the status tab strip above the search and project row, and the count line
447
+ * on the row's end edge. The tabs submit the same form, so one GET request carries whatever the
448
+ * clicker set.
449
+ */
255
450
  function FilterForm({ vm }: { vm: ListViewModel }) {
451
+ const projectOptions = [
452
+ { value: "", label: "All projects" },
453
+ ...vm.projects.map((project) => ({ value: project.slug, label: project.slug })),
454
+ ];
256
455
  return (
257
456
  <form
258
457
  id="filters"
@@ -262,83 +461,110 @@ function FilterForm({ vm }: { vm: ListViewModel }) {
262
461
  hx-target="#entries-list"
263
462
  hx-swap="innerHTML"
264
463
  hx-indicator="#entries-loading"
265
- class="filters"
266
464
  >
267
- <div class="field">
268
- <label class="field-label" for="q">Search</label>
269
- <input
270
- class="input"
271
- id="q"
272
- name="q"
273
- type="search"
274
- placeholder="Search file, anchor, or text"
275
- value={vm.q}
276
- />
277
- </div>
278
- <div class="field">
279
- <label class="field-label" for="status">Status</label>
280
- <select class="input" id="status" name="status">
281
- <option value="" selected={vm.status === ""}>Active</option>
282
- <option value="all" selected={vm.status === "all"}>All statuses</option>
283
- <option value="draft" selected={vm.status === "draft"}>Draft</option>
284
- <option value="approved" selected={vm.status === "approved"}>Approved</option>
285
- <option value="applied" selected={vm.status === "applied"}>Applied</option>
286
- <option value="rejected" selected={vm.status === "rejected"}>Rejected</option>
287
- </select>
465
+ {/* The form's first submit button is the default for an implicit submit (Enter in the
466
+ search field without JS): it carries the current status, so Enter never resets the
467
+ filter to Open. Visually hidden and out of the tab order; dashboard.js drops the name
468
+ once the widget path owns the status, so only the hidden field ever submits. */}
469
+ <button class="sr-only" type="submit" name="status" value={vm.status} tabindex={-1} aria-hidden="true">
470
+ Apply filters
471
+ </button>
472
+ {/* The strip's own attributes are the markup contract, so the named scroller that carries
473
+ the five tabs on a narrow screen is this wrapper: it keeps the scroll position when an
474
+ action round swaps the strip inside it. */}
475
+ <div class="status-scroll" data-scroll-fade>
476
+ <StatusTabs counts={vm.counts} status={vm.status} />
288
477
  </div>
289
- <div class="field">
290
- <label class="field-label" for="project">Project</label>
291
- <select class="input" id="project" name="project">
292
- <option value="">All projects</option>
293
- {vm.projects.map((project) => (
294
- <option value={project.slug} selected={vm.project === project.slug}>
295
- {project.slug}
296
- </option>
297
- ))}
298
- </select>
478
+ {/* The selected status the other fields submit under: the tab buttons carry it on a click,
479
+ and dashboard.js mirrors it here so a search or project change cannot silently drop back
480
+ to the Open view. Ships disabled so a no-JS submit reads the clicked tab, never a stale
481
+ hidden value. */}
482
+ <input type="hidden" name="status" value={vm.status} data-filter-field="status" disabled />
483
+ <div class="filter-bar">
484
+ <div class="input-wrap has-icon filter-search">
485
+ <div class="input-icon">
486
+ <svg aria-hidden="true"><use href="#i-search" /></svg>
487
+ </div>
488
+ <label class="sr-only" for="q">Search</label>
489
+ <input
490
+ class="input"
491
+ id="q"
492
+ name="q"
493
+ type="search"
494
+ placeholder="Search file, anchor, or text"
495
+ value={vm.q}
496
+ />
497
+ </div>
498
+ <FilterSelect id="filter-project" label="Project" field="project" current={vm.project} options={projectOptions} />
499
+ {/* The no-JS submit path: a native select carrying the name the hidden input holds, so the
500
+ filters still work with scripting off. With JS on, this block is not in the DOM. */}
501
+ <noscript>
502
+ <label class="sr-only" for="project-native">Project</label>
503
+ <select class="input" id="project-native" name="project">
504
+ {projectOptions.map((option) => (
505
+ <option value={option.value} selected={option.value === vm.project}>{option.label}</option>
506
+ ))}
507
+ </select>
508
+ {/* The explicit submit carries the current status too, since the hidden field above
509
+ ships disabled without JS. */}
510
+ <button class="btn btn-primary" type="submit" name="status" value={vm.status}>Apply filters</button>
511
+ </noscript>
512
+ <ListMeta vm={vm} />
299
513
  </div>
300
- <noscript>
301
- <button class="btn btn-primary" type="submit">Apply filters</button>
302
- </noscript>
303
514
  </form>
304
515
  );
305
516
  }
306
517
 
307
518
  /**
308
- * What an action round settles into, rendered above the list region: a single refusal (one entry
309
- * could not move, why, and the fix) or the batch report (how many moved, each refusal named).
310
- * The route layer builds these from the store's outcomes; the view owns the shapes.
519
+ * What an action round settles into, rendered as the page's one banner: a single refusal (one
520
+ * entry could not move, why, and the fix) or the batch report (how many moved, each refusal
521
+ * named). The route layer builds these from the store's outcomes; the view owns the shapes.
311
522
  */
312
523
  export type ListNotice =
313
524
  | { readonly kind: "refusal"; readonly title: string; readonly message: string }
314
525
  | { readonly kind: "batch"; readonly approved: number; readonly total: number; readonly refused: readonly string[] };
315
526
 
316
- /** The settled rendering of a notice: a success panel when a batch fully moved, an alert otherwise. */
317
- function NoticeCallout({ notice }: { notice: ListNotice }) {
527
+ /** The banner's one dismiss control; ui.js's initDismiss removes the banner it belongs to. */
528
+ function BannerDismiss() {
529
+ return (
530
+ <button type="button" class="close" aria-label="Dismiss">
531
+ <svg aria-hidden="true"><use href="#i-x" /></svg>
532
+ </button>
533
+ );
534
+ }
535
+
536
+ /** An action round's settled outcome as the page's one banner: a success panel when a batch fully moved, an alert otherwise. */
537
+ function NoticeBanner({ notice }: { notice: ListNotice }) {
318
538
  if (notice.kind === "refusal") {
319
- return <DangerCallout title={notice.title} body={notice.message} />;
539
+ return (
540
+ <div class="banner banner-danger" role="alert" tabindex={-1}>
541
+ <span class="banner-icon"><svg aria-hidden="true"><use href="#i-error" /></svg></span>
542
+ <div class="banner-body">
543
+ <div class="banner-title">{notice.title}</div>
544
+ <div class="banner-text">{notice.message}</div>
545
+ </div>
546
+ <BannerDismiss />
547
+ </div>
548
+ );
320
549
  }
321
550
  if (notice.refused.length === 0) {
322
551
  return (
323
- <div class="callout callout-success" role="status">
324
- <div class="callout-icon">
325
- <svg aria-hidden="true"><use href="#i-success" /></svg>
326
- </div>
327
- <div class="callout-content">
328
- <div class="callout-title">
552
+ <div class="banner banner-success" role="status" tabindex={-1}>
553
+ <span class="banner-icon"><svg aria-hidden="true"><use href="#i-success" /></svg></span>
554
+ <div class="banner-body">
555
+ <div class="banner-title">
329
556
  Approved {notice.approved} {notice.approved === 1 ? "entry" : "entries"}
330
557
  </div>
331
558
  </div>
559
+ <BannerDismiss />
332
560
  </div>
333
561
  );
334
562
  }
335
563
  return (
336
- <div class="callout callout-danger" role="alert">
337
- <div class="callout-icon">
338
- <svg aria-hidden="true"><use href="#i-error" /></svg>
339
- </div>
340
- <div class="callout-content">
341
- <div class="callout-title">
564
+ <div class="banner banner-danger" role="alert" tabindex={-1}>
565
+ <span class="banner-icon"><svg aria-hidden="true"><use href="#i-error" /></svg></span>
566
+ <div class="banner-body">
567
+ <div class="banner-title">
342
568
  Approved {notice.approved} of {notice.total} entries
343
569
  </div>
344
570
  <ul class="notice-list">
@@ -347,58 +573,46 @@ function NoticeCallout({ notice }: { notice: ListNotice }) {
347
573
  ))}
348
574
  </ul>
349
575
  </div>
576
+ <BannerDismiss />
577
+ </div>
578
+ );
579
+ }
580
+
581
+ /** The page's one banner slot; the htmx fragment replaces it out of band so a round can set or clear it. */
582
+ function PageBanner({ notice, oob = false }: { notice: ListNotice | undefined; oob?: boolean }) {
583
+ return (
584
+ <div id="page-banner" hx-swap-oob={oob ? "true" : undefined}>
585
+ {notice !== undefined ? <NoticeBanner notice={notice} /> : null}
350
586
  </div>
351
587
  );
352
588
  }
353
589
 
354
- /** The settled list region: success, empty, or partial, with an action round's notice on top. The loading and error states are client-side. */
355
- function ListRegion({ vm, notice }: { vm: ListViewModel; notice: ListNotice | undefined }) {
590
+ /** The settled list region: the tables, or one of the three empty states. The loading and error states are client-side. */
591
+ function ListRegion({ vm }: { vm: ListViewModel }) {
356
592
  if (vm.shown === 0) {
357
593
  // Zero entries at all is "no entries yet" even under a filter: nothing exists to filter, and
358
594
  // the empty state explains how entries get here. A store with entries but no matches is either
359
- // a filter miss or the default Active view over a store full of applied and rejected entries.
360
- return (
361
- <>
362
- {notice !== undefined ? <NoticeCallout notice={notice} /> : null}
363
- {vm.total === 0 ? (
364
- <EmptyAll />
365
- ) : vm.filtersActive ? (
366
- <EmptyMatch
367
- revealAllHref={
368
- vm.hiddenByActiveView
369
- ? `/?q=${encodeURIComponent(vm.q)}&project=${encodeURIComponent(vm.project)}&status=all`
370
- : undefined
371
- }
372
- />
373
- ) : (
374
- <EmptyActive />
375
- )}
376
- </>
595
+ // a filter miss or the default Open view over a store full of applied and rejected entries.
596
+ return vm.total === 0 ? (
597
+ <EmptyAll />
598
+ ) : vm.filtersActive ? (
599
+ <EmptyMatch
600
+ revealAllHref={
601
+ vm.hiddenByActiveView
602
+ ? `/?q=${encodeURIComponent(vm.q)}&project=${encodeURIComponent(vm.project)}&status=all`
603
+ : undefined
604
+ }
605
+ />
606
+ ) : (
607
+ <EmptyActive />
377
608
  );
378
609
  }
379
- return (
380
- <>
381
- {notice !== undefined ? <NoticeCallout notice={notice} /> : null}
382
- {vm.filtersActive && vm.shown < vm.total ? <PartialHeader shown={vm.shown} total={vm.total} /> : null}
383
- <BatchForm vm={vm} />
384
- </>
385
- );
386
- }
387
-
388
- function PartialHeader({ shown, total }: { shown: number; total: number }) {
389
- return (
390
- <div class="partial-header">
391
- <span class="partial-count">
392
- Showing {shown} of {total} entries
393
- </span>
394
- <a href="/" class="btn btn-secondary btn-sm">Clear filters</a>
395
- </div>
396
- );
610
+ return <BatchForm vm={vm} />;
397
611
  }
398
612
 
399
613
  function EmptyAll() {
400
614
  return (
401
- <div class="card empty-state">
615
+ <div class="empty-state">
402
616
  <svg aria-hidden="true"><use href="#i-file" /></svg>
403
617
  <div class="empty-title">No entries yet</div>
404
618
  <div>Agents file text through the reviewzy mcp endpoint. Filed entries appear here.</div>
@@ -407,14 +621,14 @@ function EmptyAll() {
407
621
  }
408
622
 
409
623
  /**
410
- * The filter-miss state, with a way onward. When the default Active view is what hides the
624
+ * The filter-miss state, with a way onward. When the default Open view is what hides the
411
625
  * matches (a search that only hits applied or rejected rows), the way onward is the all-statuses
412
- * view under the same filters — clearing the filters alone would land back on the Active view
626
+ * view under the same filters — clearing the filters alone would land back on the Open view
413
627
  * and still show nothing.
414
628
  */
415
629
  function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
416
630
  return (
417
- <div class="card empty-state">
631
+ <div class="empty-state">
418
632
  <svg aria-hidden="true"><use href="#i-search" /></svg>
419
633
  <div class="empty-title">No entries match</div>
420
634
  <div>Try different search terms, or clear the filters.</div>
@@ -429,10 +643,10 @@ function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
429
643
  );
430
644
  }
431
645
 
432
- /** The default Active view over a store whose entries are all applied or rejected: nothing to do, no filters to clear. */
646
+ /** The default Open view over a store whose entries are all applied or rejected: nothing to do, no filters to clear. */
433
647
  function EmptyActive() {
434
648
  return (
435
- <div class="card empty-state">
649
+ <div class="empty-state">
436
650
  <svg aria-hidden="true"><use href="#i-success" /></svg>
437
651
  <div class="empty-title">Nothing to review</div>
438
652
  <div>Every filed entry is applied or rejected. New drafts appear here as agents file them.</div>
@@ -441,11 +655,12 @@ function EmptyActive() {
441
655
  }
442
656
 
443
657
  /**
444
- * The batch approve form wrapping the whole list region: the checkboxes post as `id`, the hidden
658
+ * The batch approve form wrapping every project table: the checkboxes post as `id`, the hidden
445
659
  * inputs carry the active filters so an action round re-renders the region under the same filter
446
660
  * (htmx includes the closest form's inputs for the row buttons too), and the native action keeps
447
661
  * no-JS batch approve working. One form per page, never nested: the filters form lives outside the
448
- * swap region.
662
+ * swap region. The submit ships ENABLED so the no-JS path can fire it (an empty selection answers
663
+ * its refusal); dashboard.js disables it at first paint and enables it on a checked box.
449
664
  */
450
665
  function BatchForm({ vm }: { vm: ListViewModel }) {
451
666
  return (
@@ -461,123 +676,92 @@ function BatchForm({ vm }: { vm: ListViewModel }) {
461
676
  <input type="hidden" name="q" value={vm.q} />
462
677
  <input type="hidden" name="status" value={vm.status} />
463
678
  <input type="hidden" name="project" value={vm.project} />
464
- <div class="batch-bar">
465
- <button class="btn btn-primary btn-sm" type="submit" hx-disabled-elt="this">
679
+ <div class="toolbar batch-bar section-sticky">
680
+ <button class="btn btn-fill btn-sm" type="submit" hx-disabled-elt="this">
466
681
  Approve selected
467
682
  </button>
468
683
  </div>
469
684
  {vm.groups.map((group) => (
470
- <ProjectGroupView key={group.slug} group={group} />
685
+ <ProjectTable key={group.slug} group={group} />
471
686
  ))}
472
687
  </form>
473
688
  );
474
689
  }
475
690
 
476
- function ProjectGroupView({ group }: { group: ProjectGroup }) {
477
- const count = group.batches.reduce((n, batch) => n + batch.entries.length, 0);
478
- return (
479
- <section class="project-group">
480
- <header class="project-header">
481
- <h2 class="project-name">{group.slug}</h2>
482
- <span class="tag tag-data">
483
- {count} {count === 1 ? "entry" : "entries"}
484
- </span>
485
- {/* Every section and batch ships open; dashboard.js collapses the ones this viewer
486
- collapsed before (localStorage) and keeps the buttons inert without JS. */}
487
- <button
488
- type="button"
489
- class="btn btn-icon collapse-toggle"
490
- aria-expanded="true"
491
- aria-controls={`project-batches-${group.slug}`}
492
- aria-label={`Toggle ${group.slug} section`}
493
- data-collapse-toggle
494
- data-collapse-target={`project-batches-${group.slug}`}
495
- data-collapse-key={`project:${group.slug}`}
496
- >
497
- <svg aria-hidden="true"><use href="#i-chevron-down" /></svg>
498
- </button>
499
- </header>
500
- <div id={`project-batches-${group.slug}`}>
501
- {group.batches.map((batch) => (
502
- <BatchView key={batch.id} batch={batch} />
503
- ))}
504
- </div>
505
- </section>
506
- );
507
- }
508
-
509
- function BatchView({ batch }: { batch: ProjectBatch }) {
510
- // A batch names its filer in the header only when every entry agrees on one non-null
511
- // value; a disagreement leaves the header filer-less and each row names its own inline.
512
- const filedBy = batch.entries.map((entry) => entry.filed_by);
513
- const allAgree = filedBy.every((filer) => filer === filedBy[0]);
514
- const singleFiler = allAgree && filedBy[0] !== null ? filedBy[0] : null;
515
- const mixedFilers = !allAgree && filedBy.some((filer) => filer !== null);
691
+ /**
692
+ * One project's entries as one dense table. The project is a heading, not a disclosure: batches
693
+ * stay a data concept behind the row selection and the one approve action. The select-all sits in
694
+ * the header and drives this table's drafts through ui.js's per-wrap state machine.
695
+ */
696
+ function ProjectTable({ group }: { group: ProjectGroup }) {
697
+ const count = group.entries.length;
698
+ const drafts = group.entries.filter((entry) => entry.status === "draft").length;
699
+ const draftWord = drafts === 1 ? "draft" : "drafts";
700
+ const entryWord = count === 1 ? "entry" : "entries";
516
701
  return (
517
- <div class="batch">
518
- <div class="batch-header">
519
- {/* Select-all toggles only this batch's draft rows. No `name`, so it never submits as
520
- an entry id; dashboard.js derives checked/indeterminate from the draft checkboxes. */}
521
- <label class="checkbox-label">
522
- <input type="checkbox" class="select-all" aria-label={`Select all drafts in batch ${shortUlid(batch.id)}`} />
523
- </label>
524
- {singleFiler !== null ? <span class="batch-meta">{singleFiler}</span> : null}
525
- <button
526
- type="button"
527
- class="btn btn-icon collapse-toggle"
528
- aria-expanded="true"
529
- aria-controls={`batch-table-${batch.id}`}
530
- aria-label={`Toggle batch ${shortUlid(batch.id)}`}
531
- data-collapse-toggle
532
- data-collapse-target={`batch-table-${batch.id}`}
533
- data-collapse-key={`batch:${batch.id}`}
534
- >
535
- <svg aria-hidden="true"><use href="#i-chevron-down" /></svg>
536
- </button>
702
+ <section class="project-section">
703
+ <div class="section-head project-head">
704
+ <h2 class="section-title project-name">{group.slug}</h2>
705
+ <p class="section-lede project-meta">
706
+ {drafts > 0 ? `${drafts} ${draftWord} · ${count} ${entryWord}` : `${count} ${entryWord}`}
707
+ </p>
537
708
  </div>
538
- <div class="table-wrap" id={`batch-table-${batch.id}`}>
709
+ <div class="table-wrap table-dense">
539
710
  <table>
540
711
  <colgroup>
541
712
  <col class="col-select" />
542
713
  <col />
543
714
  <col class="col-status" />
544
- <col class="col-file" />
715
+ <col class="col-filed" />
545
716
  <col class="col-actions" />
546
717
  </colgroup>
547
718
  <thead>
548
719
  <tr>
549
- {/* The column header stays in the accessibility tree via the sr-only span; the
550
- checkbox column itself is too narrow for a visible label. */}
551
- <th class="cell-select" scope="col"><span class="sr-only">Select</span></th>
552
- <th scope="col">Title</th>
720
+ {/* The select-all lives in the checkbox column: ui.js's initTables owns the
721
+ checked and indeterminate state machine for the wrap. No `name`, so it never
722
+ submits as an entry id; only the draft checkboxes post as `id`. */}
723
+ <th class="cell-select" scope="col">
724
+ <span class="sr-only">Select</span>
725
+ <label class="checkbox-label">
726
+ <input type="checkbox" data-select-all aria-label={`Select all drafts in ${group.slug}`} />
727
+ </label>
728
+ </th>
729
+ <th scope="col">Entry</th>
553
730
  <th scope="col">Status</th>
554
- <th scope="col">File</th>
731
+ <th class="cell-filed" scope="col">Filed</th>
555
732
  <th scope="col">Actions</th>
556
733
  </tr>
557
734
  </thead>
558
735
  <tbody>
559
- {batch.entries.map((entry) => (
560
- <EntryRowView key={entry.id} entry={entry} filerInline={mixedFilers} />
736
+ {group.entries.map((entry) => (
737
+ <EntryRowView key={entry.id} entry={entry} />
561
738
  ))}
562
739
  </tbody>
563
740
  </table>
564
741
  </div>
565
- </div>
742
+ </section>
566
743
  );
567
744
  }
568
745
 
746
+ /** The filed time, absolute and short: UTC date plus minutes, so the stamp reads the same wherever the server runs. */
747
+ function filedStamp(createdAt: number): { iso: string; short: string } {
748
+ const iso = new Date(createdAt).toISOString();
749
+ return { iso, short: `${iso.slice(0, 10)} ${iso.slice(11, 16)}` };
750
+ }
751
+
569
752
  /**
570
- * One row. The checkbox names the entry for the batch form; it exists only on drafts, because an
571
- * approve on any other status always refuses and a checkbox that can only refuse is noise (races
572
- * are still reported per entry). The status chip sits beside the title; the three icon action slots
573
- * always render, inapplicable ones disabled, so a double-click's second click lands on the disabled
574
- * slot rather than a shifted action. Approve and reject target the list region and include the
575
- * batch form's hidden filters, so an action round re-renders the region under the same filter; the
576
- * editor link is always present. When the batch holds mixed filers, the row also names its own
577
- * filer beside the title.
753
+ * One row. The Entry cell leads with the title as a link over the file path; the checkbox names
754
+ * the entry for the batch form (and `data-name` hands it to the selection controller); it exists
755
+ * only on drafts, because an approve on any other status always refuses and a checkbox that can
756
+ * only refuse is noise (races are still reported per entry). The status chip sits in its own
757
+ * column; the three icon action slots always render, inapplicable ones disabled, so a
758
+ * double-click's second click lands on the disabled slot rather than a shifted action. Approve
759
+ * and reject target the list region and include the batch form's hidden filters, so an action
760
+ * round re-renders the region under the same filter; the editor link is always present.
578
761
  */
579
- function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: boolean }) {
762
+ function EntryRowView({ entry }: { entry: EntryRow }) {
580
763
  const title = entryTitle(entry);
764
+ const filed = filedStamp(entry.created_at);
581
765
  const approve = entry.status === "draft";
582
766
  const reject = entry.status === "draft" || entry.status === "approved";
583
767
  return (
@@ -585,26 +769,33 @@ function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: bo
585
769
  <td class="cell-select">
586
770
  {approve ? (
587
771
  <label class="checkbox-label">
588
- <input type="checkbox" name="id" value={entry.id} aria-label={`Select ${entry.file}`} />
772
+ <input type="checkbox" name="id" value={entry.id} data-name={entry.id} aria-label={`Select ${entry.file}`} />
589
773
  </label>
590
774
  ) : null}
591
775
  </td>
592
- <td title={`${entry.file} — ${entry.anchor_text}`}>
776
+ <td>
593
777
  <div class="entry-head">
594
- <span class="entry-title">{title}</span>
595
- {filerInline && entry.filed_by !== null ? <span class="entry-filer"> · {entry.filed_by}</span> : null}
778
+ <a class="entry-link truncate" href={`/entries/${entry.id}`}>{title}</a>
779
+ <span class="entry-file truncate">{entry.file}</span>
596
780
  </div>
781
+ {/* A titled entry displays the title in place of the anchor text, so the anchor the entry
782
+ changes would otherwise reach no one but the editor. */}
783
+ {hasOwnTitle(entry) ? <span class="sr-only">{entry.anchor_text}</span> : null}
597
784
  </td>
598
785
  <td>
599
- <span class={`tag ${STATUS_TAG[entry.status]}`}>{STATUS_LABEL[entry.status]}</span>
786
+ <span class={`tag ${STATUS_TAG[entry.status]}`}>
787
+ <span class="tag-dot"></span>
788
+ {STATUS_LABEL[entry.status]}
789
+ </span>
790
+ </td>
791
+ <td class="cell-filed">
792
+ <time class="num" datetime={filed.iso}>{filed.short}</time>
600
793
  </td>
601
- <td class="cell-file" title={entry.file}>{entry.file}</td>
602
794
  <td class="cell-actions">
603
795
  {approve ? (
604
796
  <button
605
797
  type="button"
606
- class="btn btn-icon"
607
- style="color: var(--success)"
798
+ class="btn btn-icon action-approve"
608
799
  aria-label={`Approve ${entry.file}`}
609
800
  hx-post={`/entries/${entry.id}/approve`}
610
801
  hx-target="#entries-list"
@@ -617,15 +808,14 @@ function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: bo
617
808
  ) : (
618
809
  // The approve slot stays occupied on every non-draft row: a double-click's second click
619
810
  // lands on the disabled button (pointer-events none) rather than the next slot.
620
- <button type="button" class="btn btn-icon" style="color: var(--success)" aria-label={`Approve ${entry.file}`} disabled>
811
+ <button type="button" class="btn btn-icon action-approve" aria-label={`Approve ${entry.file}`} disabled>
621
812
  <svg aria-hidden="true"><use href="#i-check" /></svg>
622
813
  </button>
623
814
  )}
624
815
  {reject ? (
625
816
  <button
626
817
  type="button"
627
- class="btn btn-icon"
628
- style="color: var(--danger)"
818
+ class="btn btn-icon action-reject"
629
819
  aria-label={`Reject ${entry.file}`}
630
820
  hx-post={`/entries/${entry.id}/reject`}
631
821
  hx-target="#entries-list"
@@ -637,7 +827,7 @@ function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: bo
637
827
  </button>
638
828
  ) : (
639
829
  // The reject slot stays occupied on applied and rejected rows for the same guard.
640
- <button type="button" class="btn btn-icon" style="color: var(--danger)" aria-label={`Reject ${entry.file}`} disabled>
830
+ <button type="button" class="btn btn-icon action-reject" aria-label={`Reject ${entry.file}`} disabled>
641
831
  <svg aria-hidden="true"><use href="#i-x" /></svg>
642
832
  </button>
643
833
  )}
@@ -649,26 +839,6 @@ function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: bo
649
839
  );
650
840
  }
651
841
 
652
- /** The error box htmx failures clone into the list region; the retry button re-submits the form. */
653
- function ErrorBoxTemplate() {
654
- return (
655
- <template id="error-box">
656
- <div class="callout callout-danger" role="alert">
657
- <div class="callout-icon">
658
- <svg aria-hidden="true"><use href="#i-error" /></svg>
659
- </div>
660
- <div class="callout-content">
661
- <div class="callout-title">The list could not be loaded</div>
662
- <div class="callout-body">The filter request failed. Try it again.</div>
663
- <div class="callout-actions">
664
- <button class="btn btn-secondary btn-sm" type="button" data-retry>Retry</button>
665
- </div>
666
- </div>
667
- </div>
668
- </template>
669
- );
670
- }
671
-
672
842
  /** The saved theme, restored before the stylesheet, or a light-theme visitor sees a dark flash. */
673
843
  const THEME_RESTORE = raw(
674
844
  'const saved = localStorage.getItem("cloudy-theme"); if (saved) document.documentElement.dataset.theme = saved;',
@@ -694,7 +864,7 @@ function PageHead({ title }: { title: string }) {
694
864
  );
695
865
  }
696
866
 
697
- /** The sprite as the body's first child; cursor.js creates its own canvas and nothing else may enter that slot. */
867
+ /** The sprite in the body; cursor.js creates its own canvas and nothing else may enter that slot. */
698
868
  function IconSprite() {
699
869
  return <>{raw(ICON_SPRITE)}</>;
700
870
  }
@@ -709,7 +879,7 @@ function PageModules() {
709
879
  );
710
880
  }
711
881
 
712
- /** The full page for a plain navigation; the mount wraps it in the doctype. `signedIn` is whether the navbar offers sign-out. */
882
+ /** The full page for a plain navigation; the mount wraps it in the doctype. `signedIn` is whether the sidebar offers sign-out. */
713
883
  export function dashboardPage(
714
884
  store: Store,
715
885
  params: ListParams,
@@ -721,11 +891,20 @@ export function dashboardPage(
721
891
  <html lang="en" data-theme="dark">
722
892
  <PageHead title={`Entries · ${NAME}`} />
723
893
  <body>
894
+ <a class="skip-link" href="#main">Skip to content</a>
724
895
  <IconSprite />
725
- <div class="app-shell">
726
- <Navbar signedIn={signedIn} active="entries" />
727
- <main class="main">
728
- <PageHeader total={vm.total} />
896
+ <Shell signedIn={signedIn} active="entries" draftCount={vm.counts.draft}>
897
+ <PageHeader vm={vm} />
898
+ <PageBanner notice={notice} />
899
+ {/* The persistent announcement slot for an action round: the banner may arrive already
900
+ populated through an out-of-band swap, which a live region does not reliably read. */}
901
+ <span id="list-live" class="sr-only" aria-live="polite"></span>
902
+ <section>
903
+ {/* The eyebrow alone names the section: the page title already reads "Entries",
904
+ and a second one of the same word stacks two display lines. */}
905
+ <div class="section-head">
906
+ <div class="label">Queue</div>
907
+ </div>
729
908
  <FilterForm vm={vm} />
730
909
  <div id="entries">
731
910
  <div id="entries-loading" class="htmx-indicator" role="status">
@@ -733,12 +912,12 @@ export function dashboardPage(
733
912
  Loading entries
734
913
  </div>
735
914
  <div id="entries-list">
736
- <ListRegion vm={vm} notice={notice} />
915
+ <ListRegion vm={vm} />
737
916
  </div>
738
917
  </div>
739
- <ErrorBoxTemplate />
740
- </main>
741
- </div>
918
+ </section>
919
+ </Shell>
920
+ <div class="toast-stack" id="toast-stack"></div>
742
921
  <PageModules />
743
922
  </body>
744
923
  </html>
@@ -746,10 +925,10 @@ export function dashboardPage(
746
925
  }
747
926
 
748
927
  /**
749
- * The login page: the navbar shell, the page-header triplet, and one centered card holding the
750
- * password field. `error` is the single failure message, shown as an alert callout; the field
751
- * itself is always cleared, and `next` rides along as a hidden input so a successful retry still
752
- * lands where the visitor was going.
928
+ * The login screen: a viewport-owning page-state with the sign-in card and no shell — the way out
929
+ * is signing in. `error` is the single failure message, shown as an alert callout inside the
930
+ * card's flow; the field itself is always cleared, and `next` rides along as a hidden input so a
931
+ * successful retry still lands where the visitor was going.
753
932
  */
754
933
  export function loginPage(next: string | undefined, error: string | undefined = undefined): JSX.Element {
755
934
  return (
@@ -757,20 +936,21 @@ export function loginPage(next: string | undefined, error: string | undefined =
757
936
  <PageHead title={`Sign in · ${NAME}`} />
758
937
  <body>
759
938
  <IconSprite />
760
- <div class="app-shell">
761
- <Navbar signedIn={false} active="none" />
762
- <main class="main login-main">
763
- <header class="page-header">
764
- <div class="page-header-body">
765
- <div class="label page-eyebrow">Dashboard</div>
766
- <h1 class="page-title">Sign in</h1>
767
- <p class="page-lede">The dashboard is password protected. Enter the password to continue.</p>
768
- </div>
769
- </header>
770
- {error !== undefined ? <DangerCallout title={error} /> : null}
771
- <form method="post" action="/login" class="card login-card">
939
+ <main class="page-state">
940
+ <div class="page-state-card">
941
+ <div class="page-state-brand">
942
+ <div class="logo-name">{NAME}</div>
943
+ </div>
944
+ <form method="post" action="/login" class="card">
772
945
  {next !== undefined ? <input type="hidden" name="next" value={next} /> : null}
773
- <div class="card-content">
946
+ <div class="card-header">
947
+ <div>
948
+ <div class="card-title">Sign in</div>
949
+ <div class="card-subtitle">The dashboard is password protected. Enter the password to continue.</div>
950
+ </div>
951
+ </div>
952
+ <div class="card-content login-content">
953
+ {error !== undefined ? <DangerCallout title={error} /> : null}
774
954
  <div class="field">
775
955
  <label class="field-label" for="password">Password</label>
776
956
  <input
@@ -782,32 +962,50 @@ export function loginPage(next: string | undefined, error: string | undefined =
782
962
  />
783
963
  </div>
784
964
  <div class="login-actions">
785
- <button class="btn btn-primary" type="submit">Sign in</button>
965
+ <button class="btn btn-fill" type="submit">Sign in</button>
786
966
  </div>
787
967
  </div>
788
968
  </form>
789
- </main>
790
- </div>
969
+ </div>
970
+ </main>
791
971
  <PageModules />
792
972
  </body>
793
973
  </html>
794
974
  );
795
975
  }
796
976
 
797
- /** The list region only, for htmx requests; the loading strip and the error template stay in the page. */
977
+ /**
978
+ * The list region only, for htmx requests. The tabs, the header meta, the count line, the sidebar
979
+ * badge and the banner sit outside the main swap target, so they ride out-of-band swaps instead of
980
+ * going stale after an action round.
981
+ */
798
982
  export function entriesFragment(store: Store, params: ListParams, notice: ListNotice | undefined = undefined): JSX.Element {
799
- return <ListRegion vm={loadList(store, params)} notice={notice} />;
983
+ const vm = loadList(store, params);
984
+ return (
985
+ <>
986
+ <DraftBadge count={vm.counts.draft} oob />
987
+ <PageBanner notice={notice} oob />
988
+ <StatusTabs counts={vm.counts} status={vm.status} oob />
989
+ <ListMeta vm={vm} oob />
990
+ <PageMeta draftCount={vm.counts.draft} total={vm.total} projects={vm.projects.length} oob />
991
+ <ListRegion vm={vm} />
992
+ </>
993
+ );
800
994
  }
801
995
 
802
996
  // ---- Entry editor ----
803
997
 
804
998
  export type EditorViewModel = {
805
999
  readonly entry: EntryRow;
1000
+ /** The entry's project slug, so the header's breadcrumb reads the name the queue groups by. */
1001
+ readonly projectSlug: string;
806
1002
  readonly revisions: readonly RevisionRow[];
807
1003
  readonly constraints: Constraints;
808
1004
  /** The entry immediately before/after in the list's ascending-id walk, or null at the boundary. */
809
1005
  readonly prevId: string | null;
810
1006
  readonly nextId: string | null;
1007
+ /** The sidebar's queue badge, read once with the editor's own query. */
1008
+ readonly draftCount: number;
811
1009
  };
812
1010
 
813
1011
  /**
@@ -831,7 +1029,16 @@ export function loadEditor(store: Store, id: string): EditorViewModel | null {
831
1029
  const entry = getEntry(store, id);
832
1030
  if (entry === undefined) return null;
833
1031
  const { prevId, nextId } = adjacentEntryIds(store, id);
834
- return { entry, revisions: listRevisions(store, id), constraints: parseConstraints(entry.constraints), prevId, nextId };
1032
+ const projects = listProjects(store);
1033
+ return {
1034
+ entry,
1035
+ projectSlug: projects.find((project) => project.id === entry.project_id)?.slug ?? entry.project_id,
1036
+ revisions: listRevisions(store, id),
1037
+ constraints: parseConstraints(entry.constraints),
1038
+ prevId,
1039
+ nextId,
1040
+ draftCount: countEntriesByStatus(store).draft,
1041
+ };
835
1042
  }
836
1043
 
837
1044
  /** The tolerant parse for `entries.context`, mirroring `parseConstraints`: foreign rows may hold malformed JSON, and unparseable means no context rows, never a 500. */
@@ -850,12 +1057,17 @@ function parseContext(json: string): Record<string, unknown> {
850
1057
  * The user-facing refusal messages, in the dashboard's own voice: name what failed, then the fix.
851
1058
  * The store's refusal carries the values; only this function turns them into prose.
852
1059
  */
1060
+ /** A placeholder token as the copy shows it: one pair of braces around the bare name, even when the filed token already carries its own. */
1061
+ function bracedPlaceholder(placeholder: string): string {
1062
+ return `{${placeholder.replace(/^\{+|\}+$/g, "")}}`;
1063
+ }
1064
+
853
1065
  export function formatSaveRefusal(refusal: Exclude<HumanSaveRefusal, { kind: "unknown" }>): string {
854
1066
  switch (refusal.kind) {
855
1067
  case "max_len":
856
1068
  return `This text is ${refusal.length} characters; the length limit is ${refusal.maxLen}. Shorten it to ${refusal.maxLen} characters or fewer.`;
857
1069
  case "placeholder":
858
- return `The text is missing the placeholder {${refusal.placeholder}}. Add {${refusal.placeholder}} to the text and save again.`;
1070
+ return `The text is missing the placeholder ${bracedPlaceholder(refusal.placeholder)}. Add ${bracedPlaceholder(refusal.placeholder)} to the text and save again.`;
859
1071
  case "empty":
860
1072
  return "Nothing to save. Enter text, or reject the entry.";
861
1073
  case "status":
@@ -906,245 +1118,483 @@ function DangerCallout({ title, body }: { title: string; body?: string }) {
906
1118
  );
907
1119
  }
908
1120
 
909
- /**
910
- * The editor region, swapped wholesale by a save or a transition (form + status tag + history +
911
- * any refusal). The textarea carries `hx-preserve`, so a swap keeps the user's focus and typing
912
- * while the server-rendered value stays what the last save left: the submitted text on a refusal.
913
- * The `view` marker tells the approve and reject routes which region to answer with: this region,
914
- * or the list's. The buttons carry htmx only; the save button keeps the native submit.
915
- */
916
- function EditorRegion({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
917
- const text = state?.submittedText ?? vm.entry.human_text ?? vm.entry.agent_draft ?? "";
918
- const approve = vm.entry.status === "draft";
919
- const reject = vm.entry.status === "draft" || vm.entry.status === "approved";
920
- return (
921
- <div id="editor-region">
922
- {state?.refusal !== undefined ? (
923
- <DangerCallout title={state.refusal.title} body={state.refusal.body} />
924
- ) : null}
925
- <form
926
- id="editor-form"
927
- class="card"
928
- method="post"
929
- action={`/entries/${vm.entry.id}/save`}
930
- hx-post={`/entries/${vm.entry.id}/save`}
931
- hx-target="#editor-view"
932
- hx-swap="outerHTML"
933
- >
934
- <input type="hidden" name="view" value="editor" />
935
- <div class="card-header">
936
- <div class="card-title editor-title">{entryTitle(vm.entry)}</div>
937
- <span class={`tag ${STATUS_TAG[vm.entry.status]}`}>
938
- {STATUS_LABEL[vm.entry.status]}
939
- </span>
940
- </div>
941
- <div class="card-content">
942
- <textarea id="editor-text" class="input" name="text" rows={10} aria-label="Entry text" hx-preserve="true">
943
- {text}
944
- </textarea>
945
- <div class="editor-actions">
946
- <button class="btn btn-primary" type="submit">Save text</button>
947
- {approve ? (
948
- <button
949
- type="button"
950
- class="btn btn-secondary"
951
- hx-post={`/entries/${vm.entry.id}/approve`}
952
- hx-target="#editor-region"
953
- hx-swap="outerHTML"
954
- hx-indicator="#editor-action-loading"
955
- hx-disabled-elt="this"
956
- >
957
- Approve draft
958
- </button>
959
- ) : vm.entry.status === "approved" ? (
960
- // Same slot-occupancy guard as the list row: the second click of a double-click on
961
- // the just-approving button would otherwise land on the Reject button that shifts
962
- // into this slot in the swapped region.
963
- <button type="button" class="btn btn-secondary" disabled>
964
- Approve draft
965
- </button>
966
- ) : null}
967
- {reject ? (
968
- <button
969
- type="button"
970
- class="btn btn-danger"
971
- hx-post={`/entries/${vm.entry.id}/reject`}
972
- hx-target="#editor-region"
973
- hx-swap="outerHTML"
974
- hx-indicator="#editor-action-loading"
975
- hx-disabled-elt="this"
976
- >
977
- Reject
978
- </button>
979
- ) : null}
980
- <span id="editor-action-loading" class="htmx-indicator" role="status">
981
- <span class="spinner" aria-hidden="true"></span>
982
- Updating entry
983
- </span>
984
- </div>
985
- </div>
986
- </form>
987
- <RevisionHistory revisions={vm.revisions} />
988
- </div>
989
- );
990
- }
991
-
992
- function RevisionHistory({ revisions }: { revisions: readonly RevisionRow[] }) {
1121
+ function InfoCallout({ title, body }: { title: string; body?: string }) {
993
1122
  return (
994
- <div class="card">
995
- <div class="card-header">
996
- <div class="card-title">Revision history</div>
1123
+ <div class="callout callout-info">
1124
+ <div class="callout-icon">
1125
+ <svg aria-hidden="true"><use href="#i-info" /></svg>
997
1126
  </div>
998
- <div class="card-content">
999
- {revisions.length === 0 ? (
1000
- <p class="history-empty">No saved revisions yet</p>
1001
- ) : (
1002
- <ul class="revision-list">
1003
- {revisions.map((revision) => (
1004
- <li class="revision" key={revision.id}>
1005
- <div class="revision-head">
1006
- <span class={`tag tag-data ${STATUS_TAG[revision.status]}`}>{STATUS_LABEL[revision.status]}</span>
1007
- <time class="revision-time" datetime={new Date(revision.created_at).toISOString()}>
1008
- {new Date(revision.created_at).toLocaleString()}
1009
- </time>
1010
- <button class="btn btn-secondary btn-sm" type="button" data-use-revision>
1011
- Use
1012
- </button>
1013
- </div>
1014
- <div class="revision-text">{revision.human_text}</div>
1015
- </li>
1016
- ))}
1017
- </ul>
1018
- )}
1127
+ <div class="callout-content">
1128
+ <div class="callout-title">{title}</div>
1129
+ {body !== undefined ? <div class="callout-body">{body}</div> : null}
1019
1130
  </div>
1020
1131
  </div>
1021
1132
  );
1022
1133
  }
1023
1134
 
1024
- /** The constraint panel: what the entry's save enforces (length, placeholders), plus the display-only tone. The notes moved to their own card, so the enforced fields stay the card's lead. */
1025
- function ConstraintsCard({ constraints, text }: { constraints: Constraints; text: string }) {
1026
- const maxLen = constraints.maxLen;
1027
- const placeholders = constraints.placeholders;
1028
- const tone = constraints.tone;
1029
- const hasAny = maxLen !== undefined || placeholders.length > 0 || tone !== undefined;
1030
- if (!hasAny) return null;
1135
+ /** The entry's status tag, in the page header; transitions re-render it out of band. */
1136
+ function EntryStatusTag({ status, oob = false }: { status: EntryStatus; oob?: boolean }) {
1031
1137
  return (
1032
- <div class="card">
1033
- <div class="card-header">
1034
- <div class="card-title">Constraints</div>
1035
- </div>
1036
- <div class="card-content">
1037
- {maxLen !== undefined ? (
1038
- <div class="constraint-row">
1039
- <div class="constraint-label">Length</div>
1040
- <div class="constraint-value">
1041
- <span id="char-count" class="num char-count" data-max-len={maxLen}>
1042
- {text.length} / {maxLen}
1043
- </span>
1044
- </div>
1045
- </div>
1046
- ) : null}
1047
- {placeholders.length > 0 ? (
1048
- <div class="constraint-row">
1049
- <div class="constraint-label">Placeholders</div>
1050
- <ul class="placeholder-list">
1051
- {placeholders.map((placeholder) => {
1052
- const present = text.includes(placeholder);
1053
- return (
1054
- <li class="placeholder-item" data-placeholder={placeholder} key={placeholder}>
1055
- <span class={`placeholder-state${present ? " is-present" : ""}`}>
1056
- {present ? "Present" : "Missing"}
1057
- </span>
1058
- <span class="placeholder-token">{"{" + placeholder + "}"}</span>
1059
- </li>
1060
- );
1061
- })}
1062
- </ul>
1063
- </div>
1064
- ) : null}
1065
- {tone !== undefined ? (
1066
- <div class="constraint-row">
1067
- <div class="constraint-label">Tone</div>
1068
- <div class="constraint-value">{tone}</div>
1069
- </div>
1070
- ) : null}
1071
- </div>
1072
- </div>
1138
+ <span id="entry-status" hx-swap-oob={oob ? "true" : undefined}>
1139
+ <span class={`tag ${STATUS_TAG[status]}`}>
1140
+ <span class="tag-dot"></span>
1141
+ {STATUS_LABEL[status]}
1142
+ </span>
1143
+ </span>
1073
1144
  );
1074
1145
  }
1075
1146
 
1076
- /**
1077
- * The notes card: the agent's notes (from `constraints.notes`, read-only) above the human's own
1078
- * notes as an editable textarea with its own save. The textarea carries `hx-preserve`, so any swap
1079
- * (a text save, an image add) keeps the human's in-progress note; the save targets the whole
1080
- * editor view like the save form, so the saved value re-renders in place. The card always renders:
1081
- * the human's notes need a home even before the agent sent any.
1082
- */
1083
- function NotesCard({ vm }: { vm: EditorViewModel }) {
1147
+ /** One header nav control: a secondary button linking to the neighbour, or an inert placeholder at the boundary. */
1148
+ function EditorNavButton({ side, targetId }: { side: "prev" | "next"; targetId: string | null }) {
1149
+ const label = side === "prev" ? "Previous entry" : "Next entry";
1150
+ const icon = (
1151
+ <svg aria-hidden="true"><use href={side === "prev" ? "#i-chevron-left" : "#i-chevron-right"} /></svg>
1152
+ );
1153
+ if (targetId === null) {
1154
+ return (
1155
+ <button class="btn btn-secondary" type="button" disabled aria-label={label}>
1156
+ {icon}
1157
+ </button>
1158
+ );
1159
+ }
1084
1160
  return (
1085
- <div class="card">
1161
+ <a class="btn btn-secondary" href={`/entries/${targetId}`} aria-label={label}>
1162
+ {icon}
1163
+ </a>
1164
+ );
1165
+ }
1166
+
1167
+ /**
1168
+ * The editor's page header: the breadcrumb on its own line, then the entry title with the status
1169
+ * tag, repo link, file and filed time under it, and the previous/next pair in the actions cell.
1170
+ */
1171
+ function EditorHeader({ vm }: { vm: EditorViewModel }) {
1172
+ const entry = vm.entry;
1173
+ const filed = filedStamp(entry.created_at);
1174
+ return (
1175
+ <header class="page-header">
1176
+ <nav class="breadcrumb" aria-label="Breadcrumb">
1177
+ <a class="breadcrumb-item" href="/">Entries</a>
1178
+ <span class="breadcrumb-sep" aria-hidden="true"></span>
1179
+ <span class="breadcrumb-item current">{vm.projectSlug}</span>
1180
+ </nav>
1181
+ <div class="page-header-body">
1182
+ <h1 class="page-title editor-title">{entryTitle(entry)}</h1>
1183
+ <div class="page-meta">
1184
+ <EntryStatusTag status={entry.status} />
1185
+ <span class="page-meta-item"><RepoValue repo={entry.repo} /></span>
1186
+ <span class="page-meta-item"><span class="page-file truncate">{entry.file}</span></span>
1187
+ <span class="page-meta-item">filed <time class="num" datetime={filed.iso}>{filed.short}</time></span>
1188
+ </div>
1189
+ </div>
1190
+ <div class="toolbar">
1191
+ <EditorNavButton side="prev" targetId={vm.prevId} />
1192
+ <EditorNavButton side="next" targetId={vm.nextId} />
1193
+ </div>
1194
+ </header>
1195
+ );
1196
+ }
1197
+
1198
+ /**
1199
+ * Whether the change touches both sides: a substitution or rewrite then renders two rows, while an
1200
+ * addition-only or removal-only edit stays on the unified line. On one line a substitution reads as
1201
+ * two differently tinted copies of the same word ("`clauth" then "clauth"), so the two whole rows
1202
+ * keep the sentence readable.
1203
+ */
1204
+ function hasBothSides(diff: readonly WordDiffToken[]): boolean {
1205
+ return diff.some((token) => token.kind === "removed") && diff.some((token) => token.kind === "added");
1206
+ }
1207
+
1208
+ /** One side of a two-row change: the anchor without the additions, or the proposal without the removals, each a whole sentence. */
1209
+ function diffSide(diff: readonly WordDiffToken[], side: "anchor" | "proposal"): Child[] {
1210
+ const dropped = side === "anchor" ? "added" : "removed";
1211
+ const mark = side === "anchor" ? "diff-remove" : "diff-add";
1212
+ return diff
1213
+ .filter((token) => token.kind !== dropped)
1214
+ .map((token, index) =>
1215
+ token.kind === "same" ? token.text : <span class={mark} key={index}>{token.text}</span>,
1216
+ );
1217
+ }
1218
+
1219
+ /**
1220
+ * The change card: the anchored snippet with the anchor line marked, diffed against the compare
1221
+ * text when one is given. A one-sided edit renders the unified stream, so the marks sit in place
1222
+ * while the kept words read once; a change on both sides renders the file text and the proposal as
1223
+ * two whole rows. A null or empty compare renders the anchor plain. The diff route re-renders this
1224
+ * card against the textarea's live value.
1225
+ */
1226
+ export function currentTextCard(entry: EntryRow, compareText: string | null): JSX.Element {
1227
+ const diff = compareText === null || compareText === "" ? null : diffWords(entry.anchor_text, compareText);
1228
+ const twoRow = diff !== null && hasBothSides(diff);
1229
+ return (
1230
+ <div class="card current-text-card" id="current-text">
1086
1231
  <div class="card-header">
1087
- <div class="card-title">Notes</div>
1232
+ <div>
1233
+ <div class="card-title">The change</div>
1234
+ <div class="card-subtitle">Removed words tint red; added words tint green.</div>
1235
+ </div>
1088
1236
  </div>
1089
1237
  <div class="card-content">
1090
- <div class="constraint-row">
1091
- <div class="constraint-label">Agent's notes</div>
1092
- <div class="constraint-value">{vm.constraints.notes ?? "None."}</div>
1238
+ <pre class={twoRow ? "diff-code editor-pane diff-rows" : "diff-code editor-pane"}>
1239
+ <span class="diff-line">{entry.anchor_before}</span>
1240
+ <span class="diff-line diff-anchor annot-mark">
1241
+ {diff === null
1242
+ ? entry.anchor_text
1243
+ : twoRow
1244
+ ? diffSide(diff, "anchor")
1245
+ : diff.map((token, index) =>
1246
+ token.kind === "same" ? (
1247
+ token.text
1248
+ ) : (
1249
+ <span class={token.kind === "removed" ? "diff-remove" : "diff-add"} key={index}>{token.text}</span>
1250
+ ),
1251
+ )}
1252
+ </span>
1253
+ <span class="diff-line">{entry.anchor_after}</span>
1254
+ {twoRow && diff !== null ? <span class="diff-line diff-proposed">{diffSide(diff, "proposal")}</span> : null}
1255
+ </pre>
1256
+ </div>
1257
+ </div>
1258
+ );
1259
+ }
1260
+
1261
+ /** One disclosure section of the editor's single support card. */
1262
+ function SupportSection({ label, children }: { label: string; children: Child }) {
1263
+ return (
1264
+ <details class="disclosure support-section" open>
1265
+ <summary>
1266
+ <svg aria-hidden="true"><use href="#i-chevron-right" /></svg>
1267
+ <span>{label}</span>
1268
+ </summary>
1269
+ <div class="disclosure-body">{children}</div>
1270
+ </details>
1271
+ );
1272
+ }
1273
+
1274
+ /** The entry's provenance rows: file hash, filer, stale note. */
1275
+ function ProvenanceTable({ entry }: { entry: EntryRow }) {
1276
+ return (
1277
+ <table class="info-table">
1278
+ <tr>
1279
+ <td>File hash</td>
1280
+ <td>{entry.file_hash}</td>
1281
+ </tr>
1282
+ {entry.filed_by !== null ? (
1283
+ <tr>
1284
+ <td>Filed by</td>
1285
+ <td>{entry.filed_by}</td>
1286
+ </tr>
1287
+ ) : null}
1288
+ {entry.stale_note !== null ? (
1289
+ <tr>
1290
+ <td>Stale note</td>
1291
+ <td>{entry.stale_note}</td>
1292
+ </tr>
1293
+ ) : null}
1294
+ </table>
1295
+ );
1296
+ }
1297
+
1298
+ /**
1299
+ * The entry's agent-supplied context rows, then the provenance behind a disclosure. An entry
1300
+ * without context rows shows the provenance directly: a section holding only a "More" toggle reads
1301
+ * as an empty card. The anchor, repo, file and filed time live in the page header, so no row here
1302
+ * repeats them.
1303
+ */
1304
+ function DetailsSection({ entry }: { entry: EntryRow }) {
1305
+ const context = parseContext(entry.context);
1306
+ const rows = Object.entries(context);
1307
+ return (
1308
+ <SupportSection label="Details">
1309
+ {rows.length === 0 ? (
1310
+ <ProvenanceTable entry={entry} />
1311
+ ) : (
1312
+ <>
1313
+ <table class="info-table">
1314
+ {rows.map(([key, value]) => (
1315
+ <tr key={key}>
1316
+ <td>{key}</td>
1317
+ <td>{typeof value === "string" ? value : JSON.stringify(value)}</td>
1318
+ </tr>
1319
+ ))}
1320
+ </table>
1321
+ <details class="disclosure context-secondary">
1322
+ <summary>
1323
+ <svg aria-hidden="true"><use href="#i-chevron-right" /></svg>
1324
+ <span>More</span>
1325
+ </summary>
1326
+ <div class="disclosure-body">
1327
+ <ProvenanceTable entry={entry} />
1328
+ </div>
1329
+ </details>
1330
+ </>
1331
+ )}
1332
+ </SupportSection>
1333
+ );
1334
+ }
1335
+
1336
+ /** The audit log of saved revisions: the shipped timeline, one dot per entry. */
1337
+ function RevisionHistorySection({ revisions, editable }: { revisions: readonly RevisionRow[]; editable: boolean }) {
1338
+ return (
1339
+ <SupportSection label="Revision history">
1340
+ {revisions.length === 0 ? (
1341
+ <div class="empty-state">
1342
+ <div class="empty-title">No saved revisions yet</div>
1093
1343
  </div>
1094
- <form
1095
- id="notes-form"
1096
- method="post"
1097
- action={`/entries/${vm.entry.id}/notes`}
1098
- hx-post={`/entries/${vm.entry.id}/notes`}
1099
- hx-target="#editor-view"
1344
+ ) : (
1345
+ <ol class="timeline">
1346
+ {revisions.map((revision) => (
1347
+ <li class="timeline-item" key={revision.id}>
1348
+ <span class={`timeline-dot ${STATUS_DOT[revision.status]}`} aria-hidden="true"></span>
1349
+ <div>
1350
+ <div class="timeline-head">
1351
+ <span class={`tag tag-data ${STATUS_TAG[revision.status]}`}>{STATUS_LABEL[revision.status]}</span>
1352
+ <time class="timeline-time" datetime={new Date(revision.created_at).toISOString()}>
1353
+ {new Date(revision.created_at).toLocaleString()}
1354
+ </time>
1355
+ {/* A terminal entry has no textarea to load into; the button would be dead. */}
1356
+ {editable ? (
1357
+ <button class="btn btn-secondary btn-sm" type="button" data-use-revision>
1358
+ Use
1359
+ </button>
1360
+ ) : null}
1361
+ </div>
1362
+ <div class="timeline-body clamp-2">{revision.human_text}</div>
1363
+ </div>
1364
+ </li>
1365
+ ))}
1366
+ </ol>
1367
+ )}
1368
+ </SupportSection>
1369
+ );
1370
+ }
1371
+
1372
+ /**
1373
+ * The live metrics under the textarea: the length against the cap, each required placeholder's
1374
+ * present/missing state, and the requested tone. Dashboard.js re-syncs these as the user types.
1375
+ */
1376
+ function ConstraintRow({ constraints, text }: { constraints: Constraints; text: string }) {
1377
+ const maxLen = constraints.maxLen;
1378
+ const placeholders = constraints.placeholders;
1379
+ const tone = constraints.tone;
1380
+ const hasAny = maxLen !== undefined || placeholders.length > 0 || tone !== undefined;
1381
+ if (!hasAny) return null;
1382
+ return (
1383
+ <div class="constraint-row">
1384
+ {maxLen !== undefined ? (
1385
+ <span id="char-count" class="num char-count" data-max-len={maxLen}>
1386
+ {text.length} / {maxLen}
1387
+ </span>
1388
+ ) : null}
1389
+ {placeholders.length > 0 ? (
1390
+ <ul class="placeholder-list">
1391
+ {placeholders.map((placeholder) => {
1392
+ const present = text.includes(placeholder);
1393
+ return (
1394
+ <li class="placeholder-item" data-placeholder={placeholder} key={placeholder}>
1395
+ <span class={`placeholder-state${present ? " is-present" : ""}`}>
1396
+ {present ? "Present" : "Missing"}
1397
+ </span>
1398
+ <span class="tag tag-data">{bracedPlaceholder(placeholder)}</span>
1399
+ </li>
1400
+ );
1401
+ })}
1402
+ </ul>
1403
+ ) : null}
1404
+ {tone !== undefined ? <span class="constraint-tone">Tone: {tone}</span> : null}
1405
+ </div>
1406
+ );
1407
+ }
1408
+
1409
+ /**
1410
+ * The proposal card for a draft or an approved entry: the textarea prefilled with what agents will
1411
+ * read, the constraints under it, and the one primary action. Saving a draft's text approves it,
1412
+ * so the primary reads "Save and approve" there; the old separate approve button silently discarded
1413
+ * edits and is gone. The textarea carries `hx-preserve`, so a swap keeps the user's focus and
1414
+ * typing while the server-rendered value stays what the last save left: the submitted text on a
1415
+ * refusal. The save button keeps the native submit; the reject button is an htmx transition.
1416
+ */
1417
+ function ProposalForm({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
1418
+ const entry = vm.entry;
1419
+ const text = state?.submittedText ?? entry.human_text ?? entry.agent_draft ?? "";
1420
+ const draft = entry.status === "draft";
1421
+ const reject = entry.status === "draft" || entry.status === "approved";
1422
+ return (
1423
+ <form
1424
+ id="editor-form"
1425
+ class="card"
1426
+ method="post"
1427
+ action={`/entries/${entry.id}/save`}
1428
+ hx-post={`/entries/${entry.id}/save`}
1429
+ hx-target="#editor-view"
1430
+ hx-swap="outerHTML"
1431
+ hx-indicator="#editor-action-loading"
1432
+ >
1433
+ <input type="hidden" name="view" value="editor" />
1434
+ <div class="card-header">
1435
+ <div>
1436
+ <div class="card-title">Proposed text</div>
1437
+ <div class="card-subtitle">
1438
+ {draft
1439
+ ? "Saving the text approves the entry and releases it to agents."
1440
+ : "Saving updates the text agents will apply."}
1441
+ </div>
1442
+ </div>
1443
+ </div>
1444
+ <div class="card-content">
1445
+ <textarea
1446
+ id="editor-text"
1447
+ class="input editor-pane"
1448
+ name="text"
1449
+ aria-label="Entry text"
1450
+ hx-preserve="true"
1451
+ data-autosize
1452
+ hx-post={`/entries/${entry.id}/diff`}
1453
+ hx-trigger="input changed delay:250ms"
1454
+ hx-target="#current-text"
1100
1455
  hx-swap="outerHTML"
1456
+ hx-sync="this:replace"
1101
1457
  >
1102
- <div class="field notes-field">
1103
- <label class="field-label" for="editor-notes">Your notes</label>
1104
- <textarea
1105
- id="editor-notes"
1106
- class="input"
1107
- name="notes"
1108
- rows={4}
1109
- aria-label="Your notes"
1110
- hx-preserve="true"
1458
+ {text}
1459
+ </textarea>
1460
+ <ConstraintRow constraints={vm.constraints} text={text} />
1461
+ <div class="editor-actions">
1462
+ <button class="btn btn-fill" type="submit" hx-disabled-elt="this">
1463
+ {draft ? "Save and approve" : "Save text"}
1464
+ </button>
1465
+ {reject ? (
1466
+ <button
1467
+ type="button"
1468
+ class="btn btn-danger"
1469
+ hx-post={`/entries/${entry.id}/reject`}
1470
+ hx-target="#editor-view"
1471
+ hx-swap="outerHTML"
1472
+ hx-indicator="#editor-action-loading"
1473
+ hx-disabled-elt="this"
1111
1474
  >
1112
- {vm.entry.human_notes ?? ""}
1113
- </textarea>
1114
- </div>
1115
- <div class="notes-actions">
1116
- <button class="btn btn-secondary btn-sm" type="submit">Save notes</button>
1117
- </div>
1118
- </form>
1475
+ Reject
1476
+ </button>
1477
+ ) : null}
1478
+ <span id="editor-action-loading" class="htmx-indicator" role="status">
1479
+ <span class="spinner" aria-hidden="true"></span>
1480
+ Updating entry
1481
+ </span>
1482
+ </div>
1483
+ </div>
1484
+ </form>
1485
+ );
1486
+ }
1487
+
1488
+ /** The read-only proposal for a terminal entry: the settled text in the comparison pane. */
1489
+ function TerminalTextCard({ entry }: { entry: EntryRow }) {
1490
+ return (
1491
+ <div class="card">
1492
+ <div class="card-header">
1493
+ <div>
1494
+ <div class="card-title">Settled text</div>
1495
+ </div>
1496
+ </div>
1497
+ <div class="card-content">
1498
+ <pre class="diff-code editor-pane">{entry.human_text ?? ""}</pre>
1119
1499
  </div>
1120
1500
  </div>
1121
1501
  );
1122
1502
  }
1123
1503
 
1504
+ /**
1505
+ * The proposal pane: the editable card for a draft or approved entry, or the settled text of a
1506
+ * terminal one. A terminal entry has no change card, so the pane spans the full workspace. The
1507
+ * swap region lives here; the refusal and read-only notices sit above the pair.
1508
+ */
1509
+ function EditorRegion({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
1510
+ const terminal = vm.entry.status === "applied" || vm.entry.status === "rejected";
1511
+ return (
1512
+ <div id="editor-region" tabindex={-1} class={terminal ? "editor-region-wide" : undefined}>
1513
+ {terminal ? <TerminalTextCard entry={vm.entry} /> : <ProposalForm vm={vm} state={state} />}
1514
+ </div>
1515
+ );
1516
+ }
1517
+
1518
+ /**
1519
+ * The notes section: the agent's notes (from `constraints.notes`, read-only) above the human's own
1520
+ * notes as an editable textarea with its own save. The textarea carries `hx-preserve`, so any swap
1521
+ * (a text save, an image add) keeps the human's in-progress note; the save targets the whole
1522
+ * editor view like the save form, so the saved value re-renders in place. The section always
1523
+ * renders: the human's notes need a home even before the agent sent any.
1524
+ */
1525
+ function NotesSection({ vm }: { vm: EditorViewModel }) {
1526
+ return (
1527
+ <SupportSection label="Notes">
1528
+ <table class="info-table">
1529
+ <tr>
1530
+ <td>Agent's notes</td>
1531
+ <td>{vm.constraints.notes ?? "None."}</td>
1532
+ </tr>
1533
+ </table>
1534
+ <form
1535
+ id="notes-form"
1536
+ method="post"
1537
+ action={`/entries/${vm.entry.id}/notes`}
1538
+ hx-post={`/entries/${vm.entry.id}/notes`}
1539
+ hx-target="#editor-view"
1540
+ hx-swap="outerHTML"
1541
+ >
1542
+ <div class="field notes-field">
1543
+ <label class="field-label" for="editor-notes">Your notes</label>
1544
+ <textarea
1545
+ id="editor-notes"
1546
+ class="input"
1547
+ name="notes"
1548
+ rows={4}
1549
+ aria-label="Your notes"
1550
+ hx-preserve="true"
1551
+ >
1552
+ {vm.entry.human_notes ?? ""}
1553
+ </textarea>
1554
+ </div>
1555
+ <div class="notes-actions">
1556
+ <button class="btn btn-secondary btn-sm" type="submit">Save notes</button>
1557
+ </div>
1558
+ </form>
1559
+ </SupportSection>
1560
+ );
1561
+ }
1562
+
1124
1563
  /** An image item renders only when it still matches the prefixes the wire validated; a foreign row can hold anything, so a non-matching item shows a note instead, never raw. */
1125
1564
  function isRenderableImage(item: string): boolean {
1126
1565
  return item.startsWith("data:image/") || /^https?:\/\//.test(item);
1127
1566
  }
1128
1567
 
1129
1568
  /**
1130
- * The images attached to an entry, one card in the rail: each valid item as a real `img` (sized by
1131
- * css), an invalid one as a "not shown" note, a remove form per image, and the one-file upload
1132
- * form. A card of its own, not a strip in the context card: upload and remove need a stable home,
1133
- * and the context card stays lean for what the human authors against. Both forms target the whole
1134
- * editor view like the save form, so a change re-renders the card in place.
1569
+ * The images attached to an entry, one section: each valid item as a real `img` (sized by css), an
1570
+ * invalid one as a "not shown" note, a remove form per image, and the one-file upload as the
1571
+ * system dropzone. Both forms target the whole editor view like the save form, so a change
1572
+ * re-renders the section in place.
1135
1573
  */
1136
- function ImagesCard({ entry }: { entry: EntryRow }) {
1574
+ function ImagesSection({ entry }: { entry: EntryRow }) {
1137
1575
  const images = parseEntryImages(entry.images);
1138
1576
  return (
1139
- <div class="card">
1140
- <div class="card-header">
1141
- <div class="card-title">Images</div>
1142
- </div>
1143
- <div class="card-content">
1577
+ <SupportSection label="Images">
1578
+ <div class="dropzone">
1579
+ {/* The native input is the picker and its accessible name; the zone is its paint.
1580
+ It is associated to the submit form by id rather than nested in it, so the zone
1581
+ keeps the direct-child shape the dropzone's own state rules select on. */}
1582
+ <label class="dropzone-target">
1583
+ <svg class="icon" aria-hidden="true"><use href="#i-upload" /></svg>
1584
+ <span class="dropzone-lede">Drop an image, or choose one</span>
1585
+ <input
1586
+ class="dropzone-input"
1587
+ type="file"
1588
+ name="file"
1589
+ accept="image/*"
1590
+ aria-label="Drop an image, or choose one"
1591
+ form="image-upload"
1592
+ />
1593
+ </label>
1144
1594
  {images.length === 0 ? (
1145
1595
  <p class="field-hint">No images yet.</p>
1146
1596
  ) : (
1147
- <ul class="image-list">
1597
+ <ul class="dropzone-list image-list">
1148
1598
  {images.map((item, index) =>
1149
1599
  isRenderableImage(item) ? (
1150
1600
  <li key={index}>
@@ -1169,6 +1619,7 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
1169
1619
  </ul>
1170
1620
  )}
1171
1621
  <form
1622
+ id="image-upload"
1172
1623
  class="image-upload"
1173
1624
  method="post"
1174
1625
  action={`/entries/${entry.id}/images`}
@@ -1178,258 +1629,138 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
1178
1629
  hx-target="#editor-view"
1179
1630
  hx-swap="outerHTML"
1180
1631
  >
1181
- <input class="input" type="file" name="file" accept="image/*" aria-label="Image file" />
1182
1632
  <button class="btn btn-secondary btn-sm" type="submit" hx-disabled-elt="this">Add image</button>
1183
1633
  </form>
1184
1634
  </div>
1185
- </div>
1635
+ </SupportSection>
1186
1636
  );
1187
1637
  }
1188
1638
 
1189
1639
  /**
1190
1640
  * The repo row's value: a recognized remote renders as its brand icon beside the bare owner/repo
1191
- * path, both linking out to the host; an unrecognized one renders as the raw string as filed.
1641
+ * path, both linking out to the host; an unrecognized one renders as the raw string as filed. The
1642
+ * visible path is the short form, so the full remote rides along for assistive tech.
1192
1643
  */
1193
1644
  function RepoValue({ repo }: { repo: string }) {
1194
1645
  const link = parseRepoLink(repo);
1195
1646
  if (link === null) return <>{repo}</>;
1196
1647
  return (
1197
- <a class="repo-link" href={link.href} target="_blank" rel="noopener noreferrer" title={repo}>
1648
+ <a class="repo-link" href={link.href} target="_blank" rel="noopener noreferrer">
1198
1649
  <svg class="repo-icon" viewBox="0 0 24 24" aria-hidden="true">
1199
1650
  <path d={REPO_ICON[link.provider]} />
1200
1651
  </svg>
1201
1652
  <span>{link.path}</span>
1653
+ <span class="sr-only">{repo}</span>
1202
1654
  </a>
1203
1655
  );
1204
1656
  }
1205
1657
 
1206
1658
  /**
1207
- * The entry's context JSON plus its anchor metadata, as an info-table readout. The anchor and the
1208
- * agent-supplied context rows lead — they are what the human authors against — then repo and file;
1209
- * the provenance rows (hash, filer, stale note) are secondary and sit collapsed behind a summary.
1210
- */
1211
- function ContextCard({ entry }: { entry: EntryRow }) {
1212
- const context = parseContext(entry.context);
1213
- return (
1214
- <div class="card">
1215
- <div class="card-header">
1216
- <div class="card-title">Context</div>
1217
- </div>
1218
- <div class="card-content">
1219
- <table class="info-table">
1220
- <tr>
1221
- <td>Anchor</td>
1222
- <td>{entry.anchor_text}</td>
1223
- </tr>
1224
- {Object.entries(context).map(([key, value]) => (
1225
- <tr key={key}>
1226
- <td>{key}</td>
1227
- <td>{typeof value === "string" ? value : JSON.stringify(value)}</td>
1228
- </tr>
1229
- ))}
1230
- <tr>
1231
- <td>Repo</td>
1232
- <td><RepoValue repo={entry.repo} /></td>
1233
- </tr>
1234
- <tr>
1235
- <td>File</td>
1236
- <td>{entry.file}</td>
1237
- </tr>
1238
- </table>
1239
- <details class="context-secondary">
1240
- <summary>More</summary>
1241
- <table class="info-table">
1242
- <tr>
1243
- <td>File hash</td>
1244
- <td>{entry.file_hash}</td>
1245
- </tr>
1246
- {entry.filed_by !== null ? (
1247
- <tr>
1248
- <td>Filed by</td>
1249
- <td>{entry.filed_by}</td>
1250
- </tr>
1251
- ) : null}
1252
- {entry.stale_note !== null ? (
1253
- <tr>
1254
- <td>Stale note</td>
1255
- <td>{entry.stale_note}</td>
1256
- </tr>
1257
- ) : null}
1258
- </table>
1259
- </details>
1260
- </div>
1261
- </div>
1262
- );
1263
- }
1264
-
1265
- /**
1266
- * The before-and-after of the entry's text, above the editor: what the anchored region reads now
1267
- * (the anchor visually distinct) and what the entry's text will replace it with. Both sides
1268
- * escape through JSX; the text is the stored bytes, shown as-is. The word-level marks come from
1269
- * the server-side walk: words only the anchor has render red in the before card, words only the proposal
1270
- * has render green in the after card, everything shared stays plain, so an identical pair renders
1271
- * no marks at all.
1272
- */
1273
- function DiffView({ entry }: { entry: EntryRow }) {
1274
- const after = entry.human_text ?? entry.agent_draft;
1275
- // No text at all means nothing to compare: a filed entry whose agent never drafted, or an
1276
- // approved entry whose text was never set.
1277
- if (after === null) return null;
1278
- const diff = diffWords(entry.anchor_text, after);
1279
- return (
1280
- <div class="diff-grid">
1281
- <div class="card">
1282
- <div class="card-header">
1283
- <div class="card-title">Before</div>
1284
- </div>
1285
- <pre class="diff-code">
1286
- <span class="diff-line">{entry.anchor_before}</span>
1287
- <span class="diff-line diff-anchor">
1288
- {diff.before.map((token, index) =>
1289
- token.kind === "removed" ? (
1290
- <span class="diff-remove" key={index}>{token.text}</span>
1291
- ) : (
1292
- token.text
1293
- ),
1294
- )}
1295
- </span>
1296
- <span class="diff-line">{entry.anchor_after}</span>
1297
- </pre>
1298
- </div>
1299
- <div class="card">
1300
- <div class="card-header">
1301
- <div class="card-title">After</div>
1302
- </div>
1303
- <pre class="diff-code">
1304
- {diff.after.map((token, index) =>
1305
- token.kind === "added" ? (
1306
- <span class="diff-add" key={index}>{token.text}</span>
1307
- ) : (
1308
- token.text
1309
- ),
1310
- )}
1311
- </pre>
1312
- </div>
1313
- </div>
1314
- );
1315
- }
1316
-
1317
- /**
1318
- * The editor view: the diff above the editor grid, one swap node. A save changes the diff's
1319
- * after-side, so the save form targets this wrapper and its response re-renders the whole view;
1320
- * the transitions target only the region inside (they never change what the diff shows).
1659
+ * The editor view, one swap node: the change card beside the proposal pane, an optional notice
1660
+ * above them, then one support card holding the four sections across the full width. A terminal
1661
+ * entry drops the change card and lets the settled pane span the workspace. A save re-renders the
1662
+ * whole view because the marks follow the text and the status can move; the live diff swaps only
1663
+ * the current-text card.
1321
1664
  */
1322
1665
  function EditorView({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
1323
- const text = state?.submittedText ?? vm.entry.human_text ?? vm.entry.agent_draft ?? "";
1666
+ const entry = vm.entry;
1667
+ const terminal = entry.status === "applied" || entry.status === "rejected";
1668
+ const applied = entry.status === "applied";
1669
+ const compareText = state?.submittedText ?? entry.human_text ?? entry.agent_draft;
1324
1670
  return (
1325
1671
  <div id="editor-view">
1326
- <DiffView entry={vm.entry} />
1672
+ <div id="editor-notice">
1673
+ {state?.refusal !== undefined ? (
1674
+ <DangerCallout title={state.refusal.title} body={state.refusal.body} />
1675
+ ) : terminal ? (
1676
+ <InfoCallout
1677
+ title="Read only"
1678
+ body={
1679
+ applied
1680
+ ? "This entry is already applied, so its text can't be changed here. If the anchor goes stale, the entry returns to approved and can be edited."
1681
+ : "This entry was rejected, so its text can't be changed. A rejected entry stays rejected; file a new entry if the passage still needs changing."
1682
+ }
1683
+ />
1684
+ ) : null}
1685
+ </div>
1327
1686
  <div class="editor-grid">
1328
- <div class="editor-main">
1329
- <EditorRegion vm={vm} state={state} />
1687
+ {terminal ? null : currentTextCard(entry, compareText)}
1688
+ <EditorRegion vm={vm} state={state} />
1689
+ <div class="editor-support">
1690
+ <div class="card editor-support-card">
1691
+ <div class="card-content">
1692
+ <DetailsSection entry={entry} />
1693
+ <NotesSection vm={vm} />
1694
+ <ImagesSection entry={entry} />
1695
+ <RevisionHistorySection revisions={vm.revisions} editable={!terminal} />
1696
+ </div>
1697
+ </div>
1330
1698
  </div>
1331
- <aside class="editor-rail">
1332
- <ContextCard entry={vm.entry} />
1333
- <ConstraintsCard constraints={vm.constraints} text={text} />
1334
- <NotesCard vm={vm} />
1335
- <ImagesCard entry={vm.entry} />
1336
- </aside>
1337
1699
  </div>
1338
1700
  </div>
1339
1701
  );
1340
1702
  }
1341
1703
 
1342
- /** The editor's full-height prev/next rails: one fixed strip per side, a link when a neighbour exists. */
1343
- function EditorNav({ prevId, nextId }: { prevId: string | null; nextId: string | null }) {
1344
- return (
1345
- <nav class="editor-nav" aria-label="Entry navigation">
1346
- <EditorNavButton side="prev" targetId={prevId} />
1347
- <EditorNavButton side="next" targetId={nextId} />
1348
- </nav>
1349
- );
1350
- }
1351
-
1352
- /** One rail: a chevron on a full-height strip, or an inert placeholder at the boundary. */
1353
- function EditorNavButton({ side, targetId }: { side: "prev" | "next"; targetId: string | null }) {
1354
- const label = side === "prev" ? "Previous entry" : "Next entry";
1355
- const cls = `editor-nav-btn editor-nav-${side}`;
1356
- const icon = (
1357
- <svg aria-hidden="true"><use href={side === "prev" ? "#i-chevron-left" : "#i-chevron-right"} /></svg>
1358
- );
1359
- if (targetId === null) {
1360
- return (
1361
- <span class={`${cls} is-disabled`} aria-hidden="true">
1362
- {icon}
1363
- </span>
1364
- );
1365
- }
1366
- return (
1367
- <a class={cls} href={`/entries/${targetId}`} aria-label={label}>
1368
- {icon}
1369
- </a>
1370
- );
1371
- }
1372
-
1373
- /** The full editor page; the mount wraps it in the doctype. `signedIn` is whether the navbar offers sign-out. */
1704
+ /** The full editor page; the mount wraps it in the doctype. `signedIn` is whether the sidebar offers sign-out. */
1374
1705
  export function editorPage(vm: EditorViewModel, state: EditorState | undefined = undefined, signedIn = false): JSX.Element {
1375
1706
  return (
1376
1707
  <html lang="en" data-theme="dark">
1377
1708
  <PageHead title={`Edit entry · ${NAME}`} />
1378
1709
  <body>
1710
+ <a class="skip-link" href="#main">Skip to content</a>
1379
1711
  <IconSprite />
1380
- <div class="app-shell">
1381
- <Navbar signedIn={signedIn} active="entries" />
1382
- <main class="main">
1383
- <header class="page-header">
1384
- <div class="page-header-body">
1385
- <div class="label page-eyebrow">Entry review</div>
1386
- <h1 class="page-title">Edit entry</h1>
1387
- <p class="page-lede">Saving the text approves the entry and releases it to agents.</p>
1388
- </div>
1389
- </header>
1712
+ <Shell signedIn={signedIn} active="entries" draftCount={vm.draftCount}>
1713
+ {/* The workspace, header and support card share the same designed column, so the
1714
+ comparison and its rows keep a readable measure inside the wider shell track. */}
1715
+ <div class="editor-page">
1716
+ <EditorHeader vm={vm} />
1390
1717
  <EditorView vm={vm} state={state} />
1391
- <EditorNav prevId={vm.prevId} nextId={vm.nextId} />
1392
1718
  {/* The one live region: announcements on constraint-state flips only, so a screen reader
1393
1719
  is not flooded with a per-keystroke value. Lives outside the swap region. */}
1394
1720
  <span id="editor-live" class="sr-only" aria-live="polite"></span>
1395
- </main>
1396
- </div>
1721
+ </div>
1722
+ </Shell>
1723
+ <div class="toast-stack" id="toast-stack"></div>
1397
1724
  <PageModules />
1398
1725
  </body>
1399
1726
  </html>
1400
1727
  );
1401
1728
  }
1402
1729
 
1403
- /** The editor region only, for htmx transition responses; the page shell, diff, context, and constraints stay put. */
1404
- export function editorFragment(vm: EditorViewModel, state: EditorState | undefined = undefined): JSX.Element {
1405
- return <EditorRegion vm={vm} state={state} />;
1406
- }
1407
-
1408
- /** The whole editor view (diff + region + rail), for htmx save responses: a save changes the diff's after-side, so the swap must re-render it. */
1730
+ /** The whole editor view (columns + region), for htmx save and transition responses: either can move the status, so the swap re-renders the history card's editable state and the header tag with it. */
1409
1731
  export function editorViewFragment(vm: EditorViewModel, state: EditorState | undefined = undefined): JSX.Element {
1410
- return <EditorView vm={vm} state={state} />;
1732
+ return (
1733
+ <>
1734
+ <DraftBadge count={vm.draftCount} oob />
1735
+ <EntryStatusTag status={vm.entry.status} oob />
1736
+ <EditorView vm={vm} state={state} />
1737
+ </>
1738
+ );
1411
1739
  }
1412
1740
 
1413
- /** The 404 page for an unknown entry id, with the way onward the page-not-found pattern demands. `signedIn` is whether the navbar offers sign-out. */
1414
- export function notFoundPage(signedIn = false): JSX.Element {
1741
+ /** The viewport-owning error page for an unknown entry id or route, with the way onward the page-not-found pattern demands. A route that never named an entry has no entry to miss. */
1742
+ export function notFoundPage(kind: "entry" | "route"): JSX.Element {
1743
+ const entry = kind === "entry";
1415
1744
  return (
1416
1745
  <html lang="en" data-theme="dark">
1417
- <PageHead title={`Entry not found · ${NAME}`} />
1746
+ <PageHead title={`${entry ? "Entry" : "Page"} not found · ${NAME}`} />
1418
1747
  <body>
1419
1748
  <IconSprite />
1420
- <div class="app-shell">
1421
- <Navbar signedIn={signedIn} active="entries" />
1422
- <main class="main">
1423
- <header class="page-header">
1424
- <div class="page-header-body">
1425
- <div class="label page-eyebrow">Entry review</div>
1426
- <h1 class="page-title">Entry not found</h1>
1427
- <p class="page-lede">This entry doesn't exist. Return to the list to find it.</p>
1749
+ <main class="page-state">
1750
+ <div class="page-state-gap">
1751
+ <div class="page-state-body">
1752
+ <p class="page-state-title">{entry ? "Entry not found" : "Page not found"}</p>
1753
+ <p class="page-state-text">
1754
+ {entry
1755
+ ? "This entry doesn't exist. Return to the list to find it."
1756
+ : "Nothing lives at this address."}
1757
+ </p>
1758
+ <div class="page-state-actions">
1759
+ <a href="/" class="btn btn-fill">Back to entries</a>
1428
1760
  </div>
1429
- </header>
1430
- <a href="/" class="btn btn-secondary">Back to entries</a>
1431
- </main>
1432
- </div>
1761
+ </div>
1762
+ </div>
1763
+ </main>
1433
1764
  <PageModules />
1434
1765
  </body>
1435
1766
  </html>
@@ -1439,7 +1770,7 @@ export function notFoundPage(signedIn = false): JSX.Element {
1439
1770
  /** The htmx fragment for a save on an id that vanished mid-session: htmx will not swap a 4xx, so this is a 200 with the way onward in the editor region itself. */
1440
1771
  export function entryGoneFragment(): JSX.Element {
1441
1772
  return (
1442
- <div id="editor-region">
1773
+ <div id="editor-region" tabindex={-1}>
1443
1774
  <DangerCallout title="This entry no longer exists" body="Return to the list to find it." />
1444
1775
  <a href="/" class="btn btn-secondary">Back to entries</a>
1445
1776
  </div>
@@ -1464,6 +1795,8 @@ export type StyleGuideViewModel = {
1464
1795
  readonly hasRow: boolean;
1465
1796
  /** The stored section, or the empty defaults when there is no row yet. */
1466
1797
  readonly section: StyleGuideInput;
1798
+ /** The sidebar's queue badge, read with the page's own store access. */
1799
+ readonly draftCount: number;
1467
1800
  };
1468
1801
 
1469
1802
  /** What the last save round settled into, rendered by the swap: the saved notice, the unknown-project notice, or the refusal with its message. The submitted form is kept so a refusal re-renders what the user typed. */
@@ -1487,6 +1820,7 @@ export function loadStyleGuide(store: Store, projectQuery: string | undefined):
1487
1820
  unknown,
1488
1821
  hasRow: section !== null,
1489
1822
  section: section ?? { markdown: "", bannedWords: [], glossary: {} },
1823
+ draftCount: countEntriesByStatus(store).draft,
1490
1824
  };
1491
1825
  }
1492
1826
 
@@ -1513,45 +1847,29 @@ function sectionToForm(section: StyleGuideInput): StyleGuideForm {
1513
1847
  };
1514
1848
  }
1515
1849
 
1516
- function InfoCallout({ title, body }: { title: string; body?: string }) {
1517
- return (
1518
- <div class="callout callout-info">
1519
- <div class="callout-icon">
1520
- <svg aria-hidden="true"><use href="#i-info" /></svg>
1521
- </div>
1522
- <div class="callout-content">
1523
- <div class="callout-title">{title}</div>
1524
- {body !== undefined ? <div class="callout-body">{body}</div> : null}
1525
- </div>
1526
- </div>
1527
- );
1528
- }
1529
-
1530
- /** The project selector: the global section plus every known project, as links, with the current one marked. A link list is the whole mechanism; the section never auto-creates a project. */
1850
+ /** The project selector: the global section plus every known project, as underline tabs with the current one marked. A link list is the whole mechanism; the section never auto-creates a project. ui.js builds the sliding ink. */
1531
1851
  function StyleGuideProjects({ vm }: { vm: StyleGuideViewModel }) {
1532
1852
  const activeSlug = vm.unknown ? null : vm.projectSlug;
1533
1853
  return (
1534
- <nav class="style-guide-projects" aria-label="Style guide sections">
1535
- <ProjectSectionLink href="/style-guide" label="Global" active={activeSlug === null} />
1536
- {vm.projects.map((project) => (
1537
- <ProjectSectionLink
1538
- key={project.slug}
1539
- href={`/style-guide?project=${project.slug}`}
1540
- label={project.slug}
1541
- active={activeSlug === project.slug}
1542
- />
1543
- ))}
1854
+ <nav class="tabs-wrap style-guide-projects" data-scroll-fade aria-label="Style guide sections">
1855
+ <div class="tabs">
1856
+ <ProjectSectionLink href="/style-guide" label="Global" active={activeSlug === null} />
1857
+ {vm.projects.map((project) => (
1858
+ <ProjectSectionLink
1859
+ key={project.slug}
1860
+ href={`/style-guide?project=${project.slug}`}
1861
+ label={project.slug}
1862
+ active={activeSlug === project.slug}
1863
+ />
1864
+ ))}
1865
+ </div>
1544
1866
  </nav>
1545
1867
  );
1546
1868
  }
1547
1869
 
1548
1870
  function ProjectSectionLink({ href, label, active }: { href: string; label: string; active: boolean }) {
1549
1871
  return (
1550
- <a
1551
- class={`btn btn-sm ${active ? "btn-primary" : "btn-secondary"}`}
1552
- href={href}
1553
- aria-current={active ? "page" : undefined}
1554
- >
1872
+ <a class={`tab${active ? " active" : ""}`} href={href} aria-current={active ? "page" : undefined}>
1555
1873
  {label}
1556
1874
  </a>
1557
1875
  );
@@ -1597,78 +1915,59 @@ function StyleGuideRegion({ vm, state }: { vm: StyleGuideViewModel; state: Style
1597
1915
  body="Your save creates it."
1598
1916
  />
1599
1917
  ) : null}
1600
- <form
1601
- id="style-guide-form"
1602
- class="card style-guide-card"
1603
- method="post"
1604
- action="/style-guide/save"
1605
- hx-post="/style-guide/save"
1606
- hx-target="#style-guide-region"
1607
- hx-swap="outerHTML"
1608
- hx-indicator="#style-guide-loading"
1609
- >
1610
- <input type="hidden" name="project" value={vm.projectSlug ?? ""} />
1611
- <div class="card-header">
1612
- <div class="card-title">{vm.projectSlug === null ? "Global guide" : `${vm.projectSlug} guide`}</div>
1613
- </div>
1614
- <div class="card-content">
1615
- <div class="field">
1616
- <label class="field-label" for="style-guide-markdown">Markdown</label>
1617
- <textarea class="input" id="style-guide-markdown" name="markdown" rows={10} aria-label="Guide markdown">
1618
- {formValues.markdown}
1619
- </textarea>
1620
- {vm.hasRow && vm.section.markdown === "" ? <p class="field-hint">No markdown yet.</p> : null}
1621
- </div>
1622
- <div class="field">
1623
- <label class="field-label" for="style-guide-banned">Banned words</label>
1624
- <input class="input" id="style-guide-banned" name="banned_words" type="text" value={formValues.bannedWordsCsv} />
1625
- <p class="field-hint">Comma-separated. Extra whitespace and duplicates are dropped.</p>
1626
- {vm.hasRow && vm.section.bannedWords.length === 0 ? <p class="field-hint">No banned words yet.</p> : null}
1627
- </div>
1628
- <div class="field">
1629
- <label class="field-label" for="style-guide-glossary">Glossary</label>
1630
- <textarea class="input" id="style-guide-glossary" name="glossary" rows={6} aria-label="Glossary entries">
1631
- {formValues.glossaryText}
1632
- </textarea>
1633
- <p class="field-hint">One key: value pair per line. A line without a colon is refused.</p>
1634
- {vm.hasRow && Object.keys(vm.section.glossary).length === 0 ? (
1635
- <p class="field-hint">No glossary entries yet.</p>
1636
- ) : null}
1637
- </div>
1638
- <div class="editor-actions">
1639
- <button class="btn btn-primary" type="submit" hx-disabled-elt="this">Save guide</button>
1640
- <span id="style-guide-loading" class="htmx-indicator" role="status">
1641
- <span class="spinner" aria-hidden="true"></span>
1642
- Saving guide
1643
- </span>
1918
+ <div class="card style-guide-card">
1919
+ <form
1920
+ id="style-guide-form"
1921
+ method="post"
1922
+ action="/style-guide/save"
1923
+ hx-post="/style-guide/save"
1924
+ hx-target="#style-guide-region"
1925
+ hx-swap="outerHTML"
1926
+ hx-indicator="#style-guide-loading"
1927
+ >
1928
+ <input type="hidden" name="project" value={vm.projectSlug ?? ""} />
1929
+ <div class="card-header">
1930
+ <div class="card-title">{vm.projectSlug === null ? "Global guide" : `${vm.projectSlug} guide`}</div>
1644
1931
  </div>
1645
- </div>
1646
- </form>
1647
- </div>
1648
- );
1649
- }
1650
-
1651
- /** The error box htmx failures clone into the style guide region; the retry button re-submits the save form. */
1652
- function StyleGuideErrorBoxTemplate() {
1653
- return (
1654
- <template id="style-guide-error-box">
1655
- <div class="callout callout-danger" role="alert">
1656
- <div class="callout-icon">
1657
- <svg aria-hidden="true"><use href="#i-error" /></svg>
1658
- </div>
1659
- <div class="callout-content">
1660
- <div class="callout-title">The guide could not be saved</div>
1661
- <div class="callout-body">The save request failed. Try it again.</div>
1662
- <div class="callout-actions">
1663
- <button class="btn btn-secondary btn-sm" type="button" data-retry>Retry</button>
1932
+ <div class="card-content style-guide-fields">
1933
+ <div class="field">
1934
+ <label class="field-label" for="style-guide-markdown">Markdown</label>
1935
+ <textarea class="input" id="style-guide-markdown" name="markdown" rows={10} aria-label="Guide markdown" data-autosize data-scroll-fade>
1936
+ {formValues.markdown}
1937
+ </textarea>
1938
+ {vm.hasRow && vm.section.markdown === "" ? <p class="field-hint">No markdown yet.</p> : null}
1939
+ </div>
1940
+ <div class="field">
1941
+ <label class="field-label" for="style-guide-banned">Banned words</label>
1942
+ <input class="input" id="style-guide-banned" name="banned_words" type="text" value={formValues.bannedWordsCsv} />
1943
+ <p class="field-hint">Comma-separated. Extra whitespace and duplicates are dropped.</p>
1944
+ {vm.hasRow && vm.section.bannedWords.length === 0 ? <p class="field-hint">No banned words yet.</p> : null}
1945
+ </div>
1946
+ <div class="field">
1947
+ <label class="field-label" for="style-guide-glossary">Glossary</label>
1948
+ <textarea class="input" id="style-guide-glossary" name="glossary" rows={6} aria-label="Glossary entries" data-autosize data-scroll-fade>
1949
+ {formValues.glossaryText}
1950
+ </textarea>
1951
+ <p class="field-hint">One key: value pair per line. A line without a colon is refused.</p>
1952
+ {vm.hasRow && Object.keys(vm.section.glossary).length === 0 ? (
1953
+ <p class="field-hint">No glossary entries yet.</p>
1954
+ ) : null}
1955
+ </div>
1956
+ <div class="editor-actions">
1957
+ <button class="btn btn-fill" type="submit" hx-disabled-elt="this">Save guide</button>
1958
+ <span id="style-guide-loading" class="htmx-indicator" role="status">
1959
+ <span class="spinner" aria-hidden="true"></span>
1960
+ Saving guide
1961
+ </span>
1962
+ </div>
1664
1963
  </div>
1665
- </div>
1964
+ </form>
1666
1965
  </div>
1667
- </template>
1966
+ </div>
1668
1967
  );
1669
1968
  }
1670
1969
 
1671
- /** The full style guide page; the mount wraps it in the doctype. `signedIn` is whether the navbar offers sign-out. */
1970
+ /** The full style guide page; the mount wraps it in the doctype. `signedIn` is whether the sidebar offers sign-out. */
1672
1971
  export function styleGuidePage(
1673
1972
  vm: StyleGuideViewModel,
1674
1973
  state: StyleGuideEditorState | undefined = undefined,
@@ -1678,29 +1977,27 @@ export function styleGuidePage(
1678
1977
  <html lang="en" data-theme="dark">
1679
1978
  <PageHead title={`Style guide · ${NAME}`} />
1680
1979
  <body>
1980
+ <a class="skip-link" href="#main">Skip to content</a>
1681
1981
  <IconSprite />
1682
- <div class="app-shell">
1683
- <Navbar signedIn={signedIn} active="style-guide" />
1684
- <main class="main">
1685
- <header class="page-header">
1686
- <div class="page-header-body">
1687
- <div class="label page-eyebrow">Voice</div>
1688
- <h1 class="page-title">Style guide</h1>
1689
- <p class="page-lede">The voice agents read before drafting, starting with the global guide and adding each project's own rules below.</p>
1690
- </div>
1691
- </header>
1692
- <StyleGuideProjects vm={vm} />
1693
- <StyleGuideRegion vm={vm} state={state} />
1694
- <StyleGuideErrorBoxTemplate />
1695
- </main>
1696
- </div>
1982
+ <Shell signedIn={signedIn} active="style-guide" draftCount={vm.draftCount}>
1983
+ <header class="page-header">
1984
+ <div class="page-header-body">
1985
+ <div class="label">Voice</div>
1986
+ <h1 class="page-title">Style guide</h1>
1987
+ <p class="section-lede">The voice agents read before drafting, starting with the global guide and adding each project's own rules below.</p>
1988
+ </div>
1989
+ </header>
1990
+ <StyleGuideProjects vm={vm} />
1991
+ <StyleGuideRegion vm={vm} state={state} />
1992
+ </Shell>
1993
+ <div class="toast-stack" id="toast-stack"></div>
1697
1994
  <PageModules />
1698
1995
  </body>
1699
1996
  </html>
1700
1997
  );
1701
1998
  }
1702
1999
 
1703
- /** The style guide region only, for htmx responses; the selector, the page shell, and the error template stay put. */
2000
+ /** The style guide region only, for htmx responses; the selector, the page shell, and the toast stack stay put. */
1704
2001
  export function styleGuideRegionFragment(vm: StyleGuideViewModel, state: StyleGuideEditorState | undefined = undefined): JSX.Element {
1705
2002
  return <StyleGuideRegion vm={vm} state={state} />;
1706
2003
  }