reviewzy 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,6 @@
1
1
  /** @jsxImportSource hono/jsx */
2
+ import { readFileSync } from "node:fs";
3
+ import { raw } from "hono/html";
2
4
  import type { JSX } from "hono/jsx/jsx-runtime";
3
5
  import { listRevisions, parseConstraints, parseEntryImages } from "../db/human-save.ts";
4
6
  import type { Constraints, HumanSaveRefusal, RevisionRow, TransitionRefusal } from "../db/human-save.ts";
@@ -15,7 +17,7 @@ import type { StyleGuideForm, StyleGuideFormRefusal, StyleGuideInput } from "../
15
17
  import type { Store } from "../db/store.ts";
16
18
  import { parseRepoLink, REPO_ICON } from "./repo-link.ts";
17
19
  import { diffWords } from "./word-diff.ts";
18
- import { NAME, VERSION } from "../version.ts";
20
+ import { NAME } from "../version.ts";
19
21
 
20
22
  /**
21
23
  * The three dashboard filters, straight off the query string. `status` and `project` are the raw
@@ -213,23 +215,26 @@ function shortUlid(id: string): string {
213
215
  /** The section the current page belongs to, marking the matching navbar link. */
214
216
  type NavSection = "entries" | "style-guide" | "none";
215
217
 
218
+ /** The icon sprite, pasted into every full page's body as its first child, read off the vendored file so the two can never drift. */
219
+ const ICON_SPRITE = readFileSync(new URL("./static/icons.svg", import.meta.url), "utf8");
220
+
216
221
  function Navbar({ signedIn, active }: { signedIn: boolean; active: NavSection }) {
217
222
  return (
218
223
  <nav class="navbar">
219
- <div class="navbar-brand">
220
- <div class="logo-name">{NAME}</div>
221
- <div class="logo-version">v{VERSION}</div>
222
- </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>
224
+ <div class="navbar-inner">
225
+ <div class="navbar-brand">
226
+ <div class="logo-name">{NAME}</div>
227
+ </div>
228
+ <div class="navbar-nav" id="navbar-nav">
229
+ <a class={`navbar-link${active === "entries" ? " active" : ""}`} href="/">Entries</a>
230
+ <a class={`navbar-link${active === "style-guide" ? " active" : ""}`} href="/style-guide">Style guide</a>
231
+ </div>
232
+ {signedIn ? (
233
+ <form method="post" action="/logout" class="navbar-actions">
234
+ <button class="btn btn-secondary btn-sm" type="submit">Sign out</button>
235
+ </form>
236
+ ) : null}
227
237
  </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
238
  </nav>
234
239
  );
235
240
  }
@@ -237,8 +242,12 @@ function Navbar({ signedIn, active }: { signedIn: boolean; active: NavSection })
237
242
  function PageHeader({ total }: { total: number }) {
238
243
  return (
239
244
  <header class="page-header">
240
- <h1 class="page-title">Entries</h1>
241
- <span class="page-count">{total} {total === 1 ? "entry" : "entries"}</span>
245
+ <div class="page-header-body">
246
+ <h1 class="page-title">Entries</h1>
247
+ <div class="page-meta">
248
+ <span class="page-meta-item">{total} {total === 1 ? "entry" : "entries"}</span>
249
+ </div>
250
+ </div>
242
251
  </header>
243
252
  );
244
253
  }
@@ -312,11 +321,8 @@ function NoticeCallout({ notice }: { notice: ListNotice }) {
312
321
  if (notice.refused.length === 0) {
313
322
  return (
314
323
  <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>
324
+ <div class="callout-icon">
325
+ <svg aria-hidden="true"><use href="#i-success" /></svg>
320
326
  </div>
321
327
  <div class="callout-content">
322
328
  <div class="callout-title">
@@ -328,11 +334,8 @@ function NoticeCallout({ notice }: { notice: ListNotice }) {
328
334
  }
329
335
  return (
330
336
  <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>
337
+ <div class="callout-icon">
338
+ <svg aria-hidden="true"><use href="#i-error" /></svg>
336
339
  </div>
337
340
  <div class="callout-content">
338
341
  <div class="callout-title">
@@ -396,14 +399,9 @@ function PartialHeader({ shown, total }: { shown: number; total: number }) {
396
399
  function EmptyAll() {
397
400
  return (
398
401
  <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>
402
+ <svg aria-hidden="true"><use href="#i-file" /></svg>
403
+ <div class="empty-title">No entries yet</div>
404
+ <div>Agents file text through the reviewzy mcp endpoint. Filed entries appear here.</div>
407
405
  </div>
408
406
  );
409
407
  }
@@ -417,15 +415,10 @@ function EmptyAll() {
417
415
  function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
418
416
  return (
419
417
  <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}
418
+ <svg aria-hidden="true"><use href="#i-search" /></svg>
419
+ <div class="empty-title">No entries match</div>
420
+ <div>Try different search terms, or clear the filters.</div>
421
+ {revealAllHref !== undefined ? <div>Applied and rejected entries are hidden from this view.</div> : null}
429
422
  <div class="empty-actions">
430
423
  <a href="/" class="btn btn-secondary btn-sm">Clear filters</a>
431
424
  {revealAllHref !== undefined ? (
@@ -440,14 +433,9 @@ function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
440
433
  function EmptyActive() {
441
434
  return (
442
435
  <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>
436
+ <svg aria-hidden="true"><use href="#i-success" /></svg>
437
+ <div class="empty-title">Nothing to review</div>
438
+ <div>Every filed entry is applied or rejected. New drafts appear here as agents file them.</div>
451
439
  </div>
452
440
  );
453
441
  }
@@ -506,12 +494,10 @@ function ProjectGroupView({ group }: { group: ProjectGroup }) {
506
494
  data-collapse-target={`project-batches-${group.slug}`}
507
495
  data-collapse-key={`project:${group.slug}`}
508
496
  >
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>
497
+ <svg aria-hidden="true"><use href="#i-chevron-down" /></svg>
512
498
  </button>
513
499
  </header>
514
- <div id={`project-batches-${group.slug}`} class="project-batches">
500
+ <div id={`project-batches-${group.slug}`}>
515
501
  {group.batches.map((batch) => (
516
502
  <BatchView key={batch.id} batch={batch} />
517
503
  ))}
@@ -532,7 +518,9 @@ function BatchView({ batch }: { batch: ProjectBatch }) {
532
518
  <div class="batch-header">
533
519
  {/* Select-all toggles only this batch's draft rows. No `name`, so it never submits as
534
520
  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)}`} />
521
+ <label class="checkbox-label">
522
+ <input type="checkbox" class="select-all" aria-label={`Select all drafts in batch ${shortUlid(batch.id)}`} />
523
+ </label>
536
524
  {singleFiler !== null ? <span class="batch-meta">{singleFiler}</span> : null}
537
525
  <button
538
526
  type="button"
@@ -544,9 +532,7 @@ function BatchView({ batch }: { batch: ProjectBatch }) {
544
532
  data-collapse-target={`batch-table-${batch.id}`}
545
533
  data-collapse-key={`batch:${batch.id}`}
546
534
  >
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>
535
+ <svg aria-hidden="true"><use href="#i-chevron-down" /></svg>
550
536
  </button>
551
537
  </div>
552
538
  <div class="table-wrap" id={`batch-table-${batch.id}`}>
@@ -562,11 +548,11 @@ function BatchView({ batch }: { batch: ProjectBatch }) {
562
548
  <tr>
563
549
  {/* The column header stays in the accessibility tree via the sr-only span; the
564
550
  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>
551
+ <th class="cell-select" scope="col"><span class="sr-only">Select</span></th>
552
+ <th scope="col">Title</th>
553
+ <th scope="col">Status</th>
554
+ <th scope="col">File</th>
555
+ <th scope="col">Actions</th>
570
556
  </tr>
571
557
  </thead>
572
558
  <tbody>
@@ -598,16 +584,18 @@ function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: bo
598
584
  <tr>
599
585
  <td class="cell-select">
600
586
  {approve ? (
601
- <input type="checkbox" name="id" value={entry.id} aria-label={`Select ${entry.file}`} />
587
+ <label class="checkbox-label">
588
+ <input type="checkbox" name="id" value={entry.id} aria-label={`Select ${entry.file}`} />
589
+ </label>
602
590
  ) : null}
603
591
  </td>
604
- <td class="cell-title" title={`${entry.file} — ${entry.anchor_text}`}>
592
+ <td title={`${entry.file} — ${entry.anchor_text}`}>
605
593
  <div class="entry-head">
606
594
  <span class="entry-title">{title}</span>
607
595
  {filerInline && entry.filed_by !== null ? <span class="entry-filer"> · {entry.filed_by}</span> : null}
608
596
  </div>
609
597
  </td>
610
- <td class="cell-status">
598
+ <td>
611
599
  <span class={`tag ${STATUS_TAG[entry.status]}`}>{STATUS_LABEL[entry.status]}</span>
612
600
  </td>
613
601
  <td class="cell-file" title={entry.file}>{entry.file}</td>
@@ -618,26 +606,19 @@ function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: bo
618
606
  class="btn btn-icon"
619
607
  style="color: var(--success)"
620
608
  aria-label={`Approve ${entry.file}`}
621
- title="Approve"
622
609
  hx-post={`/entries/${entry.id}/approve`}
623
610
  hx-target="#entries-list"
624
611
  hx-swap="innerHTML"
625
612
  hx-indicator="#entries-loading"
626
613
  hx-disabled-elt="this"
627
614
  >
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>
615
+ <svg aria-hidden="true"><use href="#i-check" /></svg>
632
616
  </button>
633
617
  ) : (
634
618
  // The approve slot stays occupied on every non-draft row: a double-click's second click
635
619
  // 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>
620
+ <button type="button" class="btn btn-icon" style="color: var(--success)" aria-label={`Approve ${entry.file}`} disabled>
621
+ <svg aria-hidden="true"><use href="#i-check" /></svg>
641
622
  </button>
642
623
  )}
643
624
  {reject ? (
@@ -646,31 +627,22 @@ function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: bo
646
627
  class="btn btn-icon"
647
628
  style="color: var(--danger)"
648
629
  aria-label={`Reject ${entry.file}`}
649
- title="Reject"
650
630
  hx-post={`/entries/${entry.id}/reject`}
651
631
  hx-target="#entries-list"
652
632
  hx-swap="innerHTML"
653
633
  hx-indicator="#entries-loading"
654
634
  hx-disabled-elt="this"
655
635
  >
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>
636
+ <svg aria-hidden="true"><use href="#i-x" /></svg>
660
637
  </button>
661
638
  ) : (
662
639
  // 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>
640
+ <button type="button" class="btn btn-icon" style="color: var(--danger)" aria-label={`Reject ${entry.file}`} disabled>
641
+ <svg aria-hidden="true"><use href="#i-x" /></svg>
668
642
  </button>
669
643
  )}
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>
644
+ <a class="btn btn-icon" href={`/entries/${entry.id}`} aria-label={`Edit ${entry.file}`}>
645
+ <svg aria-hidden="true"><use href="#i-edit" /></svg>
674
646
  </a>
675
647
  </td>
676
648
  </tr>
@@ -682,11 +654,8 @@ function ErrorBoxTemplate() {
682
654
  return (
683
655
  <template id="error-box">
684
656
  <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>
657
+ <div class="callout-icon">
658
+ <svg aria-hidden="true"><use href="#i-error" /></svg>
690
659
  </div>
691
660
  <div class="callout-content">
692
661
  <div class="callout-title">The list could not be loaded</div>
@@ -700,6 +669,46 @@ function ErrorBoxTemplate() {
700
669
  );
701
670
  }
702
671
 
672
+ /** The saved theme, restored before the stylesheet, or a light-theme visitor sees a dark flash. */
673
+ const THEME_RESTORE = raw(
674
+ 'const saved = localStorage.getItem("cloudy-theme"); if (saved) document.documentElement.dataset.theme = saved;',
675
+ );
676
+
677
+ /** The head shared by every full page: the three stylesheets in order, then htmx and the dashboard module. */
678
+ function PageHead({ title }: { title: string }) {
679
+ return (
680
+ <head>
681
+ <meta charset="utf-8" />
682
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
683
+ {/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
684
+ <link rel="icon" href="data:," />
685
+ <title>{title}</title>
686
+ <script>{THEME_RESTORE}</script>
687
+ <link rel="stylesheet" href="/static/tokens.css" />
688
+ <link rel="stylesheet" href="/static/components.css" />
689
+ <link rel="stylesheet" href="/static/cursor.css" />
690
+ <link rel="stylesheet" href="/static/dashboard.css" />
691
+ <script src="/static/htmx.min.js"></script>
692
+ <script type="module" src="/static/dashboard.js"></script>
693
+ </head>
694
+ );
695
+ }
696
+
697
+ /** The sprite as the body's first child; cursor.js creates its own canvas and nothing else may enter that slot. */
698
+ function IconSprite() {
699
+ return <>{raw(ICON_SPRITE)}</>;
700
+ }
701
+
702
+ /** The two controller modules, at the end of the body, exactly as the wiring prescribes. */
703
+ function PageModules() {
704
+ return (
705
+ <>
706
+ <script type="module" src="/static/cursor.js"></script>
707
+ <script type="module" src="/static/ui.js"></script>
708
+ </>
709
+ );
710
+ }
711
+
703
712
  /** The full page for a plain navigation; the mount wraps it in the doctype. `signedIn` is whether the navbar offers sign-out. */
704
713
  export function dashboardPage(
705
714
  store: Store,
@@ -710,19 +719,9 @@ export function dashboardPage(
710
719
  const vm = loadList(store, params);
711
720
  return (
712
721
  <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>
722
+ <PageHead title={`Entries · ${NAME}`} />
725
723
  <body>
724
+ <IconSprite />
726
725
  <div class="app-shell">
727
726
  <Navbar signedIn={signedIn} active="entries" />
728
727
  <main class="main">
@@ -740,6 +739,7 @@ export function dashboardPage(
740
739
  <ErrorBoxTemplate />
741
740
  </main>
742
741
  </div>
742
+ <PageModules />
743
743
  </body>
744
744
  </html>
745
745
  );
@@ -754,26 +754,18 @@ export function dashboardPage(
754
754
  export function loginPage(next: string | undefined, error: string | undefined = undefined): JSX.Element {
755
755
  return (
756
756
  <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>
757
+ <PageHead title={`Sign in · ${NAME}`} />
769
758
  <body>
759
+ <IconSprite />
770
760
  <div class="app-shell">
771
761
  <Navbar signedIn={false} active="none" />
772
762
  <main class="main login-main">
773
763
  <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>
764
+ <div class="page-header-body">
765
+ <div class="label page-eyebrow">Dashboard</div>
766
+ <h1 class="page-title">Sign in</h1>
767
+ <p class="page-lede">The dashboard is password protected. Enter the password to continue.</p>
768
+ </div>
777
769
  </header>
778
770
  {error !== undefined ? <DangerCallout title={error} /> : null}
779
771
  <form method="post" action="/login" class="card login-card">
@@ -796,6 +788,7 @@ export function loginPage(next: string | undefined, error: string | undefined =
796
788
  </form>
797
789
  </main>
798
790
  </div>
791
+ <PageModules />
799
792
  </body>
800
793
  </html>
801
794
  );
@@ -902,11 +895,8 @@ export function formatTransitionRefusal(
902
895
  function DangerCallout({ title, body }: { title: string; body?: string }) {
903
896
  return (
904
897
  <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>
898
+ <div class="callout-icon">
899
+ <svg aria-hidden="true"><use href="#i-error" /></svg>
910
900
  </div>
911
901
  <div class="callout-content">
912
902
  <div class="callout-title">{title}</div>
@@ -1017,7 +1007,7 @@ function RevisionHistory({ revisions }: { revisions: readonly RevisionRow[] }) {
1017
1007
  <time class="revision-time" datetime={new Date(revision.created_at).toISOString()}>
1018
1008
  {new Date(revision.created_at).toLocaleString()}
1019
1009
  </time>
1020
- <button class="btn btn-ghost btn-sm" type="button" data-use-revision title="Use this text">
1010
+ <button class="btn btn-secondary btn-sm" type="button" data-use-revision>
1021
1011
  Use
1022
1012
  </button>
1023
1013
  </div>
@@ -1157,7 +1147,7 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
1157
1147
  <ul class="image-list">
1158
1148
  {images.map((item, index) =>
1159
1149
  isRenderableImage(item) ? (
1160
- <li class="image-item" key={index}>
1150
+ <li key={index}>
1161
1151
  <img class="entry-image" src={item} alt={`Image ${index + 1}`} loading="lazy" />
1162
1152
  <form
1163
1153
  class="image-remove"
@@ -1167,11 +1157,11 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
1167
1157
  hx-target="#editor-view"
1168
1158
  hx-swap="outerHTML"
1169
1159
  >
1170
- <button class="btn btn-ghost btn-sm" type="submit">Remove</button>
1160
+ <button class="btn btn-secondary btn-sm" type="submit">Remove</button>
1171
1161
  </form>
1172
1162
  </li>
1173
1163
  ) : (
1174
- <li class="image-item" key={index}>
1164
+ <li key={index}>
1175
1165
  <p class="image-skip">Image {index + 1} not shown: unsupported source.</p>
1176
1166
  </li>
1177
1167
  ),
@@ -1297,7 +1287,7 @@ function DiffView({ entry }: { entry: EntryRow }) {
1297
1287
  <span class="diff-line diff-anchor">
1298
1288
  {diff.before.map((token, index) =>
1299
1289
  token.kind === "removed" ? (
1300
- <span class="diff-removed" key={index}>{token.text}</span>
1290
+ <span class="diff-remove" key={index}>{token.text}</span>
1301
1291
  ) : (
1302
1292
  token.text
1303
1293
  ),
@@ -1313,7 +1303,7 @@ function DiffView({ entry }: { entry: EntryRow }) {
1313
1303
  <pre class="diff-code">
1314
1304
  {diff.after.map((token, index) =>
1315
1305
  token.kind === "added" ? (
1316
- <span class="diff-added" key={index}>{token.text}</span>
1306
+ <span class="diff-add" key={index}>{token.text}</span>
1317
1307
  ) : (
1318
1308
  token.text
1319
1309
  ),
@@ -1362,12 +1352,9 @@ function EditorNav({ prevId, nextId }: { prevId: string | null; nextId: string |
1362
1352
  /** One rail: a chevron on a full-height strip, or an inert placeholder at the boundary. */
1363
1353
  function EditorNavButton({ side, targetId }: { side: "prev" | "next"; targetId: string | null }) {
1364
1354
  const label = side === "prev" ? "Previous entry" : "Next entry";
1365
- const chevron = side === "prev" ? "M10 3L5 8l5 5" : "M6 3l5 5-5 5";
1366
1355
  const cls = `editor-nav-btn editor-nav-${side}`;
1367
1356
  const icon = (
1368
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
1369
- <path d={chevron} />
1370
- </svg>
1357
+ <svg aria-hidden="true"><use href={side === "prev" ? "#i-chevron-left" : "#i-chevron-right"} /></svg>
1371
1358
  );
1372
1359
  if (targetId === null) {
1373
1360
  return (
@@ -1377,7 +1364,7 @@ function EditorNavButton({ side, targetId }: { side: "prev" | "next"; targetId:
1377
1364
  );
1378
1365
  }
1379
1366
  return (
1380
- <a class={cls} href={`/entries/${targetId}`} aria-label={label} title={label}>
1367
+ <a class={cls} href={`/entries/${targetId}`} aria-label={label}>
1381
1368
  {icon}
1382
1369
  </a>
1383
1370
  );
@@ -1387,34 +1374,27 @@ function EditorNavButton({ side, targetId }: { side: "prev" | "next"; targetId:
1387
1374
  export function editorPage(vm: EditorViewModel, state: EditorState | undefined = undefined, signedIn = false): JSX.Element {
1388
1375
  return (
1389
1376
  <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>
1377
+ <PageHead title={`Edit entry · ${NAME}`} />
1402
1378
  <body>
1379
+ <IconSprite />
1403
1380
  <div class="app-shell">
1404
1381
  <Navbar signedIn={signedIn} active="entries" />
1405
1382
  <main class="main">
1406
1383
  <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>
1384
+ <div class="page-header-body">
1385
+ <div class="label page-eyebrow">Entry review</div>
1386
+ <h1 class="page-title">Edit entry</h1>
1387
+ <p class="page-lede">Saving the text approves the entry and releases it to agents.</p>
1388
+ </div>
1410
1389
  </header>
1411
1390
  <EditorView vm={vm} state={state} />
1412
1391
  <EditorNav prevId={vm.prevId} nextId={vm.nextId} />
1413
1392
  {/* The one live region: announcements on constraint-state flips only, so a screen reader
1414
1393
  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>
1394
+ <span id="editor-live" class="sr-only" aria-live="polite"></span>
1416
1395
  </main>
1417
1396
  </div>
1397
+ <PageModules />
1418
1398
  </body>
1419
1399
  </html>
1420
1400
  );
@@ -1434,30 +1414,23 @@ export function editorViewFragment(vm: EditorViewModel, state: EditorState | und
1434
1414
  export function notFoundPage(signedIn = false): JSX.Element {
1435
1415
  return (
1436
1416
  <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>
1417
+ <PageHead title={`Entry not found · ${NAME}`} />
1449
1418
  <body>
1419
+ <IconSprite />
1450
1420
  <div class="app-shell">
1451
1421
  <Navbar signedIn={signedIn} active="entries" />
1452
1422
  <main class="main">
1453
1423
  <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>
1424
+ <div class="page-header-body">
1425
+ <div class="label page-eyebrow">Entry review</div>
1426
+ <h1 class="page-title">Entry not found</h1>
1427
+ <p class="page-lede">This entry doesn't exist. Return to the list to find it.</p>
1428
+ </div>
1458
1429
  </header>
1430
+ <a href="/" class="btn btn-secondary">Back to entries</a>
1459
1431
  </main>
1460
1432
  </div>
1433
+ <PageModules />
1461
1434
  </body>
1462
1435
  </html>
1463
1436
  );
@@ -1543,11 +1516,8 @@ function sectionToForm(section: StyleGuideInput): StyleGuideForm {
1543
1516
  function InfoCallout({ title, body }: { title: string; body?: string }) {
1544
1517
  return (
1545
1518
  <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>
1519
+ <div class="callout-icon">
1520
+ <svg aria-hidden="true"><use href="#i-info" /></svg>
1551
1521
  </div>
1552
1522
  <div class="callout-content">
1553
1523
  <div class="callout-title">{title}</div>
@@ -1607,11 +1577,8 @@ function StyleGuideRegion({ vm, state }: { vm: StyleGuideViewModel; state: Style
1607
1577
  <div id="style-guide-region">
1608
1578
  {state?.notice.kind === "saved" ? (
1609
1579
  <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>
1580
+ <div class="callout-icon">
1581
+ <svg aria-hidden="true"><use href="#i-success" /></svg>
1615
1582
  </div>
1616
1583
  <div class="callout-content">
1617
1584
  <div class="callout-title">Guide saved</div>
@@ -1686,11 +1653,8 @@ function StyleGuideErrorBoxTemplate() {
1686
1653
  return (
1687
1654
  <template id="style-guide-error-box">
1688
1655
  <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>
1656
+ <div class="callout-icon">
1657
+ <svg aria-hidden="true"><use href="#i-error" /></svg>
1694
1658
  </div>
1695
1659
  <div class="callout-content">
1696
1660
  <div class="callout-title">The guide could not be saved</div>
@@ -1712,32 +1676,25 @@ export function styleGuidePage(
1712
1676
  ): JSX.Element {
1713
1677
  return (
1714
1678
  <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>
1679
+ <PageHead title={`Style guide · ${NAME}`} />
1727
1680
  <body>
1681
+ <IconSprite />
1728
1682
  <div class="app-shell">
1729
1683
  <Navbar signedIn={signedIn} active="style-guide" />
1730
1684
  <main class="main">
1731
1685
  <header class="page-header">
1732
- <div class="label page-eyebrow">Voice</div>
1733
- <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>
1686
+ <div class="page-header-body">
1687
+ <div class="label page-eyebrow">Voice</div>
1688
+ <h1 class="page-title">Style guide</h1>
1689
+ <p class="page-lede">The voice agents read before drafting, starting with the global guide and adding each project's own rules below.</p>
1690
+ </div>
1735
1691
  </header>
1736
1692
  <StyleGuideProjects vm={vm} />
1737
1693
  <StyleGuideRegion vm={vm} state={state} />
1738
1694
  <StyleGuideErrorBoxTemplate />
1739
1695
  </main>
1740
1696
  </div>
1697
+ <PageModules />
1741
1698
  </body>
1742
1699
  </html>
1743
1700
  );