reviewzy 0.2.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,10 +1,14 @@
1
1
  /** @jsxImportSource hono/jsx */
2
+ import { readFileSync } from "node:fs";
3
+ import { raw } from "hono/html";
4
+ import type { Child } from "hono/jsx";
2
5
  import type { JSX } from "hono/jsx/jsx-runtime";
3
6
  import { listRevisions, parseConstraints, parseEntryImages } from "../db/human-save.ts";
4
7
  import type { Constraints, HumanSaveRefusal, RevisionRow, TransitionRefusal } from "../db/human-save.ts";
5
8
  import {
6
9
  adjacentEntryIds,
7
10
  countEntries,
11
+ countEntriesByStatus,
8
12
  listEntries,
9
13
  listProjects,
10
14
  projectIdBySlug,
@@ -15,7 +19,8 @@ import type { StyleGuideForm, StyleGuideFormRefusal, StyleGuideInput } from "../
15
19
  import type { Store } from "../db/store.ts";
16
20
  import { parseRepoLink, REPO_ICON } from "./repo-link.ts";
17
21
  import { diffWords } from "./word-diff.ts";
18
- import { NAME, VERSION } from "../version.ts";
22
+ import type { WordDiffToken } from "./word-diff.ts";
23
+ import { NAME } from "../version.ts";
19
24
 
20
25
  /**
21
26
  * The three dashboard filters, straight off the query string. `status` and `project` are the raw
@@ -33,7 +38,7 @@ const PAGE_SIZE = 200;
33
38
 
34
39
  const STATUSES: readonly EntryStatus[] = ["draft", "approved", "applied", "rejected"];
35
40
 
36
- /** 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. */
37
42
  const ACTIVE_STATUSES: readonly EntryStatus[] = ["draft", "approved"];
38
43
 
39
44
  function isEntryStatus(value: string): value is EntryStatus {
@@ -54,11 +59,20 @@ const STATUS_LABEL: Record<EntryStatus, string> = {
54
59
  rejected: "Rejected",
55
60
  };
56
61
 
57
- type ProjectBatch = { id: string; entries: EntryRow[] };
58
- 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[] };
59
71
 
60
72
  export type ListViewModel = {
61
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>;
62
76
  readonly shown: number;
63
77
  readonly groups: readonly ProjectGroup[];
64
78
  readonly projects: readonly { id: string; slug: string }[];
@@ -67,7 +81,7 @@ export type ListViewModel = {
67
81
  readonly status: string;
68
82
  readonly project: string;
69
83
  readonly filtersActive: boolean;
70
- /** 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. */
71
85
  readonly hiddenByActiveView: boolean;
72
86
  };
73
87
 
@@ -94,63 +108,19 @@ function allEntries(
94
108
  }
95
109
 
96
110
  /**
97
- * Groups the filtered rows by project (slug order) then batch; within a batch the rows keep the
98
- * list query's ascending id order. Within a project, batches split into two partitions: any batch
99
- * holding a draft or approved entry is actionable and leads, terminal-only batches (applied and
100
- * rejected) follow. Each partition sorts newest batch first, with a deterministic order for
101
- * 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.
102
113
  */
103
114
  function groupRows(rows: readonly EntryRow[], slugOf: Map<string, string>): ProjectGroup[] {
104
115
  const byProject = new Map<string, EntryRow[]>();
105
116
  for (const row of rows) {
106
117
  byProject.set(row.project_id, [...(byProject.get(row.project_id) ?? []), row]);
107
118
  }
108
- const projectIds = [...byProject.keys()].sort((a, b) => (slugOf.get(a) ?? a).localeCompare(slugOf.get(b) ?? b));
109
- return projectIds.map((projectId) => {
110
- const projectRows = byProject.get(projectId)!;
111
- const byBatch = new Map<string, EntryRow[]>();
112
- for (const row of projectRows) {
113
- byBatch.set(row.batch_id, [...(byBatch.get(row.batch_id) ?? []), row]);
114
- }
115
- const batches = [...byBatch.keys()].map((batchId) => ({ id: batchId, entries: byBatch.get(batchId)! }));
116
- const actionable = batches.filter(isActionableBatch).sort(newestBatchFirst);
117
- const terminal = batches.filter((batch) => !isActionableBatch(batch)).sort(newestBatchFirst);
118
- return { slug: slugOf.get(projectId) ?? projectId, batches: [...actionable, ...terminal] };
119
- });
120
- }
121
-
122
- /** A batch is actionable when any of its entries still needs a human: draft or approved. */
123
- function isActionableBatch(batch: ProjectBatch): boolean {
124
- return batch.entries.some((entry) => entry.status === "draft" || entry.status === "approved");
125
- }
126
-
127
- /** Code-unit descending comparison: ulids are fixed-length Crockford base32, so this equals time order. */
128
- function compareDesc(a: string, b: string): number {
129
- return a < b ? 1 : a > b ? -1 : 0;
130
- }
131
-
132
- /** The newest `created_at` in a batch, walked: a batch can hold thousands of entries, and a spread would overflow the call stack. */
133
- function maxCreatedAt(batch: ProjectBatch): number {
134
- let max = 0;
135
- for (const entry of batch.entries) {
136
- if (entry.created_at > max) max = entry.created_at;
137
- }
138
- return max;
139
- }
140
-
141
- /**
142
- * Newest batch first. Batch ulids share a millisecond timestamp prefix, so a descending id is
143
- * newest-first; batches minted in the same millisecond tie on that prefix, and the batch's
144
- * newest `created_at` breaks the tie before the id itself, so a same-ms burst still renders in
145
- * one fixed order.
146
- */
147
- function newestBatchFirst(a: ProjectBatch, b: ProjectBatch): number {
148
- const timeCmp = compareDesc(a.id.slice(0, 10), b.id.slice(0, 10));
149
- if (timeCmp !== 0) return timeCmp;
150
- const aCreated = maxCreatedAt(a);
151
- const bCreated = maxCreatedAt(b);
152
- if (aCreated !== bCreated) return bCreated - aCreated;
153
- 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));
154
124
  }
155
125
 
156
126
  function loadList(store: Store, params: ListParams): ListViewModel {
@@ -159,8 +129,8 @@ function loadList(store: Store, params: ListParams): ListViewModel {
159
129
  const statusRaw = params.status;
160
130
  const statusActive = statusRaw !== undefined && statusRaw !== "";
161
131
  // `all` is the explicit "every status" filter value; the default (no status param, or an empty
162
- // one) is the Active view of draft + approved only, which is not a filter and never renders the
163
- // 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.
164
134
  const allStatuses = statusActive && statusRaw === "all";
165
135
  const status = statusActive && !allStatuses && isEntryStatus(statusRaw) ? statusRaw : undefined;
166
136
  const projectRaw = params.project;
@@ -176,6 +146,7 @@ function loadList(store: Store, params: ListParams): ListViewModel {
176
146
  const slugOf = new Map(projects.map((project) => [project.id, project.slug]));
177
147
  return {
178
148
  total: countEntries(store),
149
+ counts: countEntriesByStatus(store),
179
150
  shown: rows.length,
180
151
  groups: groupRows(rows, slugOf),
181
152
  projects,
@@ -205,45 +176,282 @@ function entryTitle(entry: EntryRow): string {
205
176
  return fileBasename(entry.file);
206
177
  }
207
178
 
208
- /** The batch ulid shortened to head+tail, enough to name each batch's select-all checkbox distinctly for a screen reader. */
209
- function shortUlid(id: string): string {
210
- 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 !== "";
183
+ }
184
+
185
+ /** The section the current page belongs to, marking the matching nav link. */
186
+ type NavSection = "entries" | "style-guide";
187
+
188
+ /** The icon sprite, pasted into every full page's body, read off the vendored file so the two can never drift. */
189
+ const ICON_SPRITE = readFileSync(new URL("./static/icons.svg", import.meta.url), "utf8");
190
+
191
+ /** The theme toggle, once per shell: ui.js's initTheme wires it and owns the pressed state. */
192
+ function ThemeToggle() {
193
+ return (
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
+ );
211
199
  }
212
200
 
213
- /** The section the current page belongs to, marking the matching navbar link. */
214
- type NavSection = "entries" | "style-guide" | "none";
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
+ }
215
212
 
216
- function Navbar({ signedIn, active }: { signedIn: boolean; active: NavSection }) {
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
+ }) {
217
230
  return (
218
- <nav class="navbar">
219
- <div class="navbar-brand">
231
+ <>
232
+ <div class="mobile-topbar">
220
233
  <div class="logo-name">{NAME}</div>
221
- <div class="logo-version">v{VERSION}</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>
244
+ </div>
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>
222
293
  </div>
223
- <div class="navbar-nav" id="navbar-nav">
224
- <a class={`navbar-link${active === "entries" ? " active" : ""}`} href="/">Entries</a>
225
- <a class={`navbar-link${active === "style-guide" ? " active" : ""}`} href="/style-guide">Style guide</a>
226
- <div class="navbar-ink" id="navbar-ink"></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
+ ))}
227
360
  </div>
228
- {signedIn ? (
229
- <form method="post" action="/logout" class="navbar-actions">
230
- <button class="btn btn-ghost btn-sm" type="submit">Sign out</button>
231
- </form>
232
- ) : null}
233
- </nav>
361
+ </div>
234
362
  );
235
363
  }
236
364
 
237
- function PageHeader({ total }: { total: number }) {
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>
378
+ );
379
+ }
380
+
381
+ function PageHeader({ vm }: { vm: ListViewModel }) {
238
382
  return (
239
383
  <header class="page-header">
240
- <h1 class="page-title">Entries</h1>
241
- <span class="page-count">{total} {total === 1 ? "entry" : "entries"}</span>
384
+ <div class="page-header-body">
385
+ <h1 class="page-title">Entries</h1>
386
+ <PageMeta draftCount={vm.counts.draft} total={vm.total} projects={vm.projects.length} />
387
+ </div>
242
388
  </header>
243
389
  );
244
390
  }
245
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
+ */
246
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
+ ];
247
455
  return (
248
456
  <form
249
457
  id="filters"
@@ -253,89 +461,110 @@ function FilterForm({ vm }: { vm: ListViewModel }) {
253
461
  hx-target="#entries-list"
254
462
  hx-swap="innerHTML"
255
463
  hx-indicator="#entries-loading"
256
- class="filters"
257
464
  >
258
- <div class="field">
259
- <label class="field-label" for="q">Search</label>
260
- <input
261
- class="input"
262
- id="q"
263
- name="q"
264
- type="search"
265
- placeholder="Search file, anchor, or text"
266
- value={vm.q}
267
- />
268
- </div>
269
- <div class="field">
270
- <label class="field-label" for="status">Status</label>
271
- <select class="input" id="status" name="status">
272
- <option value="" selected={vm.status === ""}>Active</option>
273
- <option value="all" selected={vm.status === "all"}>All statuses</option>
274
- <option value="draft" selected={vm.status === "draft"}>Draft</option>
275
- <option value="approved" selected={vm.status === "approved"}>Approved</option>
276
- <option value="applied" selected={vm.status === "applied"}>Applied</option>
277
- <option value="rejected" selected={vm.status === "rejected"}>Rejected</option>
278
- </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} />
279
477
  </div>
280
- <div class="field">
281
- <label class="field-label" for="project">Project</label>
282
- <select class="input" id="project" name="project">
283
- <option value="">All projects</option>
284
- {vm.projects.map((project) => (
285
- <option value={project.slug} selected={vm.project === project.slug}>
286
- {project.slug}
287
- </option>
288
- ))}
289
- </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} />
290
513
  </div>
291
- <noscript>
292
- <button class="btn btn-primary" type="submit">Apply filters</button>
293
- </noscript>
294
514
  </form>
295
515
  );
296
516
  }
297
517
 
298
518
  /**
299
- * What an action round settles into, rendered above the list region: a single refusal (one entry
300
- * could not move, why, and the fix) or the batch report (how many moved, each refusal named).
301
- * 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.
302
522
  */
303
523
  export type ListNotice =
304
524
  | { readonly kind: "refusal"; readonly title: string; readonly message: string }
305
525
  | { readonly kind: "batch"; readonly approved: number; readonly total: number; readonly refused: readonly string[] };
306
526
 
307
- /** The settled rendering of a notice: a success panel when a batch fully moved, an alert otherwise. */
308
- 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 }) {
309
538
  if (notice.kind === "refusal") {
310
- 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
+ );
311
549
  }
312
550
  if (notice.refused.length === 0) {
313
551
  return (
314
- <div class="callout callout-success" role="status">
315
- <div class="callout-icon" style="color: var(--success)">
316
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
317
- <circle cx="8" cy="8" r="6.5" />
318
- <path d="M5 8l2 2 4-4" />
319
- </svg>
320
- </div>
321
- <div class="callout-content">
322
- <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">
323
556
  Approved {notice.approved} {notice.approved === 1 ? "entry" : "entries"}
324
557
  </div>
325
558
  </div>
559
+ <BannerDismiss />
326
560
  </div>
327
561
  );
328
562
  }
329
563
  return (
330
- <div class="callout callout-danger" role="alert">
331
- <div class="callout-icon" style="color: var(--danger)">
332
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
333
- <circle cx="8" cy="8" r="6.5" />
334
- <path d="M10 6L6 10M6 6l4 4" />
335
- </svg>
336
- </div>
337
- <div class="callout-content">
338
- <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">
339
568
  Approved {notice.approved} of {notice.total} entries
340
569
  </div>
341
570
  <ul class="notice-list">
@@ -344,88 +573,66 @@ function NoticeCallout({ notice }: { notice: ListNotice }) {
344
573
  ))}
345
574
  </ul>
346
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}
347
586
  </div>
348
587
  );
349
588
  }
350
589
 
351
- /** The settled list region: success, empty, or partial, with an action round's notice on top. The loading and error states are client-side. */
352
- 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 }) {
353
592
  if (vm.shown === 0) {
354
593
  // Zero entries at all is "no entries yet" even under a filter: nothing exists to filter, and
355
594
  // the empty state explains how entries get here. A store with entries but no matches is either
356
- // a filter miss or the default Active view over a store full of applied and rejected entries.
357
- return (
358
- <>
359
- {notice !== undefined ? <NoticeCallout notice={notice} /> : null}
360
- {vm.total === 0 ? (
361
- <EmptyAll />
362
- ) : vm.filtersActive ? (
363
- <EmptyMatch
364
- revealAllHref={
365
- vm.hiddenByActiveView
366
- ? `/?q=${encodeURIComponent(vm.q)}&project=${encodeURIComponent(vm.project)}&status=all`
367
- : undefined
368
- }
369
- />
370
- ) : (
371
- <EmptyActive />
372
- )}
373
- </>
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 />
374
608
  );
375
609
  }
376
- return (
377
- <>
378
- {notice !== undefined ? <NoticeCallout notice={notice} /> : null}
379
- {vm.filtersActive && vm.shown < vm.total ? <PartialHeader shown={vm.shown} total={vm.total} /> : null}
380
- <BatchForm vm={vm} />
381
- </>
382
- );
383
- }
384
-
385
- function PartialHeader({ shown, total }: { shown: number; total: number }) {
386
- return (
387
- <div class="partial-header">
388
- <span class="partial-count">
389
- Showing {shown} of {total} entries
390
- </span>
391
- <a href="/" class="btn btn-secondary btn-sm">Clear filters</a>
392
- </div>
393
- );
610
+ return <BatchForm vm={vm} />;
394
611
  }
395
612
 
396
613
  function EmptyAll() {
397
614
  return (
398
- <div class="card empty-state">
399
- <div class="empty-icon" aria-hidden="true">
400
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
401
- <rect x="1.5" y="2.5" width="13" height="11" />
402
- <path d="M1.5 9.5h3.5l1 2h4l1-2h3.5" />
403
- </svg>
404
- </div>
405
- <h3>No entries yet</h3>
406
- <p>Agents file text through the reviewzy mcp endpoint. Filed entries appear here.</p>
615
+ <div class="empty-state">
616
+ <svg aria-hidden="true"><use href="#i-file" /></svg>
617
+ <div class="empty-title">No entries yet</div>
618
+ <div>Agents file text through the reviewzy mcp endpoint. Filed entries appear here.</div>
407
619
  </div>
408
620
  );
409
621
  }
410
622
 
411
623
  /**
412
- * 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
413
625
  * matches (a search that only hits applied or rejected rows), the way onward is the all-statuses
414
- * 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
415
627
  * and still show nothing.
416
628
  */
417
629
  function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
418
630
  return (
419
- <div class="card empty-state">
420
- <div class="empty-icon" aria-hidden="true">
421
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
422
- <circle cx="6.5" cy="6.5" r="4.5" />
423
- <path d="M11 11l3 3" />
424
- </svg>
425
- </div>
426
- <h3>No entries match</h3>
427
- <p>Try different search terms, or clear the filters.</p>
428
- {revealAllHref !== undefined ? <p>Applied and rejected entries are hidden from this view.</p> : null}
631
+ <div class="empty-state">
632
+ <svg aria-hidden="true"><use href="#i-search" /></svg>
633
+ <div class="empty-title">No entries match</div>
634
+ <div>Try different search terms, or clear the filters.</div>
635
+ {revealAllHref !== undefined ? <div>Applied and rejected entries are hidden from this view.</div> : null}
429
636
  <div class="empty-actions">
430
637
  <a href="/" class="btn btn-secondary btn-sm">Clear filters</a>
431
638
  {revealAllHref !== undefined ? (
@@ -436,28 +643,24 @@ function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
436
643
  );
437
644
  }
438
645
 
439
- /** 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. */
440
647
  function EmptyActive() {
441
648
  return (
442
- <div class="card empty-state">
443
- <div class="empty-icon" aria-hidden="true">
444
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
445
- <circle cx="8" cy="8" r="6.5" />
446
- <path d="M5 8l2 2 4-4" />
447
- </svg>
448
- </div>
449
- <h3>Nothing to review</h3>
450
- <p>Every filed entry is applied or rejected. New drafts appear here as agents file them.</p>
649
+ <div class="empty-state">
650
+ <svg aria-hidden="true"><use href="#i-success" /></svg>
651
+ <div class="empty-title">Nothing to review</div>
652
+ <div>Every filed entry is applied or rejected. New drafts appear here as agents file them.</div>
451
653
  </div>
452
654
  );
453
655
  }
454
656
 
455
657
  /**
456
- * 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
457
659
  * inputs carry the active filters so an action round re-renders the region under the same filter
458
660
  * (htmx includes the closest form's inputs for the row buttons too), and the native action keeps
459
661
  * no-JS batch approve working. One form per page, never nested: the filters form lives outside the
460
- * 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.
461
664
  */
462
665
  function BatchForm({ vm }: { vm: ListViewModel }) {
463
666
  return (
@@ -473,234 +676,210 @@ function BatchForm({ vm }: { vm: ListViewModel }) {
473
676
  <input type="hidden" name="q" value={vm.q} />
474
677
  <input type="hidden" name="status" value={vm.status} />
475
678
  <input type="hidden" name="project" value={vm.project} />
476
- <div class="batch-bar">
477
- <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">
478
681
  Approve selected
479
682
  </button>
480
683
  </div>
481
684
  {vm.groups.map((group) => (
482
- <ProjectGroupView key={group.slug} group={group} />
685
+ <ProjectTable key={group.slug} group={group} />
483
686
  ))}
484
687
  </form>
485
688
  );
486
689
  }
487
690
 
488
- function ProjectGroupView({ group }: { group: ProjectGroup }) {
489
- const count = group.batches.reduce((n, batch) => n + batch.entries.length, 0);
490
- return (
491
- <section class="project-group">
492
- <header class="project-header">
493
- <h2 class="project-name">{group.slug}</h2>
494
- <span class="tag tag-data">
495
- {count} {count === 1 ? "entry" : "entries"}
496
- </span>
497
- {/* Every section and batch ships open; dashboard.js collapses the ones this viewer
498
- collapsed before (localStorage) and keeps the buttons inert without JS. */}
499
- <button
500
- type="button"
501
- class="btn btn-icon collapse-toggle"
502
- aria-expanded="true"
503
- aria-controls={`project-batches-${group.slug}`}
504
- aria-label={`Toggle ${group.slug} section`}
505
- data-collapse-toggle
506
- data-collapse-target={`project-batches-${group.slug}`}
507
- data-collapse-key={`project:${group.slug}`}
508
- >
509
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
510
- <path d="M3 6l5 5 5-5" />
511
- </svg>
512
- </button>
513
- </header>
514
- <div id={`project-batches-${group.slug}`} class="project-batches">
515
- {group.batches.map((batch) => (
516
- <BatchView key={batch.id} batch={batch} />
517
- ))}
518
- </div>
519
- </section>
520
- );
521
- }
522
-
523
- function BatchView({ batch }: { batch: ProjectBatch }) {
524
- // A batch names its filer in the header only when every entry agrees on one non-null
525
- // value; a disagreement leaves the header filer-less and each row names its own inline.
526
- const filedBy = batch.entries.map((entry) => entry.filed_by);
527
- const allAgree = filedBy.every((filer) => filer === filedBy[0]);
528
- const singleFiler = allAgree && filedBy[0] !== null ? filedBy[0] : null;
529
- 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";
530
701
  return (
531
- <div class="batch">
532
- <div class="batch-header">
533
- {/* Select-all toggles only this batch's draft rows. No `name`, so it never submits as
534
- an entry id; dashboard.js derives checked/indeterminate from the draft checkboxes. */}
535
- <input type="checkbox" class="select-all" aria-label={`Select all drafts in batch ${shortUlid(batch.id)}`} />
536
- {singleFiler !== null ? <span class="batch-meta">{singleFiler}</span> : null}
537
- <button
538
- type="button"
539
- class="btn btn-icon collapse-toggle"
540
- aria-expanded="true"
541
- aria-controls={`batch-table-${batch.id}`}
542
- aria-label={`Toggle batch ${shortUlid(batch.id)}`}
543
- data-collapse-toggle
544
- data-collapse-target={`batch-table-${batch.id}`}
545
- data-collapse-key={`batch:${batch.id}`}
546
- >
547
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
548
- <path d="M3 6l5 5 5-5" />
549
- </svg>
550
- </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>
551
708
  </div>
552
- <div class="table-wrap" id={`batch-table-${batch.id}`}>
709
+ <div class="table-wrap table-dense">
553
710
  <table>
554
711
  <colgroup>
555
712
  <col class="col-select" />
556
713
  <col />
557
714
  <col class="col-status" />
558
- <col class="col-file" />
715
+ <col class="col-filed" />
559
716
  <col class="col-actions" />
560
717
  </colgroup>
561
718
  <thead>
562
719
  <tr>
563
- {/* The column header stays in the accessibility tree via the sr-only span; the
564
- checkbox column itself is too narrow for a visible label. */}
565
- <th><span class="visually-hidden">Select</span></th>
566
- <th>Title</th>
567
- <th>Status</th>
568
- <th>File</th>
569
- <th>Actions</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>
730
+ <th scope="col">Status</th>
731
+ <th class="cell-filed" scope="col">Filed</th>
732
+ <th scope="col">Actions</th>
570
733
  </tr>
571
734
  </thead>
572
735
  <tbody>
573
- {batch.entries.map((entry) => (
574
- <EntryRowView key={entry.id} entry={entry} filerInline={mixedFilers} />
736
+ {group.entries.map((entry) => (
737
+ <EntryRowView key={entry.id} entry={entry} />
575
738
  ))}
576
739
  </tbody>
577
740
  </table>
578
741
  </div>
579
- </div>
742
+ </section>
580
743
  );
581
744
  }
582
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
+
583
752
  /**
584
- * One row. The checkbox names the entry for the batch form; it exists only on drafts, because an
585
- * approve on any other status always refuses and a checkbox that can only refuse is noise (races
586
- * are still reported per entry). The status chip sits beside the title; the three icon action slots
587
- * always render, inapplicable ones disabled, so a double-click's second click lands on the disabled
588
- * slot rather than a shifted action. Approve and reject target the list region and include the
589
- * batch form's hidden filters, so an action round re-renders the region under the same filter; the
590
- * editor link is always present. When the batch holds mixed filers, the row also names its own
591
- * 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.
592
761
  */
593
- function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: boolean }) {
762
+ function EntryRowView({ entry }: { entry: EntryRow }) {
594
763
  const title = entryTitle(entry);
764
+ const filed = filedStamp(entry.created_at);
595
765
  const approve = entry.status === "draft";
596
766
  const reject = entry.status === "draft" || entry.status === "approved";
597
767
  return (
598
768
  <tr>
599
769
  <td class="cell-select">
600
770
  {approve ? (
601
- <input type="checkbox" name="id" value={entry.id} aria-label={`Select ${entry.file}`} />
771
+ <label class="checkbox-label">
772
+ <input type="checkbox" name="id" value={entry.id} data-name={entry.id} aria-label={`Select ${entry.file}`} />
773
+ </label>
602
774
  ) : null}
603
775
  </td>
604
- <td class="cell-title" title={`${entry.file} — ${entry.anchor_text}`}>
776
+ <td>
605
777
  <div class="entry-head">
606
- <span class="entry-title">{title}</span>
607
- {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>
608
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}
609
784
  </td>
610
- <td class="cell-status">
611
- <span class={`tag ${STATUS_TAG[entry.status]}`}>{STATUS_LABEL[entry.status]}</span>
785
+ <td>
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>
612
793
  </td>
613
- <td class="cell-file" title={entry.file}>{entry.file}</td>
614
794
  <td class="cell-actions">
615
795
  {approve ? (
616
796
  <button
617
797
  type="button"
618
- class="btn btn-icon"
619
- style="color: var(--success)"
798
+ class="btn btn-icon action-approve"
620
799
  aria-label={`Approve ${entry.file}`}
621
- title="Approve"
622
800
  hx-post={`/entries/${entry.id}/approve`}
623
801
  hx-target="#entries-list"
624
802
  hx-swap="innerHTML"
625
803
  hx-indicator="#entries-loading"
626
804
  hx-disabled-elt="this"
627
805
  >
628
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
629
- <circle cx="8" cy="8" r="6.5" />
630
- <path d="M5 8l2 2 4-4" />
631
- </svg>
806
+ <svg aria-hidden="true"><use href="#i-check" /></svg>
632
807
  </button>
633
808
  ) : (
634
809
  // The approve slot stays occupied on every non-draft row: a double-click's second click
635
810
  // lands on the disabled button (pointer-events none) rather than the next slot.
636
- <button type="button" class="btn btn-icon" style="color: var(--success)" aria-label={`Approve ${entry.file}`} title="Approve" disabled>
637
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
638
- <circle cx="8" cy="8" r="6.5" />
639
- <path d="M5 8l2 2 4-4" />
640
- </svg>
811
+ <button type="button" class="btn btn-icon action-approve" aria-label={`Approve ${entry.file}`} disabled>
812
+ <svg aria-hidden="true"><use href="#i-check" /></svg>
641
813
  </button>
642
814
  )}
643
815
  {reject ? (
644
816
  <button
645
817
  type="button"
646
- class="btn btn-icon"
647
- style="color: var(--danger)"
818
+ class="btn btn-icon action-reject"
648
819
  aria-label={`Reject ${entry.file}`}
649
- title="Reject"
650
820
  hx-post={`/entries/${entry.id}/reject`}
651
821
  hx-target="#entries-list"
652
822
  hx-swap="innerHTML"
653
823
  hx-indicator="#entries-loading"
654
824
  hx-disabled-elt="this"
655
825
  >
656
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
657
- <circle cx="8" cy="8" r="6.5" />
658
- <path d="M10 6L6 10M6 6l4 4" />
659
- </svg>
826
+ <svg aria-hidden="true"><use href="#i-x" /></svg>
660
827
  </button>
661
828
  ) : (
662
829
  // The reject slot stays occupied on applied and rejected rows for the same guard.
663
- <button type="button" class="btn btn-icon" style="color: var(--danger)" aria-label={`Reject ${entry.file}`} title="Reject" disabled>
664
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
665
- <circle cx="8" cy="8" r="6.5" />
666
- <path d="M10 6L6 10M6 6l4 4" />
667
- </svg>
830
+ <button type="button" class="btn btn-icon action-reject" aria-label={`Reject ${entry.file}`} disabled>
831
+ <svg aria-hidden="true"><use href="#i-x" /></svg>
668
832
  </button>
669
833
  )}
670
- <a class="btn btn-icon" href={`/entries/${entry.id}`} aria-label={`Edit ${entry.file}`} title="Edit">
671
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
672
- <path d="M11.5 2.5l2 2L5 13H3v-2z" />
673
- </svg>
834
+ <a class="btn btn-icon" href={`/entries/${entry.id}`} aria-label={`Edit ${entry.file}`}>
835
+ <svg aria-hidden="true"><use href="#i-edit" /></svg>
674
836
  </a>
675
837
  </td>
676
838
  </tr>
677
839
  );
678
840
  }
679
841
 
680
- /** The error box htmx failures clone into the list region; the retry button re-submits the form. */
681
- function ErrorBoxTemplate() {
842
+ /** The saved theme, restored before the stylesheet, or a light-theme visitor sees a dark flash. */
843
+ const THEME_RESTORE = raw(
844
+ 'const saved = localStorage.getItem("cloudy-theme"); if (saved) document.documentElement.dataset.theme = saved;',
845
+ );
846
+
847
+ /** The head shared by every full page: the three stylesheets in order, then htmx and the dashboard module. */
848
+ function PageHead({ title }: { title: string }) {
682
849
  return (
683
- <template id="error-box">
684
- <div class="callout callout-danger" role="alert">
685
- <div class="callout-icon" style="color: var(--danger)">
686
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
687
- <circle cx="8" cy="8" r="6.5" />
688
- <path d="M10 6L6 10M6 6l4 4" />
689
- </svg>
690
- </div>
691
- <div class="callout-content">
692
- <div class="callout-title">The list could not be loaded</div>
693
- <div class="callout-body">The filter request failed. Try it again.</div>
694
- <div class="callout-actions">
695
- <button class="btn btn-secondary btn-sm" type="button" data-retry>Retry</button>
696
- </div>
697
- </div>
698
- </div>
699
- </template>
850
+ <head>
851
+ <meta charset="utf-8" />
852
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
853
+ {/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
854
+ <link rel="icon" href="data:," />
855
+ <title>{title}</title>
856
+ <script>{THEME_RESTORE}</script>
857
+ <link rel="stylesheet" href="/static/tokens.css" />
858
+ <link rel="stylesheet" href="/static/components.css" />
859
+ <link rel="stylesheet" href="/static/cursor.css" />
860
+ <link rel="stylesheet" href="/static/dashboard.css" />
861
+ <script src="/static/htmx.min.js"></script>
862
+ <script type="module" src="/static/dashboard.js"></script>
863
+ </head>
864
+ );
865
+ }
866
+
867
+ /** The sprite in the body; cursor.js creates its own canvas and nothing else may enter that slot. */
868
+ function IconSprite() {
869
+ return <>{raw(ICON_SPRITE)}</>;
870
+ }
871
+
872
+ /** The two controller modules, at the end of the body, exactly as the wiring prescribes. */
873
+ function PageModules() {
874
+ return (
875
+ <>
876
+ <script type="module" src="/static/cursor.js"></script>
877
+ <script type="module" src="/static/ui.js"></script>
878
+ </>
700
879
  );
701
880
  }
702
881
 
703
- /** 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. */
704
883
  export function dashboardPage(
705
884
  store: Store,
706
885
  params: ListParams,
@@ -710,23 +889,22 @@ export function dashboardPage(
710
889
  const vm = loadList(store, params);
711
890
  return (
712
891
  <html lang="en" data-theme="dark">
713
- <head>
714
- <meta charset="utf-8" />
715
- <meta name="viewport" content="width=device-width, initial-scale=1" />
716
- {/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
717
- <link rel="icon" href="data:," />
718
- <title>Entries · {NAME}</title>
719
- <link rel="stylesheet" href="/static/tokens.css" />
720
- <link rel="stylesheet" href="/static/components.css" />
721
- <link rel="stylesheet" href="/static/dashboard.css" />
722
- <script src="/static/htmx.min.js"></script>
723
- <script src="/static/dashboard.js" defer></script>
724
- </head>
892
+ <PageHead title={`Entries · ${NAME}`} />
725
893
  <body>
726
- <div class="app-shell">
727
- <Navbar signedIn={signedIn} active="entries" />
728
- <main class="main">
729
- <PageHeader total={vm.total} />
894
+ <a class="skip-link" href="#main">Skip to content</a>
895
+ <IconSprite />
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>
730
908
  <FilterForm vm={vm} />
731
909
  <div id="entries">
732
910
  <div id="entries-loading" class="htmx-indicator" role="status">
@@ -734,51 +912,45 @@ export function dashboardPage(
734
912
  Loading entries
735
913
  </div>
736
914
  <div id="entries-list">
737
- <ListRegion vm={vm} notice={notice} />
915
+ <ListRegion vm={vm} />
738
916
  </div>
739
917
  </div>
740
- <ErrorBoxTemplate />
741
- </main>
742
- </div>
918
+ </section>
919
+ </Shell>
920
+ <div class="toast-stack" id="toast-stack"></div>
921
+ <PageModules />
743
922
  </body>
744
923
  </html>
745
924
  );
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 (
756
935
  <html lang="en" data-theme="dark">
757
- <head>
758
- <meta charset="utf-8" />
759
- <meta name="viewport" content="width=device-width, initial-scale=1" />
760
- {/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
761
- <link rel="icon" href="data:," />
762
- <title>Sign in · {NAME}</title>
763
- <link rel="stylesheet" href="/static/tokens.css" />
764
- <link rel="stylesheet" href="/static/components.css" />
765
- <link rel="stylesheet" href="/static/dashboard.css" />
766
- <script src="/static/htmx.min.js"></script>
767
- <script src="/static/dashboard.js" defer></script>
768
- </head>
936
+ <PageHead title={`Sign in · ${NAME}`} />
769
937
  <body>
770
- <div class="app-shell">
771
- <Navbar signedIn={false} active="none" />
772
- <main class="main login-main">
773
- <header class="page-header">
774
- <div class="label page-eyebrow">Dashboard</div>
775
- <h1 class="page-title">Sign in</h1>
776
- <p class="page-lede">The dashboard is password protected. Enter the password to continue.</p>
777
- </header>
778
- {error !== undefined ? <DangerCallout title={error} /> : null}
779
- <form method="post" action="/login" class="card login-card">
938
+ <IconSprite />
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">
780
945
  {next !== undefined ? <input type="hidden" name="next" value={next} /> : null}
781
- <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}
782
954
  <div class="field">
783
955
  <label class="field-label" for="password">Password</label>
784
956
  <input
@@ -790,31 +962,50 @@ export function loginPage(next: string | undefined, error: string | undefined =
790
962
  />
791
963
  </div>
792
964
  <div class="login-actions">
793
- <button class="btn btn-primary" type="submit">Sign in</button>
965
+ <button class="btn btn-fill" type="submit">Sign in</button>
794
966
  </div>
795
967
  </div>
796
968
  </form>
797
- </main>
798
- </div>
969
+ </div>
970
+ </main>
971
+ <PageModules />
799
972
  </body>
800
973
  </html>
801
974
  );
802
975
  }
803
976
 
804
- /** 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
+ */
805
982
  export function entriesFragment(store: Store, params: ListParams, notice: ListNotice | undefined = undefined): JSX.Element {
806
- 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
+ );
807
994
  }
808
995
 
809
996
  // ---- Entry editor ----
810
997
 
811
998
  export type EditorViewModel = {
812
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;
813
1002
  readonly revisions: readonly RevisionRow[];
814
1003
  readonly constraints: Constraints;
815
1004
  /** The entry immediately before/after in the list's ascending-id walk, or null at the boundary. */
816
1005
  readonly prevId: string | null;
817
1006
  readonly nextId: string | null;
1007
+ /** The sidebar's queue badge, read once with the editor's own query. */
1008
+ readonly draftCount: number;
818
1009
  };
819
1010
 
820
1011
  /**
@@ -838,7 +1029,16 @@ export function loadEditor(store: Store, id: string): EditorViewModel | null {
838
1029
  const entry = getEntry(store, id);
839
1030
  if (entry === undefined) return null;
840
1031
  const { prevId, nextId } = adjacentEntryIds(store, id);
841
- 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
+ };
842
1042
  }
843
1043
 
844
1044
  /** The tolerant parse for `entries.context`, mirroring `parseConstraints`: foreign rows may hold malformed JSON, and unparseable means no context rows, never a 500. */
@@ -857,12 +1057,17 @@ function parseContext(json: string): Record<string, unknown> {
857
1057
  * The user-facing refusal messages, in the dashboard's own voice: name what failed, then the fix.
858
1058
  * The store's refusal carries the values; only this function turns them into prose.
859
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
+
860
1065
  export function formatSaveRefusal(refusal: Exclude<HumanSaveRefusal, { kind: "unknown" }>): string {
861
1066
  switch (refusal.kind) {
862
1067
  case "max_len":
863
1068
  return `This text is ${refusal.length} characters; the length limit is ${refusal.maxLen}. Shorten it to ${refusal.maxLen} characters or fewer.`;
864
1069
  case "placeholder":
865
- 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.`;
866
1071
  case "empty":
867
1072
  return "Nothing to save. Enter text, or reject the entry.";
868
1073
  case "status":
@@ -902,11 +1107,8 @@ export function formatTransitionRefusal(
902
1107
  function DangerCallout({ title, body }: { title: string; body?: string }) {
903
1108
  return (
904
1109
  <div class="callout callout-danger" role="alert">
905
- <div class="callout-icon" style="color: var(--danger)">
906
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
907
- <circle cx="8" cy="8" r="6.5" />
908
- <path d="M10 6L6 10M6 6l4 4" />
909
- </svg>
1110
+ <div class="callout-icon">
1111
+ <svg aria-hidden="true"><use href="#i-error" /></svg>
910
1112
  </div>
911
1113
  <div class="callout-content">
912
1114
  <div class="callout-title">{title}</div>
@@ -916,248 +1118,486 @@ function DangerCallout({ title, body }: { title: string; body?: string }) {
916
1118
  );
917
1119
  }
918
1120
 
1121
+ function InfoCallout({ title, body }: { title: string; body?: string }) {
1122
+ return (
1123
+ <div class="callout callout-info">
1124
+ <div class="callout-icon">
1125
+ <svg aria-hidden="true"><use href="#i-info" /></svg>
1126
+ </div>
1127
+ <div class="callout-content">
1128
+ <div class="callout-title">{title}</div>
1129
+ {body !== undefined ? <div class="callout-body">{body}</div> : null}
1130
+ </div>
1131
+ </div>
1132
+ );
1133
+ }
1134
+
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 }) {
1137
+ return (
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>
1144
+ );
1145
+ }
1146
+
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
+ }
1160
+ return (
1161
+ <a class="btn btn-secondary" href={`/entries/${targetId}`} aria-label={label}>
1162
+ {icon}
1163
+ </a>
1164
+ );
1165
+ }
1166
+
919
1167
  /**
920
- * The editor region, swapped wholesale by a save or a transition (form + status tag + history +
921
- * any refusal). The textarea carries `hx-preserve`, so a swap keeps the user's focus and typing
922
- * while the server-rendered value stays what the last save left: the submitted text on a refusal.
923
- * The `view` marker tells the approve and reject routes which region to answer with: this region,
924
- * or the list's. The buttons carry htmx only; the save button keeps the native submit.
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.
925
1170
  */
926
- function EditorRegion({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
927
- const text = state?.submittedText ?? vm.entry.human_text ?? vm.entry.agent_draft ?? "";
928
- const approve = vm.entry.status === "draft";
929
- const reject = vm.entry.status === "draft" || vm.entry.status === "approved";
1171
+ function EditorHeader({ vm }: { vm: EditorViewModel }) {
1172
+ const entry = vm.entry;
1173
+ const filed = filedStamp(entry.created_at);
930
1174
  return (
931
- <div id="editor-region">
932
- {state?.refusal !== undefined ? (
933
- <DangerCallout title={state.refusal.title} body={state.refusal.body} />
934
- ) : null}
935
- <form
936
- id="editor-form"
937
- class="card"
938
- method="post"
939
- action={`/entries/${vm.entry.id}/save`}
940
- hx-post={`/entries/${vm.entry.id}/save`}
941
- hx-target="#editor-view"
942
- hx-swap="outerHTML"
943
- >
944
- <input type="hidden" name="view" value="editor" />
945
- <div class="card-header">
946
- <div class="card-title editor-title">{entryTitle(vm.entry)}</div>
947
- <span class={`tag ${STATUS_TAG[vm.entry.status]}`}>
948
- {STATUS_LABEL[vm.entry.status]}
949
- </span>
950
- </div>
951
- <div class="card-content">
952
- <textarea id="editor-text" class="input" name="text" rows={10} aria-label="Entry text" hx-preserve="true">
953
- {text}
954
- </textarea>
955
- <div class="editor-actions">
956
- <button class="btn btn-primary" type="submit">Save text</button>
957
- {approve ? (
958
- <button
959
- type="button"
960
- class="btn btn-secondary"
961
- hx-post={`/entries/${vm.entry.id}/approve`}
962
- hx-target="#editor-region"
963
- hx-swap="outerHTML"
964
- hx-indicator="#editor-action-loading"
965
- hx-disabled-elt="this"
966
- >
967
- Approve draft
968
- </button>
969
- ) : vm.entry.status === "approved" ? (
970
- // Same slot-occupancy guard as the list row: the second click of a double-click on
971
- // the just-approving button would otherwise land on the Reject button that shifts
972
- // into this slot in the swapped region.
973
- <button type="button" class="btn btn-secondary" disabled>
974
- Approve draft
975
- </button>
976
- ) : null}
977
- {reject ? (
978
- <button
979
- type="button"
980
- class="btn btn-danger"
981
- hx-post={`/entries/${vm.entry.id}/reject`}
982
- hx-target="#editor-region"
983
- hx-swap="outerHTML"
984
- hx-indicator="#editor-action-loading"
985
- hx-disabled-elt="this"
986
- >
987
- Reject
988
- </button>
989
- ) : null}
990
- <span id="editor-action-loading" class="htmx-indicator" role="status">
991
- <span class="spinner" aria-hidden="true"></span>
992
- Updating entry
993
- </span>
994
- </div>
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>
995
1188
  </div>
996
- </form>
997
- <RevisionHistory revisions={vm.revisions} />
998
- </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>
999
1195
  );
1000
1196
  }
1001
1197
 
1002
- function RevisionHistory({ revisions }: { revisions: readonly RevisionRow[] }) {
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);
1003
1229
  return (
1004
- <div class="card">
1230
+ <div class="card current-text-card" id="current-text">
1005
1231
  <div class="card-header">
1006
- <div class="card-title">Revision history</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>
1007
1236
  </div>
1008
1237
  <div class="card-content">
1009
- {revisions.length === 0 ? (
1010
- <p class="history-empty">No saved revisions yet</p>
1011
- ) : (
1012
- <ul class="revision-list">
1013
- {revisions.map((revision) => (
1014
- <li class="revision" key={revision.id}>
1015
- <div class="revision-head">
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>
1343
+ </div>
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">
1016
1351
  <span class={`tag tag-data ${STATUS_TAG[revision.status]}`}>{STATUS_LABEL[revision.status]}</span>
1017
- <time class="revision-time" datetime={new Date(revision.created_at).toISOString()}>
1352
+ <time class="timeline-time" datetime={new Date(revision.created_at).toISOString()}>
1018
1353
  {new Date(revision.created_at).toLocaleString()}
1019
1354
  </time>
1020
- <button class="btn btn-ghost btn-sm" type="button" data-use-revision title="Use this text">
1021
- Use
1022
- </button>
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}
1023
1361
  </div>
1024
- <div class="revision-text">{revision.human_text}</div>
1025
- </li>
1026
- ))}
1027
- </ul>
1028
- )}
1029
- </div>
1030
- </div>
1362
+ <div class="timeline-body clamp-2">{revision.human_text}</div>
1363
+ </div>
1364
+ </li>
1365
+ ))}
1366
+ </ol>
1367
+ )}
1368
+ </SupportSection>
1031
1369
  );
1032
1370
  }
1033
1371
 
1034
- /** 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. */
1035
- function ConstraintsCard({ constraints, text }: { constraints: Constraints; text: string }) {
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 }) {
1036
1377
  const maxLen = constraints.maxLen;
1037
1378
  const placeholders = constraints.placeholders;
1038
1379
  const tone = constraints.tone;
1039
1380
  const hasAny = maxLen !== undefined || placeholders.length > 0 || tone !== undefined;
1040
1381
  if (!hasAny) return null;
1041
1382
  return (
1042
- <div class="card">
1043
- <div class="card-header">
1044
- <div class="card-title">Constraints</div>
1045
- </div>
1046
- <div class="card-content">
1047
- {maxLen !== undefined ? (
1048
- <div class="constraint-row">
1049
- <div class="constraint-label">Length</div>
1050
- <div class="constraint-value">
1051
- <span id="char-count" class="num char-count" data-max-len={maxLen}>
1052
- {text.length} / {maxLen}
1053
- </span>
1054
- </div>
1055
- </div>
1056
- ) : null}
1057
- {placeholders.length > 0 ? (
1058
- <div class="constraint-row">
1059
- <div class="constraint-label">Placeholders</div>
1060
- <ul class="placeholder-list">
1061
- {placeholders.map((placeholder) => {
1062
- const present = text.includes(placeholder);
1063
- return (
1064
- <li class="placeholder-item" data-placeholder={placeholder} key={placeholder}>
1065
- <span class={`placeholder-state${present ? " is-present" : ""}`}>
1066
- {present ? "Present" : "Missing"}
1067
- </span>
1068
- <span class="placeholder-token">{"{" + placeholder + "}"}</span>
1069
- </li>
1070
- );
1071
- })}
1072
- </ul>
1073
- </div>
1074
- ) : null}
1075
- {tone !== undefined ? (
1076
- <div class="constraint-row">
1077
- <div class="constraint-label">Tone</div>
1078
- <div class="constraint-value">{tone}</div>
1079
- </div>
1080
- ) : null}
1081
- </div>
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}
1082
1405
  </div>
1083
1406
  );
1084
1407
  }
1085
1408
 
1086
1409
  /**
1087
- * The notes card: the agent's notes (from `constraints.notes`, read-only) above the human's own
1088
- * notes as an editable textarea with its own save. The textarea carries `hx-preserve`, so any swap
1089
- * (a text save, an image add) keeps the human's in-progress note; the save targets the whole
1090
- * editor view like the save form, so the saved value re-renders in place. The card always renders:
1091
- * the human's notes need a home even before the agent sent any.
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.
1092
1416
  */
1093
- function NotesCard({ vm }: { vm: EditorViewModel }) {
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";
1094
1422
  return (
1095
- <div class="card">
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" />
1096
1434
  <div class="card-header">
1097
- <div class="card-title">Notes</div>
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>
1098
1443
  </div>
1099
1444
  <div class="card-content">
1100
- <div class="constraint-row">
1101
- <div class="constraint-label">Agent's notes</div>
1102
- <div class="constraint-value">{vm.constraints.notes ?? "None."}</div>
1103
- </div>
1104
- <form
1105
- id="notes-form"
1106
- method="post"
1107
- action={`/entries/${vm.entry.id}/notes`}
1108
- hx-post={`/entries/${vm.entry.id}/notes`}
1109
- hx-target="#editor-view"
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"
1110
1455
  hx-swap="outerHTML"
1456
+ hx-sync="this:replace"
1111
1457
  >
1112
- <div class="field notes-field">
1113
- <label class="field-label" for="editor-notes">Your notes</label>
1114
- <textarea
1115
- id="editor-notes"
1116
- class="input"
1117
- name="notes"
1118
- rows={4}
1119
- aria-label="Your notes"
1120
- 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"
1121
1474
  >
1122
- {vm.entry.human_notes ?? ""}
1123
- </textarea>
1124
- </div>
1125
- <div class="notes-actions">
1126
- <button class="btn btn-secondary btn-sm" type="submit">Save notes</button>
1127
- </div>
1128
- </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>
1129
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>
1499
+ </div>
1500
+ </div>
1501
+ );
1502
+ }
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} />}
1130
1514
  </div>
1131
1515
  );
1132
1516
  }
1133
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
+
1134
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. */
1135
1564
  function isRenderableImage(item: string): boolean {
1136
1565
  return item.startsWith("data:image/") || /^https?:\/\//.test(item);
1137
1566
  }
1138
1567
 
1139
1568
  /**
1140
- * The images attached to an entry, one card in the rail: each valid item as a real `img` (sized by
1141
- * css), an invalid one as a "not shown" note, a remove form per image, and the one-file upload
1142
- * form. A card of its own, not a strip in the context card: upload and remove need a stable home,
1143
- * and the context card stays lean for what the human authors against. Both forms target the whole
1144
- * 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.
1145
1573
  */
1146
- function ImagesCard({ entry }: { entry: EntryRow }) {
1574
+ function ImagesSection({ entry }: { entry: EntryRow }) {
1147
1575
  const images = parseEntryImages(entry.images);
1148
1576
  return (
1149
- <div class="card">
1150
- <div class="card-header">
1151
- <div class="card-title">Images</div>
1152
- </div>
1153
- <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>
1154
1594
  {images.length === 0 ? (
1155
1595
  <p class="field-hint">No images yet.</p>
1156
1596
  ) : (
1157
- <ul class="image-list">
1597
+ <ul class="dropzone-list image-list">
1158
1598
  {images.map((item, index) =>
1159
1599
  isRenderableImage(item) ? (
1160
- <li class="image-item" key={index}>
1600
+ <li key={index}>
1161
1601
  <img class="entry-image" src={item} alt={`Image ${index + 1}`} loading="lazy" />
1162
1602
  <form
1163
1603
  class="image-remove"
@@ -1167,11 +1607,11 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
1167
1607
  hx-target="#editor-view"
1168
1608
  hx-swap="outerHTML"
1169
1609
  >
1170
- <button class="btn btn-ghost btn-sm" type="submit">Remove</button>
1610
+ <button class="btn btn-secondary btn-sm" type="submit">Remove</button>
1171
1611
  </form>
1172
1612
  </li>
1173
1613
  ) : (
1174
- <li class="image-item" key={index}>
1614
+ <li key={index}>
1175
1615
  <p class="image-skip">Image {index + 1} not shown: unsupported source.</p>
1176
1616
  </li>
1177
1617
  ),
@@ -1179,6 +1619,7 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
1179
1619
  </ul>
1180
1620
  )}
1181
1621
  <form
1622
+ id="image-upload"
1182
1623
  class="image-upload"
1183
1624
  method="post"
1184
1625
  action={`/entries/${entry.id}/images`}
@@ -1188,276 +1629,139 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
1188
1629
  hx-target="#editor-view"
1189
1630
  hx-swap="outerHTML"
1190
1631
  >
1191
- <input class="input" type="file" name="file" accept="image/*" aria-label="Image file" />
1192
1632
  <button class="btn btn-secondary btn-sm" type="submit" hx-disabled-elt="this">Add image</button>
1193
1633
  </form>
1194
1634
  </div>
1195
- </div>
1635
+ </SupportSection>
1196
1636
  );
1197
1637
  }
1198
1638
 
1199
1639
  /**
1200
1640
  * The repo row's value: a recognized remote renders as its brand icon beside the bare owner/repo
1201
- * 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.
1202
1643
  */
1203
1644
  function RepoValue({ repo }: { repo: string }) {
1204
1645
  const link = parseRepoLink(repo);
1205
1646
  if (link === null) return <>{repo}</>;
1206
1647
  return (
1207
- <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">
1208
1649
  <svg class="repo-icon" viewBox="0 0 24 24" aria-hidden="true">
1209
1650
  <path d={REPO_ICON[link.provider]} />
1210
1651
  </svg>
1211
1652
  <span>{link.path}</span>
1653
+ <span class="sr-only">{repo}</span>
1212
1654
  </a>
1213
1655
  );
1214
1656
  }
1215
1657
 
1216
1658
  /**
1217
- * The entry's context JSON plus its anchor metadata, as an info-table readout. The anchor and the
1218
- * agent-supplied context rows lead — they are what the human authors against — then repo and file;
1219
- * the provenance rows (hash, filer, stale note) are secondary and sit collapsed behind a summary.
1220
- */
1221
- function ContextCard({ entry }: { entry: EntryRow }) {
1222
- const context = parseContext(entry.context);
1223
- return (
1224
- <div class="card">
1225
- <div class="card-header">
1226
- <div class="card-title">Context</div>
1227
- </div>
1228
- <div class="card-content">
1229
- <table class="info-table">
1230
- <tr>
1231
- <td>Anchor</td>
1232
- <td>{entry.anchor_text}</td>
1233
- </tr>
1234
- {Object.entries(context).map(([key, value]) => (
1235
- <tr key={key}>
1236
- <td>{key}</td>
1237
- <td>{typeof value === "string" ? value : JSON.stringify(value)}</td>
1238
- </tr>
1239
- ))}
1240
- <tr>
1241
- <td>Repo</td>
1242
- <td><RepoValue repo={entry.repo} /></td>
1243
- </tr>
1244
- <tr>
1245
- <td>File</td>
1246
- <td>{entry.file}</td>
1247
- </tr>
1248
- </table>
1249
- <details class="context-secondary">
1250
- <summary>More</summary>
1251
- <table class="info-table">
1252
- <tr>
1253
- <td>File hash</td>
1254
- <td>{entry.file_hash}</td>
1255
- </tr>
1256
- {entry.filed_by !== null ? (
1257
- <tr>
1258
- <td>Filed by</td>
1259
- <td>{entry.filed_by}</td>
1260
- </tr>
1261
- ) : null}
1262
- {entry.stale_note !== null ? (
1263
- <tr>
1264
- <td>Stale note</td>
1265
- <td>{entry.stale_note}</td>
1266
- </tr>
1267
- ) : null}
1268
- </table>
1269
- </details>
1270
- </div>
1271
- </div>
1272
- );
1273
- }
1274
-
1275
- /**
1276
- * The before-and-after of the entry's text, above the editor: what the anchored region reads now
1277
- * (the anchor visually distinct) and what the entry's text will replace it with. Both sides
1278
- * escape through JSX; the text is the stored bytes, shown as-is. The word-level marks come from
1279
- * the server-side walk: words only the anchor has render red in the before card, words only the proposal
1280
- * has render green in the after card, everything shared stays plain, so an identical pair renders
1281
- * no marks at all.
1282
- */
1283
- function DiffView({ entry }: { entry: EntryRow }) {
1284
- const after = entry.human_text ?? entry.agent_draft;
1285
- // No text at all means nothing to compare: a filed entry whose agent never drafted, or an
1286
- // approved entry whose text was never set.
1287
- if (after === null) return null;
1288
- const diff = diffWords(entry.anchor_text, after);
1289
- return (
1290
- <div class="diff-grid">
1291
- <div class="card">
1292
- <div class="card-header">
1293
- <div class="card-title">Before</div>
1294
- </div>
1295
- <pre class="diff-code">
1296
- <span class="diff-line">{entry.anchor_before}</span>
1297
- <span class="diff-line diff-anchor">
1298
- {diff.before.map((token, index) =>
1299
- token.kind === "removed" ? (
1300
- <span class="diff-removed" key={index}>{token.text}</span>
1301
- ) : (
1302
- token.text
1303
- ),
1304
- )}
1305
- </span>
1306
- <span class="diff-line">{entry.anchor_after}</span>
1307
- </pre>
1308
- </div>
1309
- <div class="card">
1310
- <div class="card-header">
1311
- <div class="card-title">After</div>
1312
- </div>
1313
- <pre class="diff-code">
1314
- {diff.after.map((token, index) =>
1315
- token.kind === "added" ? (
1316
- <span class="diff-added" key={index}>{token.text}</span>
1317
- ) : (
1318
- token.text
1319
- ),
1320
- )}
1321
- </pre>
1322
- </div>
1323
- </div>
1324
- );
1325
- }
1326
-
1327
- /**
1328
- * The editor view: the diff above the editor grid, one swap node. A save changes the diff's
1329
- * after-side, so the save form targets this wrapper and its response re-renders the whole view;
1330
- * 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.
1331
1664
  */
1332
1665
  function EditorView({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
1333
- 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;
1334
1670
  return (
1335
1671
  <div id="editor-view">
1336
- <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>
1337
1686
  <div class="editor-grid">
1338
- <div class="editor-main">
1339
- <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>
1340
1698
  </div>
1341
- <aside class="editor-rail">
1342
- <ContextCard entry={vm.entry} />
1343
- <ConstraintsCard constraints={vm.constraints} text={text} />
1344
- <NotesCard vm={vm} />
1345
- <ImagesCard entry={vm.entry} />
1346
- </aside>
1347
1699
  </div>
1348
1700
  </div>
1349
1701
  );
1350
1702
  }
1351
1703
 
1352
- /** The editor's full-height prev/next rails: one fixed strip per side, a link when a neighbour exists. */
1353
- function EditorNav({ prevId, nextId }: { prevId: string | null; nextId: string | null }) {
1354
- return (
1355
- <nav class="editor-nav" aria-label="Entry navigation">
1356
- <EditorNavButton side="prev" targetId={prevId} />
1357
- <EditorNavButton side="next" targetId={nextId} />
1358
- </nav>
1359
- );
1360
- }
1361
-
1362
- /** One rail: a chevron on a full-height strip, or an inert placeholder at the boundary. */
1363
- function EditorNavButton({ side, targetId }: { side: "prev" | "next"; targetId: string | null }) {
1364
- const label = side === "prev" ? "Previous entry" : "Next entry";
1365
- const chevron = side === "prev" ? "M10 3L5 8l5 5" : "M6 3l5 5-5 5";
1366
- const cls = `editor-nav-btn editor-nav-${side}`;
1367
- const icon = (
1368
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
1369
- <path d={chevron} />
1370
- </svg>
1371
- );
1372
- if (targetId === null) {
1373
- return (
1374
- <span class={`${cls} is-disabled`} aria-hidden="true">
1375
- {icon}
1376
- </span>
1377
- );
1378
- }
1379
- return (
1380
- <a class={cls} href={`/entries/${targetId}`} aria-label={label} title={label}>
1381
- {icon}
1382
- </a>
1383
- );
1384
- }
1385
-
1386
- /** 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. */
1387
1705
  export function editorPage(vm: EditorViewModel, state: EditorState | undefined = undefined, signedIn = false): JSX.Element {
1388
1706
  return (
1389
1707
  <html lang="en" data-theme="dark">
1390
- <head>
1391
- <meta charset="utf-8" />
1392
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1393
- {/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
1394
- <link rel="icon" href="data:," />
1395
- <title>Edit entry · {NAME}</title>
1396
- <link rel="stylesheet" href="/static/tokens.css" />
1397
- <link rel="stylesheet" href="/static/components.css" />
1398
- <link rel="stylesheet" href="/static/dashboard.css" />
1399
- <script src="/static/htmx.min.js"></script>
1400
- <script src="/static/dashboard.js" defer></script>
1401
- </head>
1708
+ <PageHead title={`Edit entry · ${NAME}`} />
1402
1709
  <body>
1403
- <div class="app-shell">
1404
- <Navbar signedIn={signedIn} active="entries" />
1405
- <main class="main">
1406
- <header class="page-header">
1407
- <div class="label page-eyebrow">Entry review</div>
1408
- <h1 class="page-title">Edit entry</h1>
1409
- <p class="page-lede">Saving the text approves the entry and releases it to agents.</p>
1410
- </header>
1710
+ <a class="skip-link" href="#main">Skip to content</a>
1711
+ <IconSprite />
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} />
1411
1717
  <EditorView vm={vm} state={state} />
1412
- <EditorNav prevId={vm.prevId} nextId={vm.nextId} />
1413
1718
  {/* The one live region: announcements on constraint-state flips only, so a screen reader
1414
1719
  is not flooded with a per-keystroke value. Lives outside the swap region. */}
1415
- <span id="editor-live" class="visually-hidden" aria-live="polite"></span>
1416
- </main>
1417
- </div>
1720
+ <span id="editor-live" class="sr-only" aria-live="polite"></span>
1721
+ </div>
1722
+ </Shell>
1723
+ <div class="toast-stack" id="toast-stack"></div>
1724
+ <PageModules />
1418
1725
  </body>
1419
1726
  </html>
1420
1727
  );
1421
1728
  }
1422
1729
 
1423
- /** The editor region only, for htmx transition responses; the page shell, diff, context, and constraints stay put. */
1424
- export function editorFragment(vm: EditorViewModel, state: EditorState | undefined = undefined): JSX.Element {
1425
- return <EditorRegion vm={vm} state={state} />;
1426
- }
1427
-
1428
- /** 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. */
1429
1731
  export function editorViewFragment(vm: EditorViewModel, state: EditorState | undefined = undefined): JSX.Element {
1430
- 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
+ );
1431
1739
  }
1432
1740
 
1433
- /** 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. */
1434
- 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";
1435
1744
  return (
1436
1745
  <html lang="en" data-theme="dark">
1437
- <head>
1438
- <meta charset="utf-8" />
1439
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1440
- {/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
1441
- <link rel="icon" href="data:," />
1442
- <title>Entry not found · {NAME}</title>
1443
- <link rel="stylesheet" href="/static/tokens.css" />
1444
- <link rel="stylesheet" href="/static/components.css" />
1445
- <link rel="stylesheet" href="/static/dashboard.css" />
1446
- <script src="/static/htmx.min.js"></script>
1447
- <script src="/static/dashboard.js" defer></script>
1448
- </head>
1746
+ <PageHead title={`${entry ? "Entry" : "Page"} not found · ${NAME}`} />
1449
1747
  <body>
1450
- <div class="app-shell">
1451
- <Navbar signedIn={signedIn} active="entries" />
1452
- <main class="main">
1453
- <header class="page-header">
1454
- <div class="label page-eyebrow">Entry review</div>
1455
- <h1 class="page-title">Entry not found</h1>
1456
- <p class="page-lede">This entry doesn't exist. Return to the list to find it.</p>
1457
- <a href="/" class="btn btn-secondary">Back to entries</a>
1458
- </header>
1459
- </main>
1460
- </div>
1748
+ <IconSprite />
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>
1760
+ </div>
1761
+ </div>
1762
+ </div>
1763
+ </main>
1764
+ <PageModules />
1461
1765
  </body>
1462
1766
  </html>
1463
1767
  );
@@ -1466,7 +1770,7 @@ export function notFoundPage(signedIn = false): JSX.Element {
1466
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. */
1467
1771
  export function entryGoneFragment(): JSX.Element {
1468
1772
  return (
1469
- <div id="editor-region">
1773
+ <div id="editor-region" tabindex={-1}>
1470
1774
  <DangerCallout title="This entry no longer exists" body="Return to the list to find it." />
1471
1775
  <a href="/" class="btn btn-secondary">Back to entries</a>
1472
1776
  </div>
@@ -1491,6 +1795,8 @@ export type StyleGuideViewModel = {
1491
1795
  readonly hasRow: boolean;
1492
1796
  /** The stored section, or the empty defaults when there is no row yet. */
1493
1797
  readonly section: StyleGuideInput;
1798
+ /** The sidebar's queue badge, read with the page's own store access. */
1799
+ readonly draftCount: number;
1494
1800
  };
1495
1801
 
1496
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. */
@@ -1514,6 +1820,7 @@ export function loadStyleGuide(store: Store, projectQuery: string | undefined):
1514
1820
  unknown,
1515
1821
  hasRow: section !== null,
1516
1822
  section: section ?? { markdown: "", bannedWords: [], glossary: {} },
1823
+ draftCount: countEntriesByStatus(store).draft,
1517
1824
  };
1518
1825
  }
1519
1826
 
@@ -1540,48 +1847,29 @@ function sectionToForm(section: StyleGuideInput): StyleGuideForm {
1540
1847
  };
1541
1848
  }
1542
1849
 
1543
- function InfoCallout({ title, body }: { title: string; body?: string }) {
1544
- return (
1545
- <div class="callout callout-info">
1546
- <div class="callout-icon" style="color: var(--info)">
1547
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
1548
- <circle cx="8" cy="8" r="6.5" />
1549
- <path d="M8 7.5v3.5M8 5.5v.01" />
1550
- </svg>
1551
- </div>
1552
- <div class="callout-content">
1553
- <div class="callout-title">{title}</div>
1554
- {body !== undefined ? <div class="callout-body">{body}</div> : null}
1555
- </div>
1556
- </div>
1557
- );
1558
- }
1559
-
1560
- /** 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. */
1561
1851
  function StyleGuideProjects({ vm }: { vm: StyleGuideViewModel }) {
1562
1852
  const activeSlug = vm.unknown ? null : vm.projectSlug;
1563
1853
  return (
1564
- <nav class="style-guide-projects" aria-label="Style guide sections">
1565
- <ProjectSectionLink href="/style-guide" label="Global" active={activeSlug === null} />
1566
- {vm.projects.map((project) => (
1567
- <ProjectSectionLink
1568
- key={project.slug}
1569
- href={`/style-guide?project=${project.slug}`}
1570
- label={project.slug}
1571
- active={activeSlug === project.slug}
1572
- />
1573
- ))}
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>
1574
1866
  </nav>
1575
1867
  );
1576
1868
  }
1577
1869
 
1578
1870
  function ProjectSectionLink({ href, label, active }: { href: string; label: string; active: boolean }) {
1579
1871
  return (
1580
- <a
1581
- class={`btn btn-sm ${active ? "btn-primary" : "btn-secondary"}`}
1582
- href={href}
1583
- aria-current={active ? "page" : undefined}
1584
- >
1872
+ <a class={`tab${active ? " active" : ""}`} href={href} aria-current={active ? "page" : undefined}>
1585
1873
  {label}
1586
1874
  </a>
1587
1875
  );
@@ -1607,11 +1895,8 @@ function StyleGuideRegion({ vm, state }: { vm: StyleGuideViewModel; state: Style
1607
1895
  <div id="style-guide-region">
1608
1896
  {state?.notice.kind === "saved" ? (
1609
1897
  <div class="callout callout-success" role="status">
1610
- <div class="callout-icon" style="color: var(--success)">
1611
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
1612
- <circle cx="8" cy="8" r="6.5" />
1613
- <path d="M5 8l2 2 4-4" />
1614
- </svg>
1898
+ <div class="callout-icon">
1899
+ <svg aria-hidden="true"><use href="#i-success" /></svg>
1615
1900
  </div>
1616
1901
  <div class="callout-content">
1617
1902
  <div class="callout-title">Guide saved</div>
@@ -1630,81 +1915,59 @@ function StyleGuideRegion({ vm, state }: { vm: StyleGuideViewModel; state: Style
1630
1915
  body="Your save creates it."
1631
1916
  />
1632
1917
  ) : null}
1633
- <form
1634
- id="style-guide-form"
1635
- class="card style-guide-card"
1636
- method="post"
1637
- action="/style-guide/save"
1638
- hx-post="/style-guide/save"
1639
- hx-target="#style-guide-region"
1640
- hx-swap="outerHTML"
1641
- hx-indicator="#style-guide-loading"
1642
- >
1643
- <input type="hidden" name="project" value={vm.projectSlug ?? ""} />
1644
- <div class="card-header">
1645
- <div class="card-title">{vm.projectSlug === null ? "Global guide" : `${vm.projectSlug} guide`}</div>
1646
- </div>
1647
- <div class="card-content">
1648
- <div class="field">
1649
- <label class="field-label" for="style-guide-markdown">Markdown</label>
1650
- <textarea class="input" id="style-guide-markdown" name="markdown" rows={10} aria-label="Guide markdown">
1651
- {formValues.markdown}
1652
- </textarea>
1653
- {vm.hasRow && vm.section.markdown === "" ? <p class="field-hint">No markdown yet.</p> : null}
1654
- </div>
1655
- <div class="field">
1656
- <label class="field-label" for="style-guide-banned">Banned words</label>
1657
- <input class="input" id="style-guide-banned" name="banned_words" type="text" value={formValues.bannedWordsCsv} />
1658
- <p class="field-hint">Comma-separated. Extra whitespace and duplicates are dropped.</p>
1659
- {vm.hasRow && vm.section.bannedWords.length === 0 ? <p class="field-hint">No banned words yet.</p> : null}
1660
- </div>
1661
- <div class="field">
1662
- <label class="field-label" for="style-guide-glossary">Glossary</label>
1663
- <textarea class="input" id="style-guide-glossary" name="glossary" rows={6} aria-label="Glossary entries">
1664
- {formValues.glossaryText}
1665
- </textarea>
1666
- <p class="field-hint">One key: value pair per line. A line without a colon is refused.</p>
1667
- {vm.hasRow && Object.keys(vm.section.glossary).length === 0 ? (
1668
- <p class="field-hint">No glossary entries yet.</p>
1669
- ) : null}
1670
- </div>
1671
- <div class="editor-actions">
1672
- <button class="btn btn-primary" type="submit" hx-disabled-elt="this">Save guide</button>
1673
- <span id="style-guide-loading" class="htmx-indicator" role="status">
1674
- <span class="spinner" aria-hidden="true"></span>
1675
- Saving guide
1676
- </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>
1677
1931
  </div>
1678
- </div>
1679
- </form>
1680
- </div>
1681
- );
1682
- }
1683
-
1684
- /** The error box htmx failures clone into the style guide region; the retry button re-submits the save form. */
1685
- function StyleGuideErrorBoxTemplate() {
1686
- return (
1687
- <template id="style-guide-error-box">
1688
- <div class="callout callout-danger" role="alert">
1689
- <div class="callout-icon" style="color: var(--danger)">
1690
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
1691
- <circle cx="8" cy="8" r="6.5" />
1692
- <path d="M10 6L6 10M6 6l4 4" />
1693
- </svg>
1694
- </div>
1695
- <div class="callout-content">
1696
- <div class="callout-title">The guide could not be saved</div>
1697
- <div class="callout-body">The save request failed. Try it again.</div>
1698
- <div class="callout-actions">
1699
- <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>
1700
1963
  </div>
1701
- </div>
1964
+ </form>
1702
1965
  </div>
1703
- </template>
1966
+ </div>
1704
1967
  );
1705
1968
  }
1706
1969
 
1707
- /** 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. */
1708
1971
  export function styleGuidePage(
1709
1972
  vm: StyleGuideViewModel,
1710
1973
  state: StyleGuideEditorState | undefined = undefined,
@@ -1712,38 +1975,29 @@ export function styleGuidePage(
1712
1975
  ): JSX.Element {
1713
1976
  return (
1714
1977
  <html lang="en" data-theme="dark">
1715
- <head>
1716
- <meta charset="utf-8" />
1717
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1718
- {/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
1719
- <link rel="icon" href="data:," />
1720
- <title>Style guide · {NAME}</title>
1721
- <link rel="stylesheet" href="/static/tokens.css" />
1722
- <link rel="stylesheet" href="/static/components.css" />
1723
- <link rel="stylesheet" href="/static/dashboard.css" />
1724
- <script src="/static/htmx.min.js"></script>
1725
- <script src="/static/dashboard.js" defer></script>
1726
- </head>
1978
+ <PageHead title={`Style guide · ${NAME}`} />
1727
1979
  <body>
1728
- <div class="app-shell">
1729
- <Navbar signedIn={signedIn} active="style-guide" />
1730
- <main class="main">
1731
- <header class="page-header">
1732
- <div class="label page-eyebrow">Voice</div>
1980
+ <a class="skip-link" href="#main">Skip to content</a>
1981
+ <IconSprite />
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>
1733
1986
  <h1 class="page-title">Style guide</h1>
1734
- <p class="page-lede">The voice agents read before drafting, starting with the global guide and adding each project's own rules below.</p>
1735
- </header>
1736
- <StyleGuideProjects vm={vm} />
1737
- <StyleGuideRegion vm={vm} state={state} />
1738
- <StyleGuideErrorBoxTemplate />
1739
- </main>
1740
- </div>
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>
1994
+ <PageModules />
1741
1995
  </body>
1742
1996
  </html>
1743
1997
  );
1744
1998
  }
1745
1999
 
1746
- /** 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. */
1747
2001
  export function styleGuideRegionFragment(vm: StyleGuideViewModel, state: StyleGuideEditorState | undefined = undefined): JSX.Element {
1748
2002
  return <StyleGuideRegion vm={vm} state={state} />;
1749
2003
  }