reviewzy 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -0
- package/README.md +29 -10
- package/package.json +1 -1
- package/src/config.ts +4 -0
- package/src/dashboard/mount.ts +118 -1
- package/src/dashboard/repo-link.ts +126 -0
- package/src/dashboard/static/components.css +6780 -534
- package/src/dashboard/static/cursor-rules.js +83 -0
- package/src/dashboard/static/cursor.css +105 -0
- package/src/dashboard/static/cursor.js +358 -0
- package/src/dashboard/static/dashboard.css +139 -77
- package/src/dashboard/static/dashboard.js +147 -29
- package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/static/icons.svg +184 -0
- package/src/dashboard/static/tokens.css +421 -110
- package/src/dashboard/static/ui.js +1992 -0
- package/src/dashboard/views.tsx +600 -282
- package/src/dashboard/word-diff.ts +89 -0
- package/src/db/human-save.ts +112 -0
- package/src/db/migrations.ts +38 -0
- package/src/db/queries.ts +36 -13
- package/src/db/sweep.ts +2 -2
- package/src/mcp/await-approved.ts +17 -10
- package/src/mcp/fetch-approved.ts +11 -6
- package/src/mcp/file-entries.ts +95 -20
- package/src/mcp/list-entries.ts +54 -29
- package/src/mcp/mark-applied.ts +6 -6
- package/src/mcp/route.ts +6 -9
- package/src/mcp/server.ts +8 -3
- package/src/shim/daemon.ts +23 -16
- package/src/shim/main.ts +26 -5
package/src/dashboard/views.tsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
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
|
-
import { listRevisions, parseConstraints } from "../db/human-save.ts";
|
|
5
|
+
import { listRevisions, parseConstraints, parseEntryImages } from "../db/human-save.ts";
|
|
4
6
|
import type { Constraints, HumanSaveRefusal, RevisionRow, TransitionRefusal } from "../db/human-save.ts";
|
|
5
7
|
import {
|
|
8
|
+
adjacentEntryIds,
|
|
6
9
|
countEntries,
|
|
7
10
|
listEntries,
|
|
8
11
|
listProjects,
|
|
@@ -12,7 +15,9 @@ import type { EntryRow, EntryStatus } from "../db/queries.ts";
|
|
|
12
15
|
import { readStyleGuideSection } from "../db/style-guide.ts";
|
|
13
16
|
import type { StyleGuideForm, StyleGuideFormRefusal, StyleGuideInput } from "../db/style-guide.ts";
|
|
14
17
|
import type { Store } from "../db/store.ts";
|
|
15
|
-
import {
|
|
18
|
+
import { parseRepoLink, REPO_ICON } from "./repo-link.ts";
|
|
19
|
+
import { diffWords } from "./word-diff.ts";
|
|
20
|
+
import { NAME } from "../version.ts";
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
23
|
* The three dashboard filters, straight off the query string. `status` and `project` are the raw
|
|
@@ -30,6 +35,9 @@ const PAGE_SIZE = 200;
|
|
|
30
35
|
|
|
31
36
|
const STATUSES: readonly EntryStatus[] = ["draft", "approved", "applied", "rejected"];
|
|
32
37
|
|
|
38
|
+
/** The default Active view: the two statuses that still need a human. */
|
|
39
|
+
const ACTIVE_STATUSES: readonly EntryStatus[] = ["draft", "approved"];
|
|
40
|
+
|
|
33
41
|
function isEntryStatus(value: string): value is EntryStatus {
|
|
34
42
|
return (STATUSES as readonly string[]).includes(value);
|
|
35
43
|
}
|
|
@@ -48,7 +56,8 @@ const STATUS_LABEL: Record<EntryStatus, string> = {
|
|
|
48
56
|
rejected: "Rejected",
|
|
49
57
|
};
|
|
50
58
|
|
|
51
|
-
type
|
|
59
|
+
type ProjectBatch = { id: string; entries: EntryRow[] };
|
|
60
|
+
type ProjectGroup = { slug: string; batches: ProjectBatch[] };
|
|
52
61
|
|
|
53
62
|
export type ListViewModel = {
|
|
54
63
|
readonly total: number;
|
|
@@ -60,6 +69,8 @@ export type ListViewModel = {
|
|
|
60
69
|
readonly status: string;
|
|
61
70
|
readonly project: string;
|
|
62
71
|
readonly filtersActive: boolean;
|
|
72
|
+
/** Whether the default Active view is what hides every match: a filter found rows, but all of them are applied or rejected. */
|
|
73
|
+
readonly hiddenByActiveView: boolean;
|
|
63
74
|
};
|
|
64
75
|
|
|
65
76
|
/** Runs the keyset walk to exhaustion: the mcp tool's own list query, page size 200, no pagination UI. */
|
|
@@ -85,9 +96,11 @@ function allEntries(
|
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
/**
|
|
88
|
-
* Groups the filtered rows by project (slug order) then batch
|
|
89
|
-
*
|
|
90
|
-
*
|
|
99
|
+
* Groups the filtered rows by project (slug order) then batch; within a batch the rows keep the
|
|
100
|
+
* list query's ascending id order. Within a project, batches split into two partitions: any batch
|
|
101
|
+
* holding a draft or approved entry is actionable and leads, terminal-only batches (applied and
|
|
102
|
+
* rejected) follow. Each partition sorts newest batch first, with a deterministic order for
|
|
103
|
+
* batches minted in the same millisecond, so the same filter always renders the same page.
|
|
91
104
|
*/
|
|
92
105
|
function groupRows(rows: readonly EntryRow[], slugOf: Map<string, string>): ProjectGroup[] {
|
|
93
106
|
const byProject = new Map<string, EntryRow[]>();
|
|
@@ -101,24 +114,65 @@ function groupRows(rows: readonly EntryRow[], slugOf: Map<string, string>): Proj
|
|
|
101
114
|
for (const row of projectRows) {
|
|
102
115
|
byBatch.set(row.batch_id, [...(byBatch.get(row.batch_id) ?? []), row]);
|
|
103
116
|
}
|
|
104
|
-
const batches = [...byBatch.keys()].
|
|
105
|
-
|
|
117
|
+
const batches = [...byBatch.keys()].map((batchId) => ({ id: batchId, entries: byBatch.get(batchId)! }));
|
|
118
|
+
const actionable = batches.filter(isActionableBatch).sort(newestBatchFirst);
|
|
119
|
+
const terminal = batches.filter((batch) => !isActionableBatch(batch)).sort(newestBatchFirst);
|
|
120
|
+
return { slug: slugOf.get(projectId) ?? projectId, batches: [...actionable, ...terminal] };
|
|
106
121
|
});
|
|
107
122
|
}
|
|
108
123
|
|
|
124
|
+
/** A batch is actionable when any of its entries still needs a human: draft or approved. */
|
|
125
|
+
function isActionableBatch(batch: ProjectBatch): boolean {
|
|
126
|
+
return batch.entries.some((entry) => entry.status === "draft" || entry.status === "approved");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Code-unit descending comparison: ulids are fixed-length Crockford base32, so this equals time order. */
|
|
130
|
+
function compareDesc(a: string, b: string): number {
|
|
131
|
+
return a < b ? 1 : a > b ? -1 : 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The newest `created_at` in a batch, walked: a batch can hold thousands of entries, and a spread would overflow the call stack. */
|
|
135
|
+
function maxCreatedAt(batch: ProjectBatch): number {
|
|
136
|
+
let max = 0;
|
|
137
|
+
for (const entry of batch.entries) {
|
|
138
|
+
if (entry.created_at > max) max = entry.created_at;
|
|
139
|
+
}
|
|
140
|
+
return max;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Newest batch first. Batch ulids share a millisecond timestamp prefix, so a descending id is
|
|
145
|
+
* newest-first; batches minted in the same millisecond tie on that prefix, and the batch's
|
|
146
|
+
* newest `created_at` breaks the tie before the id itself, so a same-ms burst still renders in
|
|
147
|
+
* one fixed order.
|
|
148
|
+
*/
|
|
149
|
+
function newestBatchFirst(a: ProjectBatch, b: ProjectBatch): number {
|
|
150
|
+
const timeCmp = compareDesc(a.id.slice(0, 10), b.id.slice(0, 10));
|
|
151
|
+
if (timeCmp !== 0) return timeCmp;
|
|
152
|
+
const aCreated = maxCreatedAt(a);
|
|
153
|
+
const bCreated = maxCreatedAt(b);
|
|
154
|
+
if (aCreated !== bCreated) return bCreated - aCreated;
|
|
155
|
+
return compareDesc(a.id, b.id);
|
|
156
|
+
}
|
|
157
|
+
|
|
109
158
|
function loadList(store: Store, params: ListParams): ListViewModel {
|
|
110
159
|
const q = params.q;
|
|
111
160
|
const qActive = q !== undefined && q.trim() !== "";
|
|
112
161
|
const statusRaw = params.status;
|
|
113
162
|
const statusActive = statusRaw !== undefined && statusRaw !== "";
|
|
114
|
-
|
|
163
|
+
// `all` is the explicit "every status" filter value; the default (no status param, or an empty
|
|
164
|
+
// one) is the Active view of draft + approved only, which is not a filter and never renders the
|
|
165
|
+
// partial header. Both query every status; the Active view narrows the walked rows afterward.
|
|
166
|
+
const allStatuses = statusActive && statusRaw === "all";
|
|
167
|
+
const status = statusActive && !allStatuses && isEntryStatus(statusRaw) ? statusRaw : undefined;
|
|
115
168
|
const projectRaw = params.project;
|
|
116
169
|
const projectActive = projectRaw !== undefined && projectRaw !== "";
|
|
117
170
|
const projectId = projectActive ? (projectIdBySlug(store, projectRaw) ?? undefined) : undefined;
|
|
118
171
|
|
|
119
172
|
// A status or project value no filter can match is a settled no-match, not a page error.
|
|
120
|
-
const impossible = (statusActive && status === undefined) || (projectActive && projectId === undefined);
|
|
121
|
-
const
|
|
173
|
+
const impossible = (statusActive && !allStatuses && status === undefined) || (projectActive && projectId === undefined);
|
|
174
|
+
const walked = impossible ? [] : allEntries(store, { q: qActive ? q : undefined, status, projectId });
|
|
175
|
+
const rows = statusActive ? walked : walked.filter((row) => ACTIVE_STATUSES.includes(row.status));
|
|
122
176
|
|
|
123
177
|
const projects = listProjects(store);
|
|
124
178
|
const slugOf = new Map(projects.map((project) => [project.id, project.slug]));
|
|
@@ -131,59 +185,69 @@ function loadList(store: Store, params: ListParams): ListViewModel {
|
|
|
131
185
|
status: statusRaw ?? "",
|
|
132
186
|
project: projectRaw ?? "",
|
|
133
187
|
filtersActive: qActive || statusActive || projectActive,
|
|
188
|
+
hiddenByActiveView: !statusActive && walked.length > 0 && rows.length === 0,
|
|
134
189
|
};
|
|
135
190
|
}
|
|
136
191
|
|
|
137
|
-
/** The
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
192
|
+
/** The basename of a repo file path (the segment after the last slash), the dim per-row prefix. */
|
|
193
|
+
function fileBasename(path: string): string {
|
|
194
|
+
return path.split(/[/\\]/).pop() ?? path;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The entry's display title: the agent-supplied `title` when it is non-empty, else the anchor text
|
|
199
|
+
* (the string being changed), else the file basename for a foreign row carrying neither. The title
|
|
200
|
+
* is what the list and the editor lead with in place of the file path.
|
|
201
|
+
*/
|
|
202
|
+
function entryTitle(entry: EntryRow): string {
|
|
203
|
+
const title = entry.title?.trim();
|
|
204
|
+
if (title !== undefined && title !== "") return title;
|
|
205
|
+
const anchor = entry.anchor_text.trim();
|
|
206
|
+
if (anchor !== "") return anchor;
|
|
207
|
+
return fileBasename(entry.file);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The batch ulid shortened to head+tail, enough to name each batch's select-all checkbox distinctly for a screen reader. */
|
|
211
|
+
function shortUlid(id: string): string {
|
|
212
|
+
return `${id.slice(0, 6)}…${id.slice(-6)}`;
|
|
155
213
|
}
|
|
156
214
|
|
|
157
215
|
/** The section the current page belongs to, marking the matching navbar link. */
|
|
158
216
|
type NavSection = "entries" | "style-guide" | "none";
|
|
159
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
|
+
|
|
160
221
|
function Navbar({ signedIn, active }: { signedIn: boolean; active: NavSection }) {
|
|
161
222
|
return (
|
|
162
223
|
<nav class="navbar">
|
|
163
|
-
<div class="navbar-
|
|
164
|
-
<div class="
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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}
|
|
171
237
|
</div>
|
|
172
|
-
{signedIn ? (
|
|
173
|
-
<form method="post" action="/logout" class="navbar-actions">
|
|
174
|
-
<button class="btn btn-ghost btn-sm" type="submit">Sign out</button>
|
|
175
|
-
</form>
|
|
176
|
-
) : null}
|
|
177
238
|
</nav>
|
|
178
239
|
);
|
|
179
240
|
}
|
|
180
241
|
|
|
181
|
-
function PageHeader() {
|
|
242
|
+
function PageHeader({ total }: { total: number }) {
|
|
182
243
|
return (
|
|
183
244
|
<header class="page-header">
|
|
184
|
-
<div class="
|
|
185
|
-
|
|
186
|
-
|
|
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>
|
|
187
251
|
</header>
|
|
188
252
|
);
|
|
189
253
|
}
|
|
@@ -214,7 +278,8 @@ function FilterForm({ vm }: { vm: ListViewModel }) {
|
|
|
214
278
|
<div class="field">
|
|
215
279
|
<label class="field-label" for="status">Status</label>
|
|
216
280
|
<select class="input" id="status" name="status">
|
|
217
|
-
<option value="">
|
|
281
|
+
<option value="" selected={vm.status === ""}>Active</option>
|
|
282
|
+
<option value="all" selected={vm.status === "all"}>All statuses</option>
|
|
218
283
|
<option value="draft" selected={vm.status === "draft"}>Draft</option>
|
|
219
284
|
<option value="approved" selected={vm.status === "approved"}>Approved</option>
|
|
220
285
|
<option value="applied" selected={vm.status === "applied"}>Applied</option>
|
|
@@ -232,7 +297,9 @@ function FilterForm({ vm }: { vm: ListViewModel }) {
|
|
|
232
297
|
))}
|
|
233
298
|
</select>
|
|
234
299
|
</div>
|
|
235
|
-
<
|
|
300
|
+
<noscript>
|
|
301
|
+
<button class="btn btn-primary" type="submit">Apply filters</button>
|
|
302
|
+
</noscript>
|
|
236
303
|
</form>
|
|
237
304
|
);
|
|
238
305
|
}
|
|
@@ -254,11 +321,8 @@ function NoticeCallout({ notice }: { notice: ListNotice }) {
|
|
|
254
321
|
if (notice.refused.length === 0) {
|
|
255
322
|
return (
|
|
256
323
|
<div class="callout callout-success" role="status">
|
|
257
|
-
<div class="callout-icon"
|
|
258
|
-
<svg
|
|
259
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
260
|
-
<path d="M5 8l2 2 4-4" />
|
|
261
|
-
</svg>
|
|
324
|
+
<div class="callout-icon">
|
|
325
|
+
<svg aria-hidden="true"><use href="#i-success" /></svg>
|
|
262
326
|
</div>
|
|
263
327
|
<div class="callout-content">
|
|
264
328
|
<div class="callout-title">
|
|
@@ -270,11 +334,8 @@ function NoticeCallout({ notice }: { notice: ListNotice }) {
|
|
|
270
334
|
}
|
|
271
335
|
return (
|
|
272
336
|
<div class="callout callout-danger" role="alert">
|
|
273
|
-
<div class="callout-icon"
|
|
274
|
-
<svg
|
|
275
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
276
|
-
<path d="M10 6L6 10M6 6l4 4" />
|
|
277
|
-
</svg>
|
|
337
|
+
<div class="callout-icon">
|
|
338
|
+
<svg aria-hidden="true"><use href="#i-error" /></svg>
|
|
278
339
|
</div>
|
|
279
340
|
<div class="callout-content">
|
|
280
341
|
<div class="callout-title">
|
|
@@ -294,11 +355,24 @@ function NoticeCallout({ notice }: { notice: ListNotice }) {
|
|
|
294
355
|
function ListRegion({ vm, notice }: { vm: ListViewModel; notice: ListNotice | undefined }) {
|
|
295
356
|
if (vm.shown === 0) {
|
|
296
357
|
// Zero entries at all is "no entries yet" even under a filter: nothing exists to filter, and
|
|
297
|
-
// the empty state explains how entries get here.
|
|
358
|
+
// the empty state explains how entries get here. A store with entries but no matches is either
|
|
359
|
+
// a filter miss or the default Active view over a store full of applied and rejected entries.
|
|
298
360
|
return (
|
|
299
361
|
<>
|
|
300
362
|
{notice !== undefined ? <NoticeCallout notice={notice} /> : null}
|
|
301
|
-
{vm.total === 0 ?
|
|
363
|
+
{vm.total === 0 ? (
|
|
364
|
+
<EmptyAll />
|
|
365
|
+
) : vm.filtersActive ? (
|
|
366
|
+
<EmptyMatch
|
|
367
|
+
revealAllHref={
|
|
368
|
+
vm.hiddenByActiveView
|
|
369
|
+
? `/?q=${encodeURIComponent(vm.q)}&project=${encodeURIComponent(vm.project)}&status=all`
|
|
370
|
+
: undefined
|
|
371
|
+
}
|
|
372
|
+
/>
|
|
373
|
+
) : (
|
|
374
|
+
<EmptyActive />
|
|
375
|
+
)}
|
|
302
376
|
</>
|
|
303
377
|
);
|
|
304
378
|
}
|
|
@@ -325,30 +399,43 @@ function PartialHeader({ shown, total }: { shown: number; total: number }) {
|
|
|
325
399
|
function EmptyAll() {
|
|
326
400
|
return (
|
|
327
401
|
<div class="card empty-state">
|
|
328
|
-
<
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
<path d="M1.5 9.5h3.5l1 2h4l1-2h3.5" />
|
|
332
|
-
</svg>
|
|
333
|
-
</div>
|
|
334
|
-
<h3>No entries yet</h3>
|
|
335
|
-
<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>
|
|
336
405
|
</div>
|
|
337
406
|
);
|
|
338
407
|
}
|
|
339
408
|
|
|
340
|
-
|
|
409
|
+
/**
|
|
410
|
+
* The filter-miss state, with a way onward. When the default Active view is what hides the
|
|
411
|
+
* matches (a search that only hits applied or rejected rows), the way onward is the all-statuses
|
|
412
|
+
* view under the same filters — clearing the filters alone would land back on the Active view
|
|
413
|
+
* and still show nothing.
|
|
414
|
+
*/
|
|
415
|
+
function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
|
|
341
416
|
return (
|
|
342
417
|
<div class="card empty-state">
|
|
343
|
-
<
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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}
|
|
422
|
+
<div class="empty-actions">
|
|
423
|
+
<a href="/" class="btn btn-secondary btn-sm">Clear filters</a>
|
|
424
|
+
{revealAllHref !== undefined ? (
|
|
425
|
+
<a href={revealAllHref} class="btn btn-secondary btn-sm">Show all statuses</a>
|
|
426
|
+
) : null}
|
|
348
427
|
</div>
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
428
|
+
</div>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The default Active view over a store whose entries are all applied or rejected: nothing to do, no filters to clear. */
|
|
433
|
+
function EmptyActive() {
|
|
434
|
+
return (
|
|
435
|
+
<div class="card empty-state">
|
|
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>
|
|
352
439
|
</div>
|
|
353
440
|
);
|
|
354
441
|
}
|
|
@@ -395,114 +482,168 @@ function ProjectGroupView({ group }: { group: ProjectGroup }) {
|
|
|
395
482
|
<span class="tag tag-data">
|
|
396
483
|
{count} {count === 1 ? "entry" : "entries"}
|
|
397
484
|
</span>
|
|
485
|
+
{/* Every section and batch ships open; dashboard.js collapses the ones this viewer
|
|
486
|
+
collapsed before (localStorage) and keeps the buttons inert without JS. */}
|
|
487
|
+
<button
|
|
488
|
+
type="button"
|
|
489
|
+
class="btn btn-icon collapse-toggle"
|
|
490
|
+
aria-expanded="true"
|
|
491
|
+
aria-controls={`project-batches-${group.slug}`}
|
|
492
|
+
aria-label={`Toggle ${group.slug} section`}
|
|
493
|
+
data-collapse-toggle
|
|
494
|
+
data-collapse-target={`project-batches-${group.slug}`}
|
|
495
|
+
data-collapse-key={`project:${group.slug}`}
|
|
496
|
+
>
|
|
497
|
+
<svg aria-hidden="true"><use href="#i-chevron-down" /></svg>
|
|
498
|
+
</button>
|
|
398
499
|
</header>
|
|
399
|
-
{group.
|
|
400
|
-
|
|
401
|
-
<
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
</div>
|
|
405
|
-
<div class="table-wrap">
|
|
406
|
-
<table>
|
|
407
|
-
<thead>
|
|
408
|
-
<tr>
|
|
409
|
-
<th class="cell-select">Select</th>
|
|
410
|
-
<th>File</th>
|
|
411
|
-
<th>Anchor</th>
|
|
412
|
-
<th>Text</th>
|
|
413
|
-
<th>Status</th>
|
|
414
|
-
<th>Filed by</th>
|
|
415
|
-
<th>Constraints</th>
|
|
416
|
-
<th>Actions</th>
|
|
417
|
-
</tr>
|
|
418
|
-
</thead>
|
|
419
|
-
<tbody>
|
|
420
|
-
{batch.entries.map((entry) => (
|
|
421
|
-
<EntryRowView key={entry.id} entry={entry} />
|
|
422
|
-
))}
|
|
423
|
-
</tbody>
|
|
424
|
-
</table>
|
|
425
|
-
</div>
|
|
426
|
-
</div>
|
|
427
|
-
))}
|
|
500
|
+
<div id={`project-batches-${group.slug}`}>
|
|
501
|
+
{group.batches.map((batch) => (
|
|
502
|
+
<BatchView key={batch.id} batch={batch} />
|
|
503
|
+
))}
|
|
504
|
+
</div>
|
|
428
505
|
</section>
|
|
429
506
|
);
|
|
430
507
|
}
|
|
431
508
|
|
|
509
|
+
function BatchView({ batch }: { batch: ProjectBatch }) {
|
|
510
|
+
// A batch names its filer in the header only when every entry agrees on one non-null
|
|
511
|
+
// value; a disagreement leaves the header filer-less and each row names its own inline.
|
|
512
|
+
const filedBy = batch.entries.map((entry) => entry.filed_by);
|
|
513
|
+
const allAgree = filedBy.every((filer) => filer === filedBy[0]);
|
|
514
|
+
const singleFiler = allAgree && filedBy[0] !== null ? filedBy[0] : null;
|
|
515
|
+
const mixedFilers = !allAgree && filedBy.some((filer) => filer !== null);
|
|
516
|
+
return (
|
|
517
|
+
<div class="batch">
|
|
518
|
+
<div class="batch-header">
|
|
519
|
+
{/* Select-all toggles only this batch's draft rows. No `name`, so it never submits as
|
|
520
|
+
an entry id; dashboard.js derives checked/indeterminate from the draft checkboxes. */}
|
|
521
|
+
<label class="checkbox-label">
|
|
522
|
+
<input type="checkbox" class="select-all" aria-label={`Select all drafts in batch ${shortUlid(batch.id)}`} />
|
|
523
|
+
</label>
|
|
524
|
+
{singleFiler !== null ? <span class="batch-meta">{singleFiler}</span> : null}
|
|
525
|
+
<button
|
|
526
|
+
type="button"
|
|
527
|
+
class="btn btn-icon collapse-toggle"
|
|
528
|
+
aria-expanded="true"
|
|
529
|
+
aria-controls={`batch-table-${batch.id}`}
|
|
530
|
+
aria-label={`Toggle batch ${shortUlid(batch.id)}`}
|
|
531
|
+
data-collapse-toggle
|
|
532
|
+
data-collapse-target={`batch-table-${batch.id}`}
|
|
533
|
+
data-collapse-key={`batch:${batch.id}`}
|
|
534
|
+
>
|
|
535
|
+
<svg aria-hidden="true"><use href="#i-chevron-down" /></svg>
|
|
536
|
+
</button>
|
|
537
|
+
</div>
|
|
538
|
+
<div class="table-wrap" id={`batch-table-${batch.id}`}>
|
|
539
|
+
<table>
|
|
540
|
+
<colgroup>
|
|
541
|
+
<col class="col-select" />
|
|
542
|
+
<col />
|
|
543
|
+
<col class="col-status" />
|
|
544
|
+
<col class="col-file" />
|
|
545
|
+
<col class="col-actions" />
|
|
546
|
+
</colgroup>
|
|
547
|
+
<thead>
|
|
548
|
+
<tr>
|
|
549
|
+
{/* The column header stays in the accessibility tree via the sr-only span; the
|
|
550
|
+
checkbox column itself is too narrow for a visible label. */}
|
|
551
|
+
<th class="cell-select" scope="col"><span class="sr-only">Select</span></th>
|
|
552
|
+
<th scope="col">Title</th>
|
|
553
|
+
<th scope="col">Status</th>
|
|
554
|
+
<th scope="col">File</th>
|
|
555
|
+
<th scope="col">Actions</th>
|
|
556
|
+
</tr>
|
|
557
|
+
</thead>
|
|
558
|
+
<tbody>
|
|
559
|
+
{batch.entries.map((entry) => (
|
|
560
|
+
<EntryRowView key={entry.id} entry={entry} filerInline={mixedFilers} />
|
|
561
|
+
))}
|
|
562
|
+
</tbody>
|
|
563
|
+
</table>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
432
569
|
/**
|
|
433
570
|
* One row. The checkbox names the entry for the batch form; it exists only on drafts, because an
|
|
434
571
|
* approve on any other status always refuses and a checkbox that can only refuse is noise (races
|
|
435
|
-
* are still reported per entry). The
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* the
|
|
572
|
+
* are still reported per entry). The status chip sits beside the title; the three icon action slots
|
|
573
|
+
* always render, inapplicable ones disabled, so a double-click's second click lands on the disabled
|
|
574
|
+
* slot rather than a shifted action. Approve and reject target the list region and include the
|
|
575
|
+
* batch form's hidden filters, so an action round re-renders the region under the same filter; the
|
|
576
|
+
* editor link is always present. When the batch holds mixed filers, the row also names its own
|
|
577
|
+
* filer beside the title.
|
|
439
578
|
*/
|
|
440
|
-
function EntryRowView({ entry }: { entry: EntryRow }) {
|
|
441
|
-
const
|
|
442
|
-
const constraints = constraintSummary(entry.constraints);
|
|
579
|
+
function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: boolean }) {
|
|
580
|
+
const title = entryTitle(entry);
|
|
443
581
|
const approve = entry.status === "draft";
|
|
444
582
|
const reject = entry.status === "draft" || entry.status === "approved";
|
|
445
583
|
return (
|
|
446
584
|
<tr>
|
|
447
585
|
<td class="cell-select">
|
|
448
586
|
{approve ? (
|
|
449
|
-
<
|
|
587
|
+
<label class="checkbox-label">
|
|
588
|
+
<input type="checkbox" name="id" value={entry.id} aria-label={`Select ${entry.file}`} />
|
|
589
|
+
</label>
|
|
450
590
|
) : null}
|
|
451
591
|
</td>
|
|
452
|
-
<td
|
|
453
|
-
<
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
</td>
|
|
458
|
-
<td class="cell-text">
|
|
459
|
-
<span class="entry-text" title={text ?? ""}>{text}</span>
|
|
592
|
+
<td title={`${entry.file} — ${entry.anchor_text}`}>
|
|
593
|
+
<div class="entry-head">
|
|
594
|
+
<span class="entry-title">{title}</span>
|
|
595
|
+
{filerInline && entry.filed_by !== null ? <span class="entry-filer"> · {entry.filed_by}</span> : null}
|
|
596
|
+
</div>
|
|
460
597
|
</td>
|
|
461
598
|
<td>
|
|
462
|
-
<span class={`tag ${STATUS_TAG[entry.status]}`}>
|
|
463
|
-
<span class="tag-dot"></span>
|
|
464
|
-
{STATUS_LABEL[entry.status]}
|
|
465
|
-
</span>
|
|
466
|
-
</td>
|
|
467
|
-
<td class="cell-filedby">{entry.filed_by ?? ""}</td>
|
|
468
|
-
<td class="cell-constraints">
|
|
469
|
-
{constraints.length > 0 ? <span class="constraint-summary">{constraints.join(" · ")}</span> : null}
|
|
599
|
+
<span class={`tag ${STATUS_TAG[entry.status]}`}>{STATUS_LABEL[entry.status]}</span>
|
|
470
600
|
</td>
|
|
601
|
+
<td class="cell-file" title={entry.file}>{entry.file}</td>
|
|
471
602
|
<td class="cell-actions">
|
|
472
603
|
{approve ? (
|
|
473
604
|
<button
|
|
474
605
|
type="button"
|
|
475
|
-
class="btn btn-
|
|
606
|
+
class="btn btn-icon"
|
|
607
|
+
style="color: var(--success)"
|
|
608
|
+
aria-label={`Approve ${entry.file}`}
|
|
476
609
|
hx-post={`/entries/${entry.id}/approve`}
|
|
477
610
|
hx-target="#entries-list"
|
|
478
611
|
hx-swap="innerHTML"
|
|
479
612
|
hx-indicator="#entries-loading"
|
|
480
613
|
hx-disabled-elt="this"
|
|
481
614
|
>
|
|
482
|
-
|
|
615
|
+
<svg aria-hidden="true"><use href="#i-check" /></svg>
|
|
483
616
|
</button>
|
|
484
|
-
) :
|
|
485
|
-
// The approve slot stays occupied
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
Approve
|
|
617
|
+
) : (
|
|
618
|
+
// The approve slot stays occupied on every non-draft row: a double-click's second click
|
|
619
|
+
// lands on the disabled button (pointer-events none) rather than the next slot.
|
|
620
|
+
<button type="button" class="btn btn-icon" style="color: var(--success)" aria-label={`Approve ${entry.file}`} disabled>
|
|
621
|
+
<svg aria-hidden="true"><use href="#i-check" /></svg>
|
|
490
622
|
</button>
|
|
491
|
-
)
|
|
623
|
+
)}
|
|
492
624
|
{reject ? (
|
|
493
625
|
<button
|
|
494
626
|
type="button"
|
|
495
|
-
class="btn btn-
|
|
627
|
+
class="btn btn-icon"
|
|
628
|
+
style="color: var(--danger)"
|
|
629
|
+
aria-label={`Reject ${entry.file}`}
|
|
496
630
|
hx-post={`/entries/${entry.id}/reject`}
|
|
497
631
|
hx-target="#entries-list"
|
|
498
632
|
hx-swap="innerHTML"
|
|
499
633
|
hx-indicator="#entries-loading"
|
|
500
634
|
hx-disabled-elt="this"
|
|
501
635
|
>
|
|
502
|
-
|
|
636
|
+
<svg aria-hidden="true"><use href="#i-x" /></svg>
|
|
503
637
|
</button>
|
|
504
|
-
) :
|
|
505
|
-
|
|
638
|
+
) : (
|
|
639
|
+
// The reject slot stays occupied on applied and rejected rows for the same guard.
|
|
640
|
+
<button type="button" class="btn btn-icon" style="color: var(--danger)" aria-label={`Reject ${entry.file}`} disabled>
|
|
641
|
+
<svg aria-hidden="true"><use href="#i-x" /></svg>
|
|
642
|
+
</button>
|
|
643
|
+
)}
|
|
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>
|
|
646
|
+
</a>
|
|
506
647
|
</td>
|
|
507
648
|
</tr>
|
|
508
649
|
);
|
|
@@ -513,11 +654,8 @@ function ErrorBoxTemplate() {
|
|
|
513
654
|
return (
|
|
514
655
|
<template id="error-box">
|
|
515
656
|
<div class="callout callout-danger" role="alert">
|
|
516
|
-
<div class="callout-icon"
|
|
517
|
-
<svg
|
|
518
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
519
|
-
<path d="M10 6L6 10M6 6l4 4" />
|
|
520
|
-
</svg>
|
|
657
|
+
<div class="callout-icon">
|
|
658
|
+
<svg aria-hidden="true"><use href="#i-error" /></svg>
|
|
521
659
|
</div>
|
|
522
660
|
<div class="callout-content">
|
|
523
661
|
<div class="callout-title">The list could not be loaded</div>
|
|
@@ -531,6 +669,46 @@ function ErrorBoxTemplate() {
|
|
|
531
669
|
);
|
|
532
670
|
}
|
|
533
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
|
+
|
|
534
712
|
/** The full page for a plain navigation; the mount wraps it in the doctype. `signedIn` is whether the navbar offers sign-out. */
|
|
535
713
|
export function dashboardPage(
|
|
536
714
|
store: Store,
|
|
@@ -541,23 +719,13 @@ export function dashboardPage(
|
|
|
541
719
|
const vm = loadList(store, params);
|
|
542
720
|
return (
|
|
543
721
|
<html lang="en" data-theme="dark">
|
|
544
|
-
<
|
|
545
|
-
<meta charset="utf-8" />
|
|
546
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
547
|
-
{/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
|
|
548
|
-
<link rel="icon" href="data:," />
|
|
549
|
-
<title>Entries · {NAME}</title>
|
|
550
|
-
<link rel="stylesheet" href="/static/tokens.css" />
|
|
551
|
-
<link rel="stylesheet" href="/static/components.css" />
|
|
552
|
-
<link rel="stylesheet" href="/static/dashboard.css" />
|
|
553
|
-
<script src="/static/htmx.min.js"></script>
|
|
554
|
-
<script src="/static/dashboard.js" defer></script>
|
|
555
|
-
</head>
|
|
722
|
+
<PageHead title={`Entries · ${NAME}`} />
|
|
556
723
|
<body>
|
|
724
|
+
<IconSprite />
|
|
557
725
|
<div class="app-shell">
|
|
558
726
|
<Navbar signedIn={signedIn} active="entries" />
|
|
559
727
|
<main class="main">
|
|
560
|
-
<PageHeader />
|
|
728
|
+
<PageHeader total={vm.total} />
|
|
561
729
|
<FilterForm vm={vm} />
|
|
562
730
|
<div id="entries">
|
|
563
731
|
<div id="entries-loading" class="htmx-indicator" role="status">
|
|
@@ -571,6 +739,7 @@ export function dashboardPage(
|
|
|
571
739
|
<ErrorBoxTemplate />
|
|
572
740
|
</main>
|
|
573
741
|
</div>
|
|
742
|
+
<PageModules />
|
|
574
743
|
</body>
|
|
575
744
|
</html>
|
|
576
745
|
);
|
|
@@ -585,26 +754,18 @@ export function dashboardPage(
|
|
|
585
754
|
export function loginPage(next: string | undefined, error: string | undefined = undefined): JSX.Element {
|
|
586
755
|
return (
|
|
587
756
|
<html lang="en" data-theme="dark">
|
|
588
|
-
<
|
|
589
|
-
<meta charset="utf-8" />
|
|
590
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
591
|
-
{/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
|
|
592
|
-
<link rel="icon" href="data:," />
|
|
593
|
-
<title>Sign in · {NAME}</title>
|
|
594
|
-
<link rel="stylesheet" href="/static/tokens.css" />
|
|
595
|
-
<link rel="stylesheet" href="/static/components.css" />
|
|
596
|
-
<link rel="stylesheet" href="/static/dashboard.css" />
|
|
597
|
-
<script src="/static/htmx.min.js"></script>
|
|
598
|
-
<script src="/static/dashboard.js" defer></script>
|
|
599
|
-
</head>
|
|
757
|
+
<PageHead title={`Sign in · ${NAME}`} />
|
|
600
758
|
<body>
|
|
759
|
+
<IconSprite />
|
|
601
760
|
<div class="app-shell">
|
|
602
761
|
<Navbar signedIn={false} active="none" />
|
|
603
762
|
<main class="main login-main">
|
|
604
763
|
<header class="page-header">
|
|
605
|
-
<div class="
|
|
606
|
-
|
|
607
|
-
|
|
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>
|
|
608
769
|
</header>
|
|
609
770
|
{error !== undefined ? <DangerCallout title={error} /> : null}
|
|
610
771
|
<form method="post" action="/login" class="card login-card">
|
|
@@ -627,6 +788,7 @@ export function loginPage(next: string | undefined, error: string | undefined =
|
|
|
627
788
|
</form>
|
|
628
789
|
</main>
|
|
629
790
|
</div>
|
|
791
|
+
<PageModules />
|
|
630
792
|
</body>
|
|
631
793
|
</html>
|
|
632
794
|
);
|
|
@@ -643,6 +805,9 @@ export type EditorViewModel = {
|
|
|
643
805
|
readonly entry: EntryRow;
|
|
644
806
|
readonly revisions: readonly RevisionRow[];
|
|
645
807
|
readonly constraints: Constraints;
|
|
808
|
+
/** The entry immediately before/after in the list's ascending-id walk, or null at the boundary. */
|
|
809
|
+
readonly prevId: string | null;
|
|
810
|
+
readonly nextId: string | null;
|
|
646
811
|
};
|
|
647
812
|
|
|
648
813
|
/**
|
|
@@ -665,7 +830,8 @@ export function getEntry(store: Store, id: string): EntryRow | undefined {
|
|
|
665
830
|
export function loadEditor(store: Store, id: string): EditorViewModel | null {
|
|
666
831
|
const entry = getEntry(store, id);
|
|
667
832
|
if (entry === undefined) return null;
|
|
668
|
-
|
|
833
|
+
const { prevId, nextId } = adjacentEntryIds(store, id);
|
|
834
|
+
return { entry, revisions: listRevisions(store, id), constraints: parseConstraints(entry.constraints), prevId, nextId };
|
|
669
835
|
}
|
|
670
836
|
|
|
671
837
|
/** The tolerant parse for `entries.context`, mirroring `parseConstraints`: foreign rows may hold malformed JSON, and unparseable means no context rows, never a 500. */
|
|
@@ -695,7 +861,7 @@ export function formatSaveRefusal(refusal: Exclude<HumanSaveRefusal, { kind: "un
|
|
|
695
861
|
case "status":
|
|
696
862
|
return refusal.status === "applied"
|
|
697
863
|
? "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."
|
|
698
|
-
: "This entry was rejected, so its text can't be changed. A rejected entry stays rejected; file a new entry if the
|
|
864
|
+
: "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.";
|
|
699
865
|
}
|
|
700
866
|
}
|
|
701
867
|
|
|
@@ -721,7 +887,7 @@ export function formatTransitionRefusal(
|
|
|
721
887
|
? `Entry ${entryFile} is already applied, so it can't be approved. If the anchor goes stale, the entry returns to approved.`
|
|
722
888
|
: `Entry ${entryFile} is already applied, so it can't be rejected. If the anchor goes stale, it returns to approved and can be rejected then.`;
|
|
723
889
|
case "rejected":
|
|
724
|
-
return `Entry ${entryFile} was rejected, and a rejected entry stays rejected. File a new entry if the
|
|
890
|
+
return `Entry ${entryFile} was rejected, and a rejected entry stays rejected. File a new entry if the passage still needs changing.`;
|
|
725
891
|
}
|
|
726
892
|
}
|
|
727
893
|
}
|
|
@@ -729,11 +895,8 @@ export function formatTransitionRefusal(
|
|
|
729
895
|
function DangerCallout({ title, body }: { title: string; body?: string }) {
|
|
730
896
|
return (
|
|
731
897
|
<div class="callout callout-danger" role="alert">
|
|
732
|
-
<div class="callout-icon"
|
|
733
|
-
<svg
|
|
734
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
735
|
-
<path d="M10 6L6 10M6 6l4 4" />
|
|
736
|
-
</svg>
|
|
898
|
+
<div class="callout-icon">
|
|
899
|
+
<svg aria-hidden="true"><use href="#i-error" /></svg>
|
|
737
900
|
</div>
|
|
738
901
|
<div class="callout-content">
|
|
739
902
|
<div class="callout-title">{title}</div>
|
|
@@ -770,9 +933,8 @@ function EditorRegion({ vm, state }: { vm: EditorViewModel; state: EditorState |
|
|
|
770
933
|
>
|
|
771
934
|
<input type="hidden" name="view" value="editor" />
|
|
772
935
|
<div class="card-header">
|
|
773
|
-
<div class="card-title">
|
|
936
|
+
<div class="card-title editor-title">{entryTitle(vm.entry)}</div>
|
|
774
937
|
<span class={`tag ${STATUS_TAG[vm.entry.status]}`}>
|
|
775
|
-
<span class="tag-dot"></span>
|
|
776
938
|
{STATUS_LABEL[vm.entry.status]}
|
|
777
939
|
</span>
|
|
778
940
|
</div>
|
|
@@ -845,7 +1007,7 @@ function RevisionHistory({ revisions }: { revisions: readonly RevisionRow[] }) {
|
|
|
845
1007
|
<time class="revision-time" datetime={new Date(revision.created_at).toISOString()}>
|
|
846
1008
|
{new Date(revision.created_at).toLocaleString()}
|
|
847
1009
|
</time>
|
|
848
|
-
<button class="btn btn-
|
|
1010
|
+
<button class="btn btn-secondary btn-sm" type="button" data-use-revision>
|
|
849
1011
|
Use
|
|
850
1012
|
</button>
|
|
851
1013
|
</div>
|
|
@@ -859,13 +1021,12 @@ function RevisionHistory({ revisions }: { revisions: readonly RevisionRow[] }) {
|
|
|
859
1021
|
);
|
|
860
1022
|
}
|
|
861
1023
|
|
|
862
|
-
/** The constraint panel: what the entry's save enforces, plus the display-only tone
|
|
1024
|
+
/** The constraint panel: what the entry's save enforces (length, placeholders), plus the display-only tone. The notes moved to their own card, so the enforced fields stay the card's lead. */
|
|
863
1025
|
function ConstraintsCard({ constraints, text }: { constraints: Constraints; text: string }) {
|
|
864
1026
|
const maxLen = constraints.maxLen;
|
|
865
1027
|
const placeholders = constraints.placeholders;
|
|
866
1028
|
const tone = constraints.tone;
|
|
867
|
-
const
|
|
868
|
-
const hasAny = maxLen !== undefined || placeholders.length > 0 || tone !== undefined || notes !== undefined;
|
|
1029
|
+
const hasAny = maxLen !== undefined || placeholders.length > 0 || tone !== undefined;
|
|
869
1030
|
if (!hasAny) return null;
|
|
870
1031
|
return (
|
|
871
1032
|
<div class="card">
|
|
@@ -907,18 +1068,146 @@ function ConstraintsCard({ constraints, text }: { constraints: Constraints; text
|
|
|
907
1068
|
<div class="constraint-value">{tone}</div>
|
|
908
1069
|
</div>
|
|
909
1070
|
) : null}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1071
|
+
</div>
|
|
1072
|
+
</div>
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* The notes card: the agent's notes (from `constraints.notes`, read-only) above the human's own
|
|
1078
|
+
* notes as an editable textarea with its own save. The textarea carries `hx-preserve`, so any swap
|
|
1079
|
+
* (a text save, an image add) keeps the human's in-progress note; the save targets the whole
|
|
1080
|
+
* editor view like the save form, so the saved value re-renders in place. The card always renders:
|
|
1081
|
+
* the human's notes need a home even before the agent sent any.
|
|
1082
|
+
*/
|
|
1083
|
+
function NotesCard({ vm }: { vm: EditorViewModel }) {
|
|
1084
|
+
return (
|
|
1085
|
+
<div class="card">
|
|
1086
|
+
<div class="card-header">
|
|
1087
|
+
<div class="card-title">Notes</div>
|
|
1088
|
+
</div>
|
|
1089
|
+
<div class="card-content">
|
|
1090
|
+
<div class="constraint-row">
|
|
1091
|
+
<div class="constraint-label">Agent's notes</div>
|
|
1092
|
+
<div class="constraint-value">{vm.constraints.notes ?? "None."}</div>
|
|
1093
|
+
</div>
|
|
1094
|
+
<form
|
|
1095
|
+
id="notes-form"
|
|
1096
|
+
method="post"
|
|
1097
|
+
action={`/entries/${vm.entry.id}/notes`}
|
|
1098
|
+
hx-post={`/entries/${vm.entry.id}/notes`}
|
|
1099
|
+
hx-target="#editor-view"
|
|
1100
|
+
hx-swap="outerHTML"
|
|
1101
|
+
>
|
|
1102
|
+
<div class="field notes-field">
|
|
1103
|
+
<label class="field-label" for="editor-notes">Your notes</label>
|
|
1104
|
+
<textarea
|
|
1105
|
+
id="editor-notes"
|
|
1106
|
+
class="input"
|
|
1107
|
+
name="notes"
|
|
1108
|
+
rows={4}
|
|
1109
|
+
aria-label="Your notes"
|
|
1110
|
+
hx-preserve="true"
|
|
1111
|
+
>
|
|
1112
|
+
{vm.entry.human_notes ?? ""}
|
|
1113
|
+
</textarea>
|
|
914
1114
|
</div>
|
|
915
|
-
|
|
1115
|
+
<div class="notes-actions">
|
|
1116
|
+
<button class="btn btn-secondary btn-sm" type="submit">Save notes</button>
|
|
1117
|
+
</div>
|
|
1118
|
+
</form>
|
|
916
1119
|
</div>
|
|
917
1120
|
</div>
|
|
918
1121
|
);
|
|
919
1122
|
}
|
|
920
1123
|
|
|
921
|
-
/**
|
|
1124
|
+
/** An image item renders only when it still matches the prefixes the wire validated; a foreign row can hold anything, so a non-matching item shows a note instead, never raw. */
|
|
1125
|
+
function isRenderableImage(item: string): boolean {
|
|
1126
|
+
return item.startsWith("data:image/") || /^https?:\/\//.test(item);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* The images attached to an entry, one card in the rail: each valid item as a real `img` (sized by
|
|
1131
|
+
* css), an invalid one as a "not shown" note, a remove form per image, and the one-file upload
|
|
1132
|
+
* form. A card of its own, not a strip in the context card: upload and remove need a stable home,
|
|
1133
|
+
* and the context card stays lean for what the human authors against. Both forms target the whole
|
|
1134
|
+
* editor view like the save form, so a change re-renders the card in place.
|
|
1135
|
+
*/
|
|
1136
|
+
function ImagesCard({ entry }: { entry: EntryRow }) {
|
|
1137
|
+
const images = parseEntryImages(entry.images);
|
|
1138
|
+
return (
|
|
1139
|
+
<div class="card">
|
|
1140
|
+
<div class="card-header">
|
|
1141
|
+
<div class="card-title">Images</div>
|
|
1142
|
+
</div>
|
|
1143
|
+
<div class="card-content">
|
|
1144
|
+
{images.length === 0 ? (
|
|
1145
|
+
<p class="field-hint">No images yet.</p>
|
|
1146
|
+
) : (
|
|
1147
|
+
<ul class="image-list">
|
|
1148
|
+
{images.map((item, index) =>
|
|
1149
|
+
isRenderableImage(item) ? (
|
|
1150
|
+
<li key={index}>
|
|
1151
|
+
<img class="entry-image" src={item} alt={`Image ${index + 1}`} loading="lazy" />
|
|
1152
|
+
<form
|
|
1153
|
+
class="image-remove"
|
|
1154
|
+
method="post"
|
|
1155
|
+
action={`/entries/${entry.id}/images/${index}/remove`}
|
|
1156
|
+
hx-post={`/entries/${entry.id}/images/${index}/remove`}
|
|
1157
|
+
hx-target="#editor-view"
|
|
1158
|
+
hx-swap="outerHTML"
|
|
1159
|
+
>
|
|
1160
|
+
<button class="btn btn-secondary btn-sm" type="submit">Remove</button>
|
|
1161
|
+
</form>
|
|
1162
|
+
</li>
|
|
1163
|
+
) : (
|
|
1164
|
+
<li key={index}>
|
|
1165
|
+
<p class="image-skip">Image {index + 1} not shown: unsupported source.</p>
|
|
1166
|
+
</li>
|
|
1167
|
+
),
|
|
1168
|
+
)}
|
|
1169
|
+
</ul>
|
|
1170
|
+
)}
|
|
1171
|
+
<form
|
|
1172
|
+
class="image-upload"
|
|
1173
|
+
method="post"
|
|
1174
|
+
action={`/entries/${entry.id}/images`}
|
|
1175
|
+
enctype="multipart/form-data"
|
|
1176
|
+
hx-post={`/entries/${entry.id}/images`}
|
|
1177
|
+
hx-encoding="multipart/form-data"
|
|
1178
|
+
hx-target="#editor-view"
|
|
1179
|
+
hx-swap="outerHTML"
|
|
1180
|
+
>
|
|
1181
|
+
<input class="input" type="file" name="file" accept="image/*" aria-label="Image file" />
|
|
1182
|
+
<button class="btn btn-secondary btn-sm" type="submit" hx-disabled-elt="this">Add image</button>
|
|
1183
|
+
</form>
|
|
1184
|
+
</div>
|
|
1185
|
+
</div>
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* The repo row's value: a recognized remote renders as its brand icon beside the bare owner/repo
|
|
1191
|
+
* path, both linking out to the host; an unrecognized one renders as the raw string as filed.
|
|
1192
|
+
*/
|
|
1193
|
+
function RepoValue({ repo }: { repo: string }) {
|
|
1194
|
+
const link = parseRepoLink(repo);
|
|
1195
|
+
if (link === null) return <>{repo}</>;
|
|
1196
|
+
return (
|
|
1197
|
+
<a class="repo-link" href={link.href} target="_blank" rel="noopener noreferrer" title={repo}>
|
|
1198
|
+
<svg class="repo-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
1199
|
+
<path d={REPO_ICON[link.provider]} />
|
|
1200
|
+
</svg>
|
|
1201
|
+
<span>{link.path}</span>
|
|
1202
|
+
</a>
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* The entry's context JSON plus its anchor metadata, as an info-table readout. The anchor and the
|
|
1208
|
+
* agent-supplied context rows lead — they are what the human authors against — then repo and file;
|
|
1209
|
+
* the provenance rows (hash, filer, stale note) are secondary and sit collapsed behind a summary.
|
|
1210
|
+
*/
|
|
922
1211
|
function ContextCard({ entry }: { entry: EntryRow }) {
|
|
923
1212
|
const context = parseContext(entry.context);
|
|
924
1213
|
return (
|
|
@@ -928,6 +1217,10 @@ function ContextCard({ entry }: { entry: EntryRow }) {
|
|
|
928
1217
|
</div>
|
|
929
1218
|
<div class="card-content">
|
|
930
1219
|
<table class="info-table">
|
|
1220
|
+
<tr>
|
|
1221
|
+
<td>Anchor</td>
|
|
1222
|
+
<td>{entry.anchor_text}</td>
|
|
1223
|
+
</tr>
|
|
931
1224
|
{Object.entries(context).map(([key, value]) => (
|
|
932
1225
|
<tr key={key}>
|
|
933
1226
|
<td>{key}</td>
|
|
@@ -936,33 +1229,34 @@ function ContextCard({ entry }: { entry: EntryRow }) {
|
|
|
936
1229
|
))}
|
|
937
1230
|
<tr>
|
|
938
1231
|
<td>Repo</td>
|
|
939
|
-
<td
|
|
1232
|
+
<td><RepoValue repo={entry.repo} /></td>
|
|
940
1233
|
</tr>
|
|
941
1234
|
<tr>
|
|
942
1235
|
<td>File</td>
|
|
943
1236
|
<td>{entry.file}</td>
|
|
944
1237
|
</tr>
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
<tr>
|
|
950
|
-
<td>File hash</td>
|
|
951
|
-
<td>{entry.file_hash}</td>
|
|
952
|
-
</tr>
|
|
953
|
-
{entry.filed_by !== null ? (
|
|
954
|
-
<tr>
|
|
955
|
-
<td>Filed by</td>
|
|
956
|
-
<td>{entry.filed_by}</td>
|
|
957
|
-
</tr>
|
|
958
|
-
) : null}
|
|
959
|
-
{entry.stale_note !== null ? (
|
|
1238
|
+
</table>
|
|
1239
|
+
<details class="context-secondary">
|
|
1240
|
+
<summary>More</summary>
|
|
1241
|
+
<table class="info-table">
|
|
960
1242
|
<tr>
|
|
961
|
-
<td>
|
|
962
|
-
<td>{entry.
|
|
1243
|
+
<td>File hash</td>
|
|
1244
|
+
<td>{entry.file_hash}</td>
|
|
963
1245
|
</tr>
|
|
964
|
-
|
|
965
|
-
|
|
1246
|
+
{entry.filed_by !== null ? (
|
|
1247
|
+
<tr>
|
|
1248
|
+
<td>Filed by</td>
|
|
1249
|
+
<td>{entry.filed_by}</td>
|
|
1250
|
+
</tr>
|
|
1251
|
+
) : null}
|
|
1252
|
+
{entry.stale_note !== null ? (
|
|
1253
|
+
<tr>
|
|
1254
|
+
<td>Stale note</td>
|
|
1255
|
+
<td>{entry.stale_note}</td>
|
|
1256
|
+
</tr>
|
|
1257
|
+
) : null}
|
|
1258
|
+
</table>
|
|
1259
|
+
</details>
|
|
966
1260
|
</div>
|
|
967
1261
|
</div>
|
|
968
1262
|
);
|
|
@@ -970,14 +1264,18 @@ function ContextCard({ entry }: { entry: EntryRow }) {
|
|
|
970
1264
|
|
|
971
1265
|
/**
|
|
972
1266
|
* The before-and-after of the entry's text, above the editor: what the anchored region reads now
|
|
973
|
-
* (the anchor
|
|
974
|
-
* escape through JSX; the text is the stored bytes, shown as-is.
|
|
1267
|
+
* (the anchor visually distinct) and what the entry's text will replace it with. Both sides
|
|
1268
|
+
* escape through JSX; the text is the stored bytes, shown as-is. The word-level marks come from
|
|
1269
|
+
* the server-side walk: words only the anchor has render red in the before card, words only the proposal
|
|
1270
|
+
* has render green in the after card, everything shared stays plain, so an identical pair renders
|
|
1271
|
+
* no marks at all.
|
|
975
1272
|
*/
|
|
976
1273
|
function DiffView({ entry }: { entry: EntryRow }) {
|
|
977
1274
|
const after = entry.human_text ?? entry.agent_draft;
|
|
978
1275
|
// No text at all means nothing to compare: a filed entry whose agent never drafted, or an
|
|
979
1276
|
// approved entry whose text was never set.
|
|
980
1277
|
if (after === null) return null;
|
|
1278
|
+
const diff = diffWords(entry.anchor_text, after);
|
|
981
1279
|
return (
|
|
982
1280
|
<div class="diff-grid">
|
|
983
1281
|
<div class="card">
|
|
@@ -986,7 +1284,15 @@ function DiffView({ entry }: { entry: EntryRow }) {
|
|
|
986
1284
|
</div>
|
|
987
1285
|
<pre class="diff-code">
|
|
988
1286
|
<span class="diff-line">{entry.anchor_before}</span>
|
|
989
|
-
<span class="diff-line diff-anchor">
|
|
1287
|
+
<span class="diff-line diff-anchor">
|
|
1288
|
+
{diff.before.map((token, index) =>
|
|
1289
|
+
token.kind === "removed" ? (
|
|
1290
|
+
<span class="diff-remove" key={index}>{token.text}</span>
|
|
1291
|
+
) : (
|
|
1292
|
+
token.text
|
|
1293
|
+
),
|
|
1294
|
+
)}
|
|
1295
|
+
</span>
|
|
990
1296
|
<span class="diff-line">{entry.anchor_after}</span>
|
|
991
1297
|
</pre>
|
|
992
1298
|
</div>
|
|
@@ -994,7 +1300,15 @@ function DiffView({ entry }: { entry: EntryRow }) {
|
|
|
994
1300
|
<div class="card-header">
|
|
995
1301
|
<div class="card-title">After</div>
|
|
996
1302
|
</div>
|
|
997
|
-
<pre class="diff-code">
|
|
1303
|
+
<pre class="diff-code">
|
|
1304
|
+
{diff.after.map((token, index) =>
|
|
1305
|
+
token.kind === "added" ? (
|
|
1306
|
+
<span class="diff-add" key={index}>{token.text}</span>
|
|
1307
|
+
) : (
|
|
1308
|
+
token.text
|
|
1309
|
+
),
|
|
1310
|
+
)}
|
|
1311
|
+
</pre>
|
|
998
1312
|
</div>
|
|
999
1313
|
</div>
|
|
1000
1314
|
);
|
|
@@ -1017,43 +1331,70 @@ function EditorView({ vm, state }: { vm: EditorViewModel; state: EditorState | u
|
|
|
1017
1331
|
<aside class="editor-rail">
|
|
1018
1332
|
<ContextCard entry={vm.entry} />
|
|
1019
1333
|
<ConstraintsCard constraints={vm.constraints} text={text} />
|
|
1334
|
+
<NotesCard vm={vm} />
|
|
1335
|
+
<ImagesCard entry={vm.entry} />
|
|
1020
1336
|
</aside>
|
|
1021
1337
|
</div>
|
|
1022
1338
|
</div>
|
|
1023
1339
|
);
|
|
1024
1340
|
}
|
|
1025
1341
|
|
|
1342
|
+
/** The editor's full-height prev/next rails: one fixed strip per side, a link when a neighbour exists. */
|
|
1343
|
+
function EditorNav({ prevId, nextId }: { prevId: string | null; nextId: string | null }) {
|
|
1344
|
+
return (
|
|
1345
|
+
<nav class="editor-nav" aria-label="Entry navigation">
|
|
1346
|
+
<EditorNavButton side="prev" targetId={prevId} />
|
|
1347
|
+
<EditorNavButton side="next" targetId={nextId} />
|
|
1348
|
+
</nav>
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** One rail: a chevron on a full-height strip, or an inert placeholder at the boundary. */
|
|
1353
|
+
function EditorNavButton({ side, targetId }: { side: "prev" | "next"; targetId: string | null }) {
|
|
1354
|
+
const label = side === "prev" ? "Previous entry" : "Next entry";
|
|
1355
|
+
const cls = `editor-nav-btn editor-nav-${side}`;
|
|
1356
|
+
const icon = (
|
|
1357
|
+
<svg aria-hidden="true"><use href={side === "prev" ? "#i-chevron-left" : "#i-chevron-right"} /></svg>
|
|
1358
|
+
);
|
|
1359
|
+
if (targetId === null) {
|
|
1360
|
+
return (
|
|
1361
|
+
<span class={`${cls} is-disabled`} aria-hidden="true">
|
|
1362
|
+
{icon}
|
|
1363
|
+
</span>
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
return (
|
|
1367
|
+
<a class={cls} href={`/entries/${targetId}`} aria-label={label}>
|
|
1368
|
+
{icon}
|
|
1369
|
+
</a>
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1026
1373
|
/** The full editor page; the mount wraps it in the doctype. `signedIn` is whether the navbar offers sign-out. */
|
|
1027
1374
|
export function editorPage(vm: EditorViewModel, state: EditorState | undefined = undefined, signedIn = false): JSX.Element {
|
|
1028
1375
|
return (
|
|
1029
1376
|
<html lang="en" data-theme="dark">
|
|
1030
|
-
<
|
|
1031
|
-
<meta charset="utf-8" />
|
|
1032
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1033
|
-
{/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
|
|
1034
|
-
<link rel="icon" href="data:," />
|
|
1035
|
-
<title>Edit entry · {NAME}</title>
|
|
1036
|
-
<link rel="stylesheet" href="/static/tokens.css" />
|
|
1037
|
-
<link rel="stylesheet" href="/static/components.css" />
|
|
1038
|
-
<link rel="stylesheet" href="/static/dashboard.css" />
|
|
1039
|
-
<script src="/static/htmx.min.js"></script>
|
|
1040
|
-
<script src="/static/dashboard.js" defer></script>
|
|
1041
|
-
</head>
|
|
1377
|
+
<PageHead title={`Edit entry · ${NAME}`} />
|
|
1042
1378
|
<body>
|
|
1379
|
+
<IconSprite />
|
|
1043
1380
|
<div class="app-shell">
|
|
1044
1381
|
<Navbar signedIn={signedIn} active="entries" />
|
|
1045
1382
|
<main class="main">
|
|
1046
1383
|
<header class="page-header">
|
|
1047
|
-
<div class="
|
|
1048
|
-
|
|
1049
|
-
|
|
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>
|
|
1050
1389
|
</header>
|
|
1051
1390
|
<EditorView vm={vm} state={state} />
|
|
1391
|
+
<EditorNav prevId={vm.prevId} nextId={vm.nextId} />
|
|
1052
1392
|
{/* The one live region: announcements on constraint-state flips only, so a screen reader
|
|
1053
1393
|
is not flooded with a per-keystroke value. Lives outside the swap region. */}
|
|
1054
|
-
<span id="editor-live" class="
|
|
1394
|
+
<span id="editor-live" class="sr-only" aria-live="polite"></span>
|
|
1055
1395
|
</main>
|
|
1056
1396
|
</div>
|
|
1397
|
+
<PageModules />
|
|
1057
1398
|
</body>
|
|
1058
1399
|
</html>
|
|
1059
1400
|
);
|
|
@@ -1073,30 +1414,23 @@ export function editorViewFragment(vm: EditorViewModel, state: EditorState | und
|
|
|
1073
1414
|
export function notFoundPage(signedIn = false): JSX.Element {
|
|
1074
1415
|
return (
|
|
1075
1416
|
<html lang="en" data-theme="dark">
|
|
1076
|
-
<
|
|
1077
|
-
<meta charset="utf-8" />
|
|
1078
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1079
|
-
{/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
|
|
1080
|
-
<link rel="icon" href="data:," />
|
|
1081
|
-
<title>Entry not found · {NAME}</title>
|
|
1082
|
-
<link rel="stylesheet" href="/static/tokens.css" />
|
|
1083
|
-
<link rel="stylesheet" href="/static/components.css" />
|
|
1084
|
-
<link rel="stylesheet" href="/static/dashboard.css" />
|
|
1085
|
-
<script src="/static/htmx.min.js"></script>
|
|
1086
|
-
<script src="/static/dashboard.js" defer></script>
|
|
1087
|
-
</head>
|
|
1417
|
+
<PageHead title={`Entry not found · ${NAME}`} />
|
|
1088
1418
|
<body>
|
|
1419
|
+
<IconSprite />
|
|
1089
1420
|
<div class="app-shell">
|
|
1090
1421
|
<Navbar signedIn={signedIn} active="entries" />
|
|
1091
1422
|
<main class="main">
|
|
1092
1423
|
<header class="page-header">
|
|
1093
|
-
<div class="
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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>
|
|
1097
1429
|
</header>
|
|
1430
|
+
<a href="/" class="btn btn-secondary">Back to entries</a>
|
|
1098
1431
|
</main>
|
|
1099
1432
|
</div>
|
|
1433
|
+
<PageModules />
|
|
1100
1434
|
</body>
|
|
1101
1435
|
</html>
|
|
1102
1436
|
);
|
|
@@ -1182,11 +1516,8 @@ function sectionToForm(section: StyleGuideInput): StyleGuideForm {
|
|
|
1182
1516
|
function InfoCallout({ title, body }: { title: string; body?: string }) {
|
|
1183
1517
|
return (
|
|
1184
1518
|
<div class="callout callout-info">
|
|
1185
|
-
<div class="callout-icon"
|
|
1186
|
-
<svg
|
|
1187
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
1188
|
-
<path d="M8 7.5v3.5M8 5.5v.01" />
|
|
1189
|
-
</svg>
|
|
1519
|
+
<div class="callout-icon">
|
|
1520
|
+
<svg aria-hidden="true"><use href="#i-info" /></svg>
|
|
1190
1521
|
</div>
|
|
1191
1522
|
<div class="callout-content">
|
|
1192
1523
|
<div class="callout-title">{title}</div>
|
|
@@ -1246,11 +1577,8 @@ function StyleGuideRegion({ vm, state }: { vm: StyleGuideViewModel; state: Style
|
|
|
1246
1577
|
<div id="style-guide-region">
|
|
1247
1578
|
{state?.notice.kind === "saved" ? (
|
|
1248
1579
|
<div class="callout callout-success" role="status">
|
|
1249
|
-
<div class="callout-icon"
|
|
1250
|
-
<svg
|
|
1251
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
1252
|
-
<path d="M5 8l2 2 4-4" />
|
|
1253
|
-
</svg>
|
|
1580
|
+
<div class="callout-icon">
|
|
1581
|
+
<svg aria-hidden="true"><use href="#i-success" /></svg>
|
|
1254
1582
|
</div>
|
|
1255
1583
|
<div class="callout-content">
|
|
1256
1584
|
<div class="callout-title">Guide saved</div>
|
|
@@ -1325,11 +1653,8 @@ function StyleGuideErrorBoxTemplate() {
|
|
|
1325
1653
|
return (
|
|
1326
1654
|
<template id="style-guide-error-box">
|
|
1327
1655
|
<div class="callout callout-danger" role="alert">
|
|
1328
|
-
<div class="callout-icon"
|
|
1329
|
-
<svg
|
|
1330
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
1331
|
-
<path d="M10 6L6 10M6 6l4 4" />
|
|
1332
|
-
</svg>
|
|
1656
|
+
<div class="callout-icon">
|
|
1657
|
+
<svg aria-hidden="true"><use href="#i-error" /></svg>
|
|
1333
1658
|
</div>
|
|
1334
1659
|
<div class="callout-content">
|
|
1335
1660
|
<div class="callout-title">The guide could not be saved</div>
|
|
@@ -1351,32 +1676,25 @@ export function styleGuidePage(
|
|
|
1351
1676
|
): JSX.Element {
|
|
1352
1677
|
return (
|
|
1353
1678
|
<html lang="en" data-theme="dark">
|
|
1354
|
-
<
|
|
1355
|
-
<meta charset="utf-8" />
|
|
1356
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1357
|
-
{/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
|
|
1358
|
-
<link rel="icon" href="data:," />
|
|
1359
|
-
<title>Style guide · {NAME}</title>
|
|
1360
|
-
<link rel="stylesheet" href="/static/tokens.css" />
|
|
1361
|
-
<link rel="stylesheet" href="/static/components.css" />
|
|
1362
|
-
<link rel="stylesheet" href="/static/dashboard.css" />
|
|
1363
|
-
<script src="/static/htmx.min.js"></script>
|
|
1364
|
-
<script src="/static/dashboard.js" defer></script>
|
|
1365
|
-
</head>
|
|
1679
|
+
<PageHead title={`Style guide · ${NAME}`} />
|
|
1366
1680
|
<body>
|
|
1681
|
+
<IconSprite />
|
|
1367
1682
|
<div class="app-shell">
|
|
1368
1683
|
<Navbar signedIn={signedIn} active="style-guide" />
|
|
1369
1684
|
<main class="main">
|
|
1370
1685
|
<header class="page-header">
|
|
1371
|
-
<div class="
|
|
1372
|
-
|
|
1373
|
-
|
|
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>
|
|
1374
1691
|
</header>
|
|
1375
1692
|
<StyleGuideProjects vm={vm} />
|
|
1376
1693
|
<StyleGuideRegion vm={vm} state={state} />
|
|
1377
1694
|
<StyleGuideErrorBoxTemplate />
|
|
1378
1695
|
</main>
|
|
1379
1696
|
</div>
|
|
1697
|
+
<PageModules />
|
|
1380
1698
|
</body>
|
|
1381
1699
|
</html>
|
|
1382
1700
|
);
|