reviewzy 0.1.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 +30 -0
- package/LICENSE +661 -0
- package/README.md +170 -0
- package/bin/reviewzy +7 -0
- package/package.json +29 -0
- package/src/config.ts +76 -0
- package/src/daemon/app.ts +159 -0
- package/src/daemon/main.ts +103 -0
- package/src/daemon/origin.ts +60 -0
- package/src/dashboard/auth.ts +122 -0
- package/src/dashboard/mount.ts +373 -0
- package/src/dashboard/static/components.css +1116 -0
- package/src/dashboard/static/dashboard.css +182 -0
- package/src/dashboard/static/dashboard.js +169 -0
- package/src/dashboard/static/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/dashboard/static/fonts/OFL-Onest.txt +93 -0
- 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/htmx.min.js +2 -0
- package/src/dashboard/static/tokens.css +213 -0
- package/src/dashboard/views.tsx +1388 -0
- package/src/db/human-save.ts +242 -0
- package/src/db/migrate.ts +71 -0
- package/src/db/migrations.ts +149 -0
- package/src/db/queries.ts +425 -0
- package/src/db/store.ts +83 -0
- package/src/db/style-guide.ts +236 -0
- package/src/db/sweep.ts +88 -0
- package/src/mcp/await-approved.ts +156 -0
- package/src/mcp/fetch-approved.ts +132 -0
- package/src/mcp/file-entries.ts +219 -0
- package/src/mcp/list-entries.ts +107 -0
- package/src/mcp/mark-applied.ts +103 -0
- package/src/mcp/route.ts +88 -0
- package/src/mcp/server.ts +59 -0
- package/src/mcp/style-guide-resource.ts +40 -0
- package/src/notify.ts +142 -0
- package/src/shim/daemon.ts +318 -0
- package/src/shim/lockfile.ts +153 -0
- package/src/shim/main.ts +172 -0
- package/src/shim/semver.ts +29 -0
- package/src/version.ts +4 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,1388 @@
|
|
|
1
|
+
/** @jsxImportSource hono/jsx */
|
|
2
|
+
import type { JSX } from "hono/jsx/jsx-runtime";
|
|
3
|
+
import { listRevisions, parseConstraints } from "../db/human-save.ts";
|
|
4
|
+
import type { Constraints, HumanSaveRefusal, RevisionRow, TransitionRefusal } from "../db/human-save.ts";
|
|
5
|
+
import {
|
|
6
|
+
countEntries,
|
|
7
|
+
listEntries,
|
|
8
|
+
listProjects,
|
|
9
|
+
projectIdBySlug,
|
|
10
|
+
} from "../db/queries.ts";
|
|
11
|
+
import type { EntryRow, EntryStatus } from "../db/queries.ts";
|
|
12
|
+
import { readStyleGuideSection } from "../db/style-guide.ts";
|
|
13
|
+
import type { StyleGuideForm, StyleGuideFormRefusal, StyleGuideInput } from "../db/style-guide.ts";
|
|
14
|
+
import type { Store } from "../db/store.ts";
|
|
15
|
+
import { NAME, VERSION } from "../version.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The three dashboard filters, straight off the query string. `status` and `project` are the raw
|
|
19
|
+
* values: an unrecognized one is a filter that cannot match and renders the no-match state rather
|
|
20
|
+
* than being silently dropped, so the page never contradicts the URL it was asked for.
|
|
21
|
+
*/
|
|
22
|
+
export type ListParams = {
|
|
23
|
+
readonly q: string | undefined;
|
|
24
|
+
readonly status: string | undefined;
|
|
25
|
+
readonly project: string | undefined;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The list entries are walked in pages of this many rows; the dashboard has no pagination UI, so the walk always runs to exhaustion. */
|
|
29
|
+
const PAGE_SIZE = 200;
|
|
30
|
+
|
|
31
|
+
const STATUSES: readonly EntryStatus[] = ["draft", "approved", "applied", "rejected"];
|
|
32
|
+
|
|
33
|
+
function isEntryStatus(value: string): value is EntryStatus {
|
|
34
|
+
return (STATUSES as readonly string[]).includes(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The status tag class and label; the four statuses map one-to-one onto the four tag semantics. */
|
|
38
|
+
const STATUS_TAG: Record<EntryStatus, string> = {
|
|
39
|
+
draft: "tag-warning",
|
|
40
|
+
approved: "tag-success",
|
|
41
|
+
applied: "tag-info",
|
|
42
|
+
rejected: "tag-danger",
|
|
43
|
+
};
|
|
44
|
+
const STATUS_LABEL: Record<EntryStatus, string> = {
|
|
45
|
+
draft: "Draft",
|
|
46
|
+
approved: "Approved",
|
|
47
|
+
applied: "Applied",
|
|
48
|
+
rejected: "Rejected",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type ProjectGroup = { slug: string; batches: { id: string; entries: EntryRow[] }[] };
|
|
52
|
+
|
|
53
|
+
export type ListViewModel = {
|
|
54
|
+
readonly total: number;
|
|
55
|
+
readonly shown: number;
|
|
56
|
+
readonly groups: readonly ProjectGroup[];
|
|
57
|
+
readonly projects: readonly { id: string; slug: string }[];
|
|
58
|
+
/** The raw filter values, so the form keeps what the URL asked for. */
|
|
59
|
+
readonly q: string;
|
|
60
|
+
readonly status: string;
|
|
61
|
+
readonly project: string;
|
|
62
|
+
readonly filtersActive: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Runs the keyset walk to exhaustion: the mcp tool's own list query, page size 200, no pagination UI. */
|
|
66
|
+
function allEntries(
|
|
67
|
+
store: Store,
|
|
68
|
+
filter: { q: string | undefined; status: EntryStatus | undefined; projectId: string | undefined },
|
|
69
|
+
): EntryRow[] {
|
|
70
|
+
const rows: EntryRow[] = [];
|
|
71
|
+
let cursor: string | undefined;
|
|
72
|
+
for (;;) {
|
|
73
|
+
const page = listEntries(store, {
|
|
74
|
+
projectId: filter.projectId,
|
|
75
|
+
status: filter.status,
|
|
76
|
+
ids: undefined,
|
|
77
|
+
q: filter.q,
|
|
78
|
+
cursor,
|
|
79
|
+
limit: PAGE_SIZE,
|
|
80
|
+
});
|
|
81
|
+
rows.push(...page.rows);
|
|
82
|
+
if (page.nextCursor === null) return rows;
|
|
83
|
+
cursor = page.nextCursor;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Groups the filtered rows by project (slug order) then batch (batch_id order); within a batch the
|
|
89
|
+
* rows keep the list query's ascending id order. The group order is deliberate: slugs and ulids
|
|
90
|
+
* sort deterministically, so the same filter always renders the same page.
|
|
91
|
+
*/
|
|
92
|
+
function groupRows(rows: readonly EntryRow[], slugOf: Map<string, string>): ProjectGroup[] {
|
|
93
|
+
const byProject = new Map<string, EntryRow[]>();
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
byProject.set(row.project_id, [...(byProject.get(row.project_id) ?? []), row]);
|
|
96
|
+
}
|
|
97
|
+
const projectIds = [...byProject.keys()].sort((a, b) => (slugOf.get(a) ?? a).localeCompare(slugOf.get(b) ?? b));
|
|
98
|
+
return projectIds.map((projectId) => {
|
|
99
|
+
const projectRows = byProject.get(projectId)!;
|
|
100
|
+
const byBatch = new Map<string, EntryRow[]>();
|
|
101
|
+
for (const row of projectRows) {
|
|
102
|
+
byBatch.set(row.batch_id, [...(byBatch.get(row.batch_id) ?? []), row]);
|
|
103
|
+
}
|
|
104
|
+
const batches = [...byBatch.keys()].sort().map((batchId) => ({ id: batchId, entries: byBatch.get(batchId)! }));
|
|
105
|
+
return { slug: slugOf.get(projectId) ?? projectId, batches };
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function loadList(store: Store, params: ListParams): ListViewModel {
|
|
110
|
+
const q = params.q;
|
|
111
|
+
const qActive = q !== undefined && q.trim() !== "";
|
|
112
|
+
const statusRaw = params.status;
|
|
113
|
+
const statusActive = statusRaw !== undefined && statusRaw !== "";
|
|
114
|
+
const status = statusActive && isEntryStatus(statusRaw) ? statusRaw : undefined;
|
|
115
|
+
const projectRaw = params.project;
|
|
116
|
+
const projectActive = projectRaw !== undefined && projectRaw !== "";
|
|
117
|
+
const projectId = projectActive ? (projectIdBySlug(store, projectRaw) ?? undefined) : undefined;
|
|
118
|
+
|
|
119
|
+
// 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 rows = impossible ? [] : allEntries(store, { q: qActive ? q : undefined, status, projectId });
|
|
122
|
+
|
|
123
|
+
const projects = listProjects(store);
|
|
124
|
+
const slugOf = new Map(projects.map((project) => [project.id, project.slug]));
|
|
125
|
+
return {
|
|
126
|
+
total: countEntries(store),
|
|
127
|
+
shown: rows.length,
|
|
128
|
+
groups: groupRows(rows, slugOf),
|
|
129
|
+
projects,
|
|
130
|
+
q: q ?? "",
|
|
131
|
+
status: statusRaw ?? "",
|
|
132
|
+
project: projectRaw ?? "",
|
|
133
|
+
filtersActive: qActive || statusActive || projectActive,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The constraint summary cell: max_len and placeholders from the stored constraints JSON, mono, middot-joined. */
|
|
138
|
+
function constraintSummary(constraintsJson: string): string[] {
|
|
139
|
+
let parsed: unknown;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(constraintsJson);
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
if (typeof parsed !== "object" || parsed === null) return [];
|
|
146
|
+
const constraints = parsed as Record<string, unknown>;
|
|
147
|
+
const parts: string[] = [];
|
|
148
|
+
if (typeof constraints.max_len === "number") parts.push(`max ${constraints.max_len}`);
|
|
149
|
+
if (Array.isArray(constraints.placeholders)) {
|
|
150
|
+
for (const placeholder of constraints.placeholders) {
|
|
151
|
+
if (typeof placeholder === "string" && placeholder !== "") parts.push(`{${placeholder}}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return parts;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The section the current page belongs to, marking the matching navbar link. */
|
|
158
|
+
type NavSection = "entries" | "style-guide" | "none";
|
|
159
|
+
|
|
160
|
+
function Navbar({ signedIn, active }: { signedIn: boolean; active: NavSection }) {
|
|
161
|
+
return (
|
|
162
|
+
<nav class="navbar">
|
|
163
|
+
<div class="navbar-brand">
|
|
164
|
+
<div class="logo-name">{NAME}</div>
|
|
165
|
+
<div class="logo-version">v{VERSION}</div>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="navbar-nav" id="navbar-nav">
|
|
168
|
+
<a class={`navbar-link${active === "entries" ? " active" : ""}`} href="/">Entries</a>
|
|
169
|
+
<a class={`navbar-link${active === "style-guide" ? " active" : ""}`} href="/style-guide">Style guide</a>
|
|
170
|
+
<div class="navbar-ink" id="navbar-ink"></div>
|
|
171
|
+
</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
|
+
</nav>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function PageHeader() {
|
|
182
|
+
return (
|
|
183
|
+
<header class="page-header">
|
|
184
|
+
<div class="label page-eyebrow">Review queue</div>
|
|
185
|
+
<h1 class="page-title">Entries</h1>
|
|
186
|
+
<p class="page-lede">Agent drafts waiting for your approval.</p>
|
|
187
|
+
</header>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function FilterForm({ vm }: { vm: ListViewModel }) {
|
|
192
|
+
return (
|
|
193
|
+
<form
|
|
194
|
+
id="filters"
|
|
195
|
+
method="get"
|
|
196
|
+
action="/"
|
|
197
|
+
hx-get="/"
|
|
198
|
+
hx-target="#entries-list"
|
|
199
|
+
hx-swap="innerHTML"
|
|
200
|
+
hx-indicator="#entries-loading"
|
|
201
|
+
class="filters"
|
|
202
|
+
>
|
|
203
|
+
<div class="field">
|
|
204
|
+
<label class="field-label" for="q">Search</label>
|
|
205
|
+
<input
|
|
206
|
+
class="input"
|
|
207
|
+
id="q"
|
|
208
|
+
name="q"
|
|
209
|
+
type="search"
|
|
210
|
+
placeholder="Search file, anchor, or text"
|
|
211
|
+
value={vm.q}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
<div class="field">
|
|
215
|
+
<label class="field-label" for="status">Status</label>
|
|
216
|
+
<select class="input" id="status" name="status">
|
|
217
|
+
<option value="">All statuses</option>
|
|
218
|
+
<option value="draft" selected={vm.status === "draft"}>Draft</option>
|
|
219
|
+
<option value="approved" selected={vm.status === "approved"}>Approved</option>
|
|
220
|
+
<option value="applied" selected={vm.status === "applied"}>Applied</option>
|
|
221
|
+
<option value="rejected" selected={vm.status === "rejected"}>Rejected</option>
|
|
222
|
+
</select>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="field">
|
|
225
|
+
<label class="field-label" for="project">Project</label>
|
|
226
|
+
<select class="input" id="project" name="project">
|
|
227
|
+
<option value="">All projects</option>
|
|
228
|
+
{vm.projects.map((project) => (
|
|
229
|
+
<option value={project.slug} selected={vm.project === project.slug}>
|
|
230
|
+
{project.slug}
|
|
231
|
+
</option>
|
|
232
|
+
))}
|
|
233
|
+
</select>
|
|
234
|
+
</div>
|
|
235
|
+
<button class="btn btn-primary" type="submit">Apply filters</button>
|
|
236
|
+
</form>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* What an action round settles into, rendered above the list region: a single refusal (one entry
|
|
242
|
+
* could not move, why, and the fix) or the batch report (how many moved, each refusal named).
|
|
243
|
+
* The route layer builds these from the store's outcomes; the view owns the shapes.
|
|
244
|
+
*/
|
|
245
|
+
export type ListNotice =
|
|
246
|
+
| { readonly kind: "refusal"; readonly title: string; readonly message: string }
|
|
247
|
+
| { readonly kind: "batch"; readonly approved: number; readonly total: number; readonly refused: readonly string[] };
|
|
248
|
+
|
|
249
|
+
/** The settled rendering of a notice: a success panel when a batch fully moved, an alert otherwise. */
|
|
250
|
+
function NoticeCallout({ notice }: { notice: ListNotice }) {
|
|
251
|
+
if (notice.kind === "refusal") {
|
|
252
|
+
return <DangerCallout title={notice.title} body={notice.message} />;
|
|
253
|
+
}
|
|
254
|
+
if (notice.refused.length === 0) {
|
|
255
|
+
return (
|
|
256
|
+
<div class="callout callout-success" role="status">
|
|
257
|
+
<div class="callout-icon" style="color: var(--success)">
|
|
258
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
259
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
260
|
+
<path d="M5 8l2 2 4-4" />
|
|
261
|
+
</svg>
|
|
262
|
+
</div>
|
|
263
|
+
<div class="callout-content">
|
|
264
|
+
<div class="callout-title">
|
|
265
|
+
Approved {notice.approved} {notice.approved === 1 ? "entry" : "entries"}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return (
|
|
272
|
+
<div class="callout callout-danger" role="alert">
|
|
273
|
+
<div class="callout-icon" style="color: var(--danger)">
|
|
274
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
275
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
276
|
+
<path d="M10 6L6 10M6 6l4 4" />
|
|
277
|
+
</svg>
|
|
278
|
+
</div>
|
|
279
|
+
<div class="callout-content">
|
|
280
|
+
<div class="callout-title">
|
|
281
|
+
Approved {notice.approved} of {notice.total} entries
|
|
282
|
+
</div>
|
|
283
|
+
<ul class="notice-list">
|
|
284
|
+
{notice.refused.map((message) => (
|
|
285
|
+
<li key={message}>{message}</li>
|
|
286
|
+
))}
|
|
287
|
+
</ul>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The settled list region: success, empty, or partial, with an action round's notice on top. The loading and error states are client-side. */
|
|
294
|
+
function ListRegion({ vm, notice }: { vm: ListViewModel; notice: ListNotice | undefined }) {
|
|
295
|
+
if (vm.shown === 0) {
|
|
296
|
+
// 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. Only a store with entries can have matches.
|
|
298
|
+
return (
|
|
299
|
+
<>
|
|
300
|
+
{notice !== undefined ? <NoticeCallout notice={notice} /> : null}
|
|
301
|
+
{vm.total === 0 ? <EmptyAll /> : <EmptyMatch />}
|
|
302
|
+
</>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return (
|
|
306
|
+
<>
|
|
307
|
+
{notice !== undefined ? <NoticeCallout notice={notice} /> : null}
|
|
308
|
+
{vm.filtersActive && vm.shown < vm.total ? <PartialHeader shown={vm.shown} total={vm.total} /> : null}
|
|
309
|
+
<BatchForm vm={vm} />
|
|
310
|
+
</>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function PartialHeader({ shown, total }: { shown: number; total: number }) {
|
|
315
|
+
return (
|
|
316
|
+
<div class="partial-header">
|
|
317
|
+
<span class="partial-count">
|
|
318
|
+
Showing {shown} of {total} entries
|
|
319
|
+
</span>
|
|
320
|
+
<a href="/" class="btn btn-secondary btn-sm">Clear filters</a>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function EmptyAll() {
|
|
326
|
+
return (
|
|
327
|
+
<div class="card empty-state">
|
|
328
|
+
<div class="empty-icon" aria-hidden="true">
|
|
329
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
330
|
+
<rect x="1.5" y="2.5" width="13" height="11" />
|
|
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>
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function EmptyMatch() {
|
|
341
|
+
return (
|
|
342
|
+
<div class="card empty-state">
|
|
343
|
+
<div class="empty-icon" aria-hidden="true">
|
|
344
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
345
|
+
<circle cx="6.5" cy="6.5" r="4.5" />
|
|
346
|
+
<path d="M11 11l3 3" />
|
|
347
|
+
</svg>
|
|
348
|
+
</div>
|
|
349
|
+
<h3>No entries match</h3>
|
|
350
|
+
<p>Try different search terms, or clear the filters.</p>
|
|
351
|
+
<a href="/" class="btn btn-secondary btn-sm">Clear filters</a>
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The batch approve form wrapping the whole list region: the checkboxes post as `id`, the hidden
|
|
358
|
+
* inputs carry the active filters so an action round re-renders the region under the same filter
|
|
359
|
+
* (htmx includes the closest form's inputs for the row buttons too), and the native action keeps
|
|
360
|
+
* no-JS batch approve working. One form per page, never nested: the filters form lives outside the
|
|
361
|
+
* swap region.
|
|
362
|
+
*/
|
|
363
|
+
function BatchForm({ vm }: { vm: ListViewModel }) {
|
|
364
|
+
return (
|
|
365
|
+
<form
|
|
366
|
+
id="batch-form"
|
|
367
|
+
method="post"
|
|
368
|
+
action="/batch-approve"
|
|
369
|
+
hx-post="/batch-approve"
|
|
370
|
+
hx-target="#entries-list"
|
|
371
|
+
hx-swap="innerHTML"
|
|
372
|
+
hx-indicator="#entries-loading"
|
|
373
|
+
>
|
|
374
|
+
<input type="hidden" name="q" value={vm.q} />
|
|
375
|
+
<input type="hidden" name="status" value={vm.status} />
|
|
376
|
+
<input type="hidden" name="project" value={vm.project} />
|
|
377
|
+
<div class="batch-bar">
|
|
378
|
+
<button class="btn btn-primary btn-sm" type="submit" hx-disabled-elt="this">
|
|
379
|
+
Approve selected
|
|
380
|
+
</button>
|
|
381
|
+
</div>
|
|
382
|
+
{vm.groups.map((group) => (
|
|
383
|
+
<ProjectGroupView key={group.slug} group={group} />
|
|
384
|
+
))}
|
|
385
|
+
</form>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function ProjectGroupView({ group }: { group: ProjectGroup }) {
|
|
390
|
+
const count = group.batches.reduce((n, batch) => n + batch.entries.length, 0);
|
|
391
|
+
return (
|
|
392
|
+
<section class="project-group">
|
|
393
|
+
<header class="project-header">
|
|
394
|
+
<h2 class="project-name">{group.slug}</h2>
|
|
395
|
+
<span class="tag tag-data">
|
|
396
|
+
{count} {count === 1 ? "entry" : "entries"}
|
|
397
|
+
</span>
|
|
398
|
+
</header>
|
|
399
|
+
{group.batches.map((batch) => (
|
|
400
|
+
<div class="batch" key={batch.id}>
|
|
401
|
+
<div class="batch-header">
|
|
402
|
+
<span class="label">Batch</span>
|
|
403
|
+
<span class="batch-id">{batch.id}</span>
|
|
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
|
+
))}
|
|
428
|
+
</section>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* One row. The checkbox names the entry for the batch form; it exists only on drafts, because an
|
|
434
|
+
* approve on any other status always refuses and a checkbox that can only refuse is noise (races
|
|
435
|
+
* are still reported per entry). The action buttons carry the row's own approve and reject,
|
|
436
|
+
* targeting the list region; their requests include the batch form's hidden filters, so an action
|
|
437
|
+
* round re-renders the region under the same filter. Applied and rejected rows are terminal from
|
|
438
|
+
* the dashboard side and offer only the editor.
|
|
439
|
+
*/
|
|
440
|
+
function EntryRowView({ entry }: { entry: EntryRow }) {
|
|
441
|
+
const text = entry.human_text ?? entry.agent_draft;
|
|
442
|
+
const constraints = constraintSummary(entry.constraints);
|
|
443
|
+
const approve = entry.status === "draft";
|
|
444
|
+
const reject = entry.status === "draft" || entry.status === "approved";
|
|
445
|
+
return (
|
|
446
|
+
<tr>
|
|
447
|
+
<td class="cell-select">
|
|
448
|
+
{approve ? (
|
|
449
|
+
<input type="checkbox" name="id" value={entry.id} aria-label={`Select ${entry.file}`} />
|
|
450
|
+
) : null}
|
|
451
|
+
</td>
|
|
452
|
+
<td class="cell-file">
|
|
453
|
+
<span class="file-path" title={entry.file}>{entry.file}</span>
|
|
454
|
+
</td>
|
|
455
|
+
<td class="cell-anchor">
|
|
456
|
+
<span class="anchor-text" title={entry.anchor_text}>{entry.anchor_text}</span>
|
|
457
|
+
</td>
|
|
458
|
+
<td class="cell-text">
|
|
459
|
+
<span class="entry-text" title={text ?? ""}>{text}</span>
|
|
460
|
+
</td>
|
|
461
|
+
<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}
|
|
470
|
+
</td>
|
|
471
|
+
<td class="cell-actions">
|
|
472
|
+
{approve ? (
|
|
473
|
+
<button
|
|
474
|
+
type="button"
|
|
475
|
+
class="btn btn-primary btn-sm"
|
|
476
|
+
hx-post={`/entries/${entry.id}/approve`}
|
|
477
|
+
hx-target="#entries-list"
|
|
478
|
+
hx-swap="innerHTML"
|
|
479
|
+
hx-indicator="#entries-loading"
|
|
480
|
+
hx-disabled-elt="this"
|
|
481
|
+
>
|
|
482
|
+
Approve
|
|
483
|
+
</button>
|
|
484
|
+
) : entry.status === "approved" ? (
|
|
485
|
+
// The approve slot stays occupied for approved rows: a double-click's second click lands
|
|
486
|
+
// where Approve was, and the disabled button (pointer-events none) swallows it before it
|
|
487
|
+
// can hit the Reject button that shifted into the slot.
|
|
488
|
+
<button type="button" class="btn btn-primary btn-sm" disabled>
|
|
489
|
+
Approve
|
|
490
|
+
</button>
|
|
491
|
+
) : null}
|
|
492
|
+
{reject ? (
|
|
493
|
+
<button
|
|
494
|
+
type="button"
|
|
495
|
+
class="btn btn-danger btn-sm"
|
|
496
|
+
hx-post={`/entries/${entry.id}/reject`}
|
|
497
|
+
hx-target="#entries-list"
|
|
498
|
+
hx-swap="innerHTML"
|
|
499
|
+
hx-indicator="#entries-loading"
|
|
500
|
+
hx-disabled-elt="this"
|
|
501
|
+
>
|
|
502
|
+
Reject
|
|
503
|
+
</button>
|
|
504
|
+
) : null}
|
|
505
|
+
<a class="btn btn-secondary btn-sm" href={`/entries/${entry.id}`}>Edit</a>
|
|
506
|
+
</td>
|
|
507
|
+
</tr>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** The error box htmx failures clone into the list region; the retry button re-submits the form. */
|
|
512
|
+
function ErrorBoxTemplate() {
|
|
513
|
+
return (
|
|
514
|
+
<template id="error-box">
|
|
515
|
+
<div class="callout callout-danger" role="alert">
|
|
516
|
+
<div class="callout-icon" style="color: var(--danger)">
|
|
517
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
518
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
519
|
+
<path d="M10 6L6 10M6 6l4 4" />
|
|
520
|
+
</svg>
|
|
521
|
+
</div>
|
|
522
|
+
<div class="callout-content">
|
|
523
|
+
<div class="callout-title">The list could not be loaded</div>
|
|
524
|
+
<div class="callout-body">The filter request failed. Try it again.</div>
|
|
525
|
+
<div class="callout-actions">
|
|
526
|
+
<button class="btn btn-secondary btn-sm" type="button" data-retry>Retry</button>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
</template>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The full page for a plain navigation; the mount wraps it in the doctype. `signedIn` is whether the navbar offers sign-out. */
|
|
535
|
+
export function dashboardPage(
|
|
536
|
+
store: Store,
|
|
537
|
+
params: ListParams,
|
|
538
|
+
notice: ListNotice | undefined = undefined,
|
|
539
|
+
signedIn = false,
|
|
540
|
+
): JSX.Element {
|
|
541
|
+
const vm = loadList(store, params);
|
|
542
|
+
return (
|
|
543
|
+
<html lang="en" data-theme="dark">
|
|
544
|
+
<head>
|
|
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>
|
|
556
|
+
<body>
|
|
557
|
+
<div class="app-shell">
|
|
558
|
+
<Navbar signedIn={signedIn} active="entries" />
|
|
559
|
+
<main class="main">
|
|
560
|
+
<PageHeader />
|
|
561
|
+
<FilterForm vm={vm} />
|
|
562
|
+
<div id="entries">
|
|
563
|
+
<div id="entries-loading" class="htmx-indicator" role="status">
|
|
564
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
565
|
+
Loading entries
|
|
566
|
+
</div>
|
|
567
|
+
<div id="entries-list">
|
|
568
|
+
<ListRegion vm={vm} notice={notice} />
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
<ErrorBoxTemplate />
|
|
572
|
+
</main>
|
|
573
|
+
</div>
|
|
574
|
+
</body>
|
|
575
|
+
</html>
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* The login page: the navbar shell, the page-header triplet, and one centered card holding the
|
|
581
|
+
* password field. `error` is the single failure message, shown as an alert callout; the field
|
|
582
|
+
* itself is always cleared, and `next` rides along as a hidden input so a successful retry still
|
|
583
|
+
* lands where the visitor was going.
|
|
584
|
+
*/
|
|
585
|
+
export function loginPage(next: string | undefined, error: string | undefined = undefined): JSX.Element {
|
|
586
|
+
return (
|
|
587
|
+
<html lang="en" data-theme="dark">
|
|
588
|
+
<head>
|
|
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>
|
|
600
|
+
<body>
|
|
601
|
+
<div class="app-shell">
|
|
602
|
+
<Navbar signedIn={false} active="none" />
|
|
603
|
+
<main class="main login-main">
|
|
604
|
+
<header class="page-header">
|
|
605
|
+
<div class="label page-eyebrow">Dashboard</div>
|
|
606
|
+
<h1 class="page-title">Sign in</h1>
|
|
607
|
+
<p class="page-lede">The dashboard is password protected. Enter the password to continue.</p>
|
|
608
|
+
</header>
|
|
609
|
+
{error !== undefined ? <DangerCallout title={error} /> : null}
|
|
610
|
+
<form method="post" action="/login" class="card login-card">
|
|
611
|
+
{next !== undefined ? <input type="hidden" name="next" value={next} /> : null}
|
|
612
|
+
<div class="card-content">
|
|
613
|
+
<div class="field">
|
|
614
|
+
<label class="field-label" for="password">Password</label>
|
|
615
|
+
<input
|
|
616
|
+
class="input"
|
|
617
|
+
id="password"
|
|
618
|
+
name="password"
|
|
619
|
+
type="password"
|
|
620
|
+
autocomplete="current-password"
|
|
621
|
+
/>
|
|
622
|
+
</div>
|
|
623
|
+
<div class="login-actions">
|
|
624
|
+
<button class="btn btn-primary" type="submit">Sign in</button>
|
|
625
|
+
</div>
|
|
626
|
+
</div>
|
|
627
|
+
</form>
|
|
628
|
+
</main>
|
|
629
|
+
</div>
|
|
630
|
+
</body>
|
|
631
|
+
</html>
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** The list region only, for htmx requests; the loading strip and the error template stay in the page. */
|
|
636
|
+
export function entriesFragment(store: Store, params: ListParams, notice: ListNotice | undefined = undefined): JSX.Element {
|
|
637
|
+
return <ListRegion vm={loadList(store, params)} notice={notice} />;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---- Entry editor ----
|
|
641
|
+
|
|
642
|
+
export type EditorViewModel = {
|
|
643
|
+
readonly entry: EntryRow;
|
|
644
|
+
readonly revisions: readonly RevisionRow[];
|
|
645
|
+
readonly constraints: Constraints;
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* What the last save or transition round rendered the editor with. `submittedText` is the text the
|
|
650
|
+
* user submitted (a refusal must keep it in the textarea), and `refusal` is the formatted title and
|
|
651
|
+
* message when the last round was refused (a save, an approve, or a reject each name their own
|
|
652
|
+
* failure). Both are present so a swap never has to guess which one exists.
|
|
653
|
+
*/
|
|
654
|
+
export type EditorState = {
|
|
655
|
+
readonly submittedText: string;
|
|
656
|
+
readonly refusal: { readonly title: string; readonly body: string } | undefined;
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
/** The one entry the editor edits, by the same keyset query the list uses; `undefined` when the id is unknown. */
|
|
660
|
+
export function getEntry(store: Store, id: string): EntryRow | undefined {
|
|
661
|
+
return listEntries(store, { projectId: undefined, status: undefined, ids: [id], q: undefined, limit: 1, cursor: undefined })
|
|
662
|
+
.rows[0];
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export function loadEditor(store: Store, id: string): EditorViewModel | null {
|
|
666
|
+
const entry = getEntry(store, id);
|
|
667
|
+
if (entry === undefined) return null;
|
|
668
|
+
return { entry, revisions: listRevisions(store, id), constraints: parseConstraints(entry.constraints) };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** The tolerant parse for `entries.context`, mirroring `parseConstraints`: foreign rows may hold malformed JSON, and unparseable means no context rows, never a 500. */
|
|
672
|
+
function parseContext(json: string): Record<string, unknown> {
|
|
673
|
+
let parsed: unknown;
|
|
674
|
+
try {
|
|
675
|
+
parsed = JSON.parse(json);
|
|
676
|
+
} catch {
|
|
677
|
+
return {};
|
|
678
|
+
}
|
|
679
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
680
|
+
return parsed as Record<string, unknown>;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* The user-facing refusal messages, in the dashboard's own voice: name what failed, then the fix.
|
|
685
|
+
* The store's refusal carries the values; only this function turns them into prose.
|
|
686
|
+
*/
|
|
687
|
+
export function formatSaveRefusal(refusal: Exclude<HumanSaveRefusal, { kind: "unknown" }>): string {
|
|
688
|
+
switch (refusal.kind) {
|
|
689
|
+
case "max_len":
|
|
690
|
+
return `This text is ${refusal.length} characters; the length limit is ${refusal.maxLen}. Shorten it to ${refusal.maxLen} characters or fewer.`;
|
|
691
|
+
case "placeholder":
|
|
692
|
+
return `The text is missing the placeholder {${refusal.placeholder}}. Add {${refusal.placeholder}} to the text and save again.`;
|
|
693
|
+
case "empty":
|
|
694
|
+
return "Nothing to save. Enter text, or reject the entry.";
|
|
695
|
+
case "status":
|
|
696
|
+
return refusal.status === "applied"
|
|
697
|
+
? "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 line still needs changing.";
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The user-facing refusal messages for the approve and reject transitions, in the dashboard's own
|
|
704
|
+
* voice: name the entry and what failed, then the fix. Both transitions share the refusal kinds;
|
|
705
|
+
* the message spells the verb each one refused.
|
|
706
|
+
*/
|
|
707
|
+
export function formatTransitionRefusal(
|
|
708
|
+
action: "approve" | "reject",
|
|
709
|
+
refusal: Exclude<TransitionRefusal, { kind: "unknown" }>,
|
|
710
|
+
entryFile: string,
|
|
711
|
+
): string {
|
|
712
|
+
switch (refusal.kind) {
|
|
713
|
+
case "no_draft":
|
|
714
|
+
return `Entry ${entryFile} has no draft to approve. Open the editor to write the text.`;
|
|
715
|
+
case "status":
|
|
716
|
+
switch (refusal.status) {
|
|
717
|
+
case "approved":
|
|
718
|
+
return `Entry ${entryFile} is already approved, so there's nothing to accept. Open the editor to revise its text.`;
|
|
719
|
+
case "applied":
|
|
720
|
+
return action === "approve"
|
|
721
|
+
? `Entry ${entryFile} is already applied, so it can't be approved. If the anchor goes stale, the entry returns to approved.`
|
|
722
|
+
: `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
|
+
case "rejected":
|
|
724
|
+
return `Entry ${entryFile} was rejected, and a rejected entry stays rejected. File a new entry if the line still needs changing.`;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function DangerCallout({ title, body }: { title: string; body?: string }) {
|
|
730
|
+
return (
|
|
731
|
+
<div class="callout callout-danger" role="alert">
|
|
732
|
+
<div class="callout-icon" style="color: var(--danger)">
|
|
733
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
734
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
735
|
+
<path d="M10 6L6 10M6 6l4 4" />
|
|
736
|
+
</svg>
|
|
737
|
+
</div>
|
|
738
|
+
<div class="callout-content">
|
|
739
|
+
<div class="callout-title">{title}</div>
|
|
740
|
+
{body !== undefined ? <div class="callout-body">{body}</div> : null}
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* The editor region, swapped wholesale by a save or a transition (form + status tag + history +
|
|
748
|
+
* any refusal). The textarea carries `hx-preserve`, so a swap keeps the user's focus and typing
|
|
749
|
+
* while the server-rendered value stays what the last save left: the submitted text on a refusal.
|
|
750
|
+
* The `view` marker tells the approve and reject routes which region to answer with: this region,
|
|
751
|
+
* or the list's. The buttons carry htmx only; the save button keeps the native submit.
|
|
752
|
+
*/
|
|
753
|
+
function EditorRegion({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
|
|
754
|
+
const text = state?.submittedText ?? vm.entry.human_text ?? vm.entry.agent_draft ?? "";
|
|
755
|
+
const approve = vm.entry.status === "draft";
|
|
756
|
+
const reject = vm.entry.status === "draft" || vm.entry.status === "approved";
|
|
757
|
+
return (
|
|
758
|
+
<div id="editor-region">
|
|
759
|
+
{state?.refusal !== undefined ? (
|
|
760
|
+
<DangerCallout title={state.refusal.title} body={state.refusal.body} />
|
|
761
|
+
) : null}
|
|
762
|
+
<form
|
|
763
|
+
id="editor-form"
|
|
764
|
+
class="card"
|
|
765
|
+
method="post"
|
|
766
|
+
action={`/entries/${vm.entry.id}/save`}
|
|
767
|
+
hx-post={`/entries/${vm.entry.id}/save`}
|
|
768
|
+
hx-target="#editor-view"
|
|
769
|
+
hx-swap="outerHTML"
|
|
770
|
+
>
|
|
771
|
+
<input type="hidden" name="view" value="editor" />
|
|
772
|
+
<div class="card-header">
|
|
773
|
+
<div class="card-title">Text</div>
|
|
774
|
+
<span class={`tag ${STATUS_TAG[vm.entry.status]}`}>
|
|
775
|
+
<span class="tag-dot"></span>
|
|
776
|
+
{STATUS_LABEL[vm.entry.status]}
|
|
777
|
+
</span>
|
|
778
|
+
</div>
|
|
779
|
+
<div class="card-content">
|
|
780
|
+
<textarea id="editor-text" class="input" name="text" rows={10} aria-label="Entry text" hx-preserve="true">
|
|
781
|
+
{text}
|
|
782
|
+
</textarea>
|
|
783
|
+
<div class="editor-actions">
|
|
784
|
+
<button class="btn btn-primary" type="submit">Save text</button>
|
|
785
|
+
{approve ? (
|
|
786
|
+
<button
|
|
787
|
+
type="button"
|
|
788
|
+
class="btn btn-secondary"
|
|
789
|
+
hx-post={`/entries/${vm.entry.id}/approve`}
|
|
790
|
+
hx-target="#editor-region"
|
|
791
|
+
hx-swap="outerHTML"
|
|
792
|
+
hx-indicator="#editor-action-loading"
|
|
793
|
+
hx-disabled-elt="this"
|
|
794
|
+
>
|
|
795
|
+
Approve draft
|
|
796
|
+
</button>
|
|
797
|
+
) : vm.entry.status === "approved" ? (
|
|
798
|
+
// Same slot-occupancy guard as the list row: the second click of a double-click on
|
|
799
|
+
// the just-approving button would otherwise land on the Reject button that shifts
|
|
800
|
+
// into this slot in the swapped region.
|
|
801
|
+
<button type="button" class="btn btn-secondary" disabled>
|
|
802
|
+
Approve draft
|
|
803
|
+
</button>
|
|
804
|
+
) : null}
|
|
805
|
+
{reject ? (
|
|
806
|
+
<button
|
|
807
|
+
type="button"
|
|
808
|
+
class="btn btn-danger"
|
|
809
|
+
hx-post={`/entries/${vm.entry.id}/reject`}
|
|
810
|
+
hx-target="#editor-region"
|
|
811
|
+
hx-swap="outerHTML"
|
|
812
|
+
hx-indicator="#editor-action-loading"
|
|
813
|
+
hx-disabled-elt="this"
|
|
814
|
+
>
|
|
815
|
+
Reject
|
|
816
|
+
</button>
|
|
817
|
+
) : null}
|
|
818
|
+
<span id="editor-action-loading" class="htmx-indicator" role="status">
|
|
819
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
820
|
+
Updating entry
|
|
821
|
+
</span>
|
|
822
|
+
</div>
|
|
823
|
+
</div>
|
|
824
|
+
</form>
|
|
825
|
+
<RevisionHistory revisions={vm.revisions} />
|
|
826
|
+
</div>
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function RevisionHistory({ revisions }: { revisions: readonly RevisionRow[] }) {
|
|
831
|
+
return (
|
|
832
|
+
<div class="card">
|
|
833
|
+
<div class="card-header">
|
|
834
|
+
<div class="card-title">Revision history</div>
|
|
835
|
+
</div>
|
|
836
|
+
<div class="card-content">
|
|
837
|
+
{revisions.length === 0 ? (
|
|
838
|
+
<p class="history-empty">No saved revisions yet</p>
|
|
839
|
+
) : (
|
|
840
|
+
<ul class="revision-list">
|
|
841
|
+
{revisions.map((revision) => (
|
|
842
|
+
<li class="revision" key={revision.id}>
|
|
843
|
+
<div class="revision-head">
|
|
844
|
+
<span class={`tag tag-data ${STATUS_TAG[revision.status]}`}>{STATUS_LABEL[revision.status]}</span>
|
|
845
|
+
<time class="revision-time" datetime={new Date(revision.created_at).toISOString()}>
|
|
846
|
+
{new Date(revision.created_at).toLocaleString()}
|
|
847
|
+
</time>
|
|
848
|
+
<button class="btn btn-ghost btn-sm" type="button" data-use-revision title="Use this text">
|
|
849
|
+
Use
|
|
850
|
+
</button>
|
|
851
|
+
</div>
|
|
852
|
+
<div class="revision-text">{revision.human_text}</div>
|
|
853
|
+
</li>
|
|
854
|
+
))}
|
|
855
|
+
</ul>
|
|
856
|
+
)}
|
|
857
|
+
</div>
|
|
858
|
+
</div>
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** The constraint panel: what the entry's save enforces, plus the display-only tone and notes. */
|
|
863
|
+
function ConstraintsCard({ constraints, text }: { constraints: Constraints; text: string }) {
|
|
864
|
+
const maxLen = constraints.maxLen;
|
|
865
|
+
const placeholders = constraints.placeholders;
|
|
866
|
+
const tone = constraints.tone;
|
|
867
|
+
const notes = constraints.notes;
|
|
868
|
+
const hasAny = maxLen !== undefined || placeholders.length > 0 || tone !== undefined || notes !== undefined;
|
|
869
|
+
if (!hasAny) return null;
|
|
870
|
+
return (
|
|
871
|
+
<div class="card">
|
|
872
|
+
<div class="card-header">
|
|
873
|
+
<div class="card-title">Constraints</div>
|
|
874
|
+
</div>
|
|
875
|
+
<div class="card-content">
|
|
876
|
+
{maxLen !== undefined ? (
|
|
877
|
+
<div class="constraint-row">
|
|
878
|
+
<div class="constraint-label">Length</div>
|
|
879
|
+
<div class="constraint-value">
|
|
880
|
+
<span id="char-count" class="num char-count" data-max-len={maxLen}>
|
|
881
|
+
{text.length} / {maxLen}
|
|
882
|
+
</span>
|
|
883
|
+
</div>
|
|
884
|
+
</div>
|
|
885
|
+
) : null}
|
|
886
|
+
{placeholders.length > 0 ? (
|
|
887
|
+
<div class="constraint-row">
|
|
888
|
+
<div class="constraint-label">Placeholders</div>
|
|
889
|
+
<ul class="placeholder-list">
|
|
890
|
+
{placeholders.map((placeholder) => {
|
|
891
|
+
const present = text.includes(placeholder);
|
|
892
|
+
return (
|
|
893
|
+
<li class="placeholder-item" data-placeholder={placeholder} key={placeholder}>
|
|
894
|
+
<span class={`placeholder-state${present ? " is-present" : ""}`}>
|
|
895
|
+
{present ? "Present" : "Missing"}
|
|
896
|
+
</span>
|
|
897
|
+
<span class="placeholder-token">{"{" + placeholder + "}"}</span>
|
|
898
|
+
</li>
|
|
899
|
+
);
|
|
900
|
+
})}
|
|
901
|
+
</ul>
|
|
902
|
+
</div>
|
|
903
|
+
) : null}
|
|
904
|
+
{tone !== undefined ? (
|
|
905
|
+
<div class="constraint-row">
|
|
906
|
+
<div class="constraint-label">Tone</div>
|
|
907
|
+
<div class="constraint-value">{tone}</div>
|
|
908
|
+
</div>
|
|
909
|
+
) : null}
|
|
910
|
+
{notes !== undefined ? (
|
|
911
|
+
<div class="constraint-row">
|
|
912
|
+
<div class="constraint-label">Notes</div>
|
|
913
|
+
<div class="constraint-value">{notes}</div>
|
|
914
|
+
</div>
|
|
915
|
+
) : null}
|
|
916
|
+
</div>
|
|
917
|
+
</div>
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** The entry's context JSON plus its anchor metadata, as an info-table readout. */
|
|
922
|
+
function ContextCard({ entry }: { entry: EntryRow }) {
|
|
923
|
+
const context = parseContext(entry.context);
|
|
924
|
+
return (
|
|
925
|
+
<div class="card">
|
|
926
|
+
<div class="card-header">
|
|
927
|
+
<div class="card-title">Context</div>
|
|
928
|
+
</div>
|
|
929
|
+
<div class="card-content">
|
|
930
|
+
<table class="info-table">
|
|
931
|
+
{Object.entries(context).map(([key, value]) => (
|
|
932
|
+
<tr key={key}>
|
|
933
|
+
<td>{key}</td>
|
|
934
|
+
<td>{typeof value === "string" ? value : JSON.stringify(value)}</td>
|
|
935
|
+
</tr>
|
|
936
|
+
))}
|
|
937
|
+
<tr>
|
|
938
|
+
<td>Repo</td>
|
|
939
|
+
<td>{entry.repo}</td>
|
|
940
|
+
</tr>
|
|
941
|
+
<tr>
|
|
942
|
+
<td>File</td>
|
|
943
|
+
<td>{entry.file}</td>
|
|
944
|
+
</tr>
|
|
945
|
+
<tr>
|
|
946
|
+
<td>Anchor</td>
|
|
947
|
+
<td>{entry.anchor_text}</td>
|
|
948
|
+
</tr>
|
|
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 ? (
|
|
960
|
+
<tr>
|
|
961
|
+
<td>Stale note</td>
|
|
962
|
+
<td>{entry.stale_note}</td>
|
|
963
|
+
</tr>
|
|
964
|
+
) : null}
|
|
965
|
+
</table>
|
|
966
|
+
</div>
|
|
967
|
+
</div>
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* The before-and-after of the entry's text, above the editor: what the anchored region reads now
|
|
973
|
+
* (the anchor line visually distinct) and what the entry's text will replace it with. Both sides
|
|
974
|
+
* escape through JSX; the text is the stored bytes, shown as-is.
|
|
975
|
+
*/
|
|
976
|
+
function DiffView({ entry }: { entry: EntryRow }) {
|
|
977
|
+
const after = entry.human_text ?? entry.agent_draft;
|
|
978
|
+
// No text at all means nothing to compare: a filed entry whose agent never drafted, or an
|
|
979
|
+
// approved entry whose text was never set.
|
|
980
|
+
if (after === null) return null;
|
|
981
|
+
return (
|
|
982
|
+
<div class="diff-grid">
|
|
983
|
+
<div class="card">
|
|
984
|
+
<div class="card-header">
|
|
985
|
+
<div class="card-title">Before</div>
|
|
986
|
+
</div>
|
|
987
|
+
<pre class="diff-code">
|
|
988
|
+
<span class="diff-line">{entry.anchor_before}</span>
|
|
989
|
+
<span class="diff-line diff-anchor">{entry.anchor_text}</span>
|
|
990
|
+
<span class="diff-line">{entry.anchor_after}</span>
|
|
991
|
+
</pre>
|
|
992
|
+
</div>
|
|
993
|
+
<div class="card">
|
|
994
|
+
<div class="card-header">
|
|
995
|
+
<div class="card-title">After</div>
|
|
996
|
+
</div>
|
|
997
|
+
<pre class="diff-code">{after}</pre>
|
|
998
|
+
</div>
|
|
999
|
+
</div>
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* The editor view: the diff above the editor grid, one swap node. A save changes the diff's
|
|
1005
|
+
* after-side, so the save form targets this wrapper and its response re-renders the whole view;
|
|
1006
|
+
* the transitions target only the region inside (they never change what the diff shows).
|
|
1007
|
+
*/
|
|
1008
|
+
function EditorView({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
|
|
1009
|
+
const text = state?.submittedText ?? vm.entry.human_text ?? vm.entry.agent_draft ?? "";
|
|
1010
|
+
return (
|
|
1011
|
+
<div id="editor-view">
|
|
1012
|
+
<DiffView entry={vm.entry} />
|
|
1013
|
+
<div class="editor-grid">
|
|
1014
|
+
<div class="editor-main">
|
|
1015
|
+
<EditorRegion vm={vm} state={state} />
|
|
1016
|
+
</div>
|
|
1017
|
+
<aside class="editor-rail">
|
|
1018
|
+
<ContextCard entry={vm.entry} />
|
|
1019
|
+
<ConstraintsCard constraints={vm.constraints} text={text} />
|
|
1020
|
+
</aside>
|
|
1021
|
+
</div>
|
|
1022
|
+
</div>
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** The full editor page; the mount wraps it in the doctype. `signedIn` is whether the navbar offers sign-out. */
|
|
1027
|
+
export function editorPage(vm: EditorViewModel, state: EditorState | undefined = undefined, signedIn = false): JSX.Element {
|
|
1028
|
+
return (
|
|
1029
|
+
<html lang="en" data-theme="dark">
|
|
1030
|
+
<head>
|
|
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>
|
|
1042
|
+
<body>
|
|
1043
|
+
<div class="app-shell">
|
|
1044
|
+
<Navbar signedIn={signedIn} active="entries" />
|
|
1045
|
+
<main class="main">
|
|
1046
|
+
<header class="page-header">
|
|
1047
|
+
<div class="label page-eyebrow">Entry review</div>
|
|
1048
|
+
<h1 class="page-title">Edit entry</h1>
|
|
1049
|
+
<p class="page-lede">Saving the text approves the entry and releases it to agents.</p>
|
|
1050
|
+
</header>
|
|
1051
|
+
<EditorView vm={vm} state={state} />
|
|
1052
|
+
{/* The one live region: announcements on constraint-state flips only, so a screen reader
|
|
1053
|
+
is not flooded with a per-keystroke value. Lives outside the swap region. */}
|
|
1054
|
+
<span id="editor-live" class="visually-hidden" aria-live="polite"></span>
|
|
1055
|
+
</main>
|
|
1056
|
+
</div>
|
|
1057
|
+
</body>
|
|
1058
|
+
</html>
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** The editor region only, for htmx transition responses; the page shell, diff, context, and constraints stay put. */
|
|
1063
|
+
export function editorFragment(vm: EditorViewModel, state: EditorState | undefined = undefined): JSX.Element {
|
|
1064
|
+
return <EditorRegion vm={vm} state={state} />;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** The whole editor view (diff + region + rail), for htmx save responses: a save changes the diff's after-side, so the swap must re-render it. */
|
|
1068
|
+
export function editorViewFragment(vm: EditorViewModel, state: EditorState | undefined = undefined): JSX.Element {
|
|
1069
|
+
return <EditorView vm={vm} state={state} />;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** The 404 page for an unknown entry id, with the way onward the page-not-found pattern demands. `signedIn` is whether the navbar offers sign-out. */
|
|
1073
|
+
export function notFoundPage(signedIn = false): JSX.Element {
|
|
1074
|
+
return (
|
|
1075
|
+
<html lang="en" data-theme="dark">
|
|
1076
|
+
<head>
|
|
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>
|
|
1088
|
+
<body>
|
|
1089
|
+
<div class="app-shell">
|
|
1090
|
+
<Navbar signedIn={signedIn} active="entries" />
|
|
1091
|
+
<main class="main">
|
|
1092
|
+
<header class="page-header">
|
|
1093
|
+
<div class="label page-eyebrow">Entry review</div>
|
|
1094
|
+
<h1 class="page-title">Entry not found</h1>
|
|
1095
|
+
<p class="page-lede">This entry doesn't exist. Return to the list to find it.</p>
|
|
1096
|
+
<a href="/" class="btn btn-secondary">Back to entries</a>
|
|
1097
|
+
</header>
|
|
1098
|
+
</main>
|
|
1099
|
+
</div>
|
|
1100
|
+
</body>
|
|
1101
|
+
</html>
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** The htmx fragment for a save on an id that vanished mid-session: htmx will not swap a 4xx, so this is a 200 with the way onward in the editor region itself. */
|
|
1106
|
+
export function entryGoneFragment(): JSX.Element {
|
|
1107
|
+
return (
|
|
1108
|
+
<div id="editor-region">
|
|
1109
|
+
<DangerCallout title="This entry no longer exists" body="Return to the list to find it." />
|
|
1110
|
+
<a href="/" class="btn btn-secondary">Back to entries</a>
|
|
1111
|
+
</div>
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** The save swap targets the whole editor view, so a vanished id answers in that same wrapper shape. */
|
|
1116
|
+
export function editorViewGoneFragment(): JSX.Element {
|
|
1117
|
+
return <div id="editor-view">{entryGoneFragment()}</div>;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// ---- Style guide editor ----
|
|
1121
|
+
|
|
1122
|
+
/** The page's settled read: which section is being edited, the selector's projects, and whether the section has a row. */
|
|
1123
|
+
export type StyleGuideViewModel = {
|
|
1124
|
+
/** null is the global section; a slug its project section. */
|
|
1125
|
+
readonly projectSlug: string | null;
|
|
1126
|
+
readonly projects: readonly { id: string; slug: string }[];
|
|
1127
|
+
/** The slug resolved to nothing: the region renders the notice instead of a form, and no row is ever created. */
|
|
1128
|
+
readonly unknown: boolean;
|
|
1129
|
+
/** Whether a row exists for this section: the empty state (no row) differs from the partial one (a row with missing fields). */
|
|
1130
|
+
readonly hasRow: boolean;
|
|
1131
|
+
/** The stored section, or the empty defaults when there is no row yet. */
|
|
1132
|
+
readonly section: StyleGuideInput;
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
/** What the last save round settled into, rendered by the swap: the saved notice, the unknown-project notice, or the refusal with its message. The submitted form is kept so a refusal re-renders what the user typed. */
|
|
1136
|
+
export type StyleGuideEditorState = {
|
|
1137
|
+
readonly submitted: StyleGuideForm;
|
|
1138
|
+
readonly notice:
|
|
1139
|
+
| { readonly kind: "saved" }
|
|
1140
|
+
| { readonly kind: "unknown_project" }
|
|
1141
|
+
| { readonly kind: "refusal"; readonly title: string; readonly body: string };
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
/** The global and project sections: `project` empty means global, and the slug is checked against the projects list, never minted. */
|
|
1145
|
+
export function loadStyleGuide(store: Store, projectQuery: string | undefined): StyleGuideViewModel {
|
|
1146
|
+
const projectSlug = projectQuery !== undefined && projectQuery !== "" ? projectQuery : null;
|
|
1147
|
+
const projects = listProjects(store);
|
|
1148
|
+
const unknown = projectSlug !== null && !projects.some((project) => project.slug === projectSlug);
|
|
1149
|
+
const section = readStyleGuideSection(store, projectSlug);
|
|
1150
|
+
return {
|
|
1151
|
+
projectSlug,
|
|
1152
|
+
projects,
|
|
1153
|
+
unknown,
|
|
1154
|
+
hasRow: section !== null,
|
|
1155
|
+
section: section ?? { markdown: "", bannedWords: [], glossary: {} },
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** The user-facing refusal message for a style guide save, in the dashboard's own voice: name the line and offer the fix. The unknown project is its own notice kind with its own region, so it never formats as a refusal. */
|
|
1160
|
+
export function formatStyleGuideRefusal(refusal: StyleGuideFormRefusal): { title: string; body: string } {
|
|
1161
|
+
return {
|
|
1162
|
+
title: "Guide not saved",
|
|
1163
|
+
body:
|
|
1164
|
+
refusal.reason === "no_colon"
|
|
1165
|
+
? `Glossary line ${refusal.lineNumber} ("${refusal.line}") has no colon. Write it as key: value.`
|
|
1166
|
+
: `Glossary line ${refusal.lineNumber} ("${refusal.line}") has no key. Write it as key: value.`,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** The stored section as the form's raw strings. A stored item can hold a line break (a foreign row), and the form would refuse that on re-save, so the display collapses the break the way the merge's render does. */
|
|
1171
|
+
function sectionToForm(section: StyleGuideInput): StyleGuideForm {
|
|
1172
|
+
const oneLine = (value: string) => value.replace(/[\r\n]+/g, " ");
|
|
1173
|
+
return {
|
|
1174
|
+
markdown: section.markdown,
|
|
1175
|
+
bannedWordsCsv: section.bannedWords.join(", "),
|
|
1176
|
+
glossaryText: Object.entries(section.glossary)
|
|
1177
|
+
.map(([key, value]) => `${oneLine(key)}: ${oneLine(value)}`)
|
|
1178
|
+
.join("\n"),
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function InfoCallout({ title, body }: { title: string; body?: string }) {
|
|
1183
|
+
return (
|
|
1184
|
+
<div class="callout callout-info">
|
|
1185
|
+
<div class="callout-icon" style="color: var(--info)">
|
|
1186
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
1187
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
1188
|
+
<path d="M8 7.5v3.5M8 5.5v.01" />
|
|
1189
|
+
</svg>
|
|
1190
|
+
</div>
|
|
1191
|
+
<div class="callout-content">
|
|
1192
|
+
<div class="callout-title">{title}</div>
|
|
1193
|
+
{body !== undefined ? <div class="callout-body">{body}</div> : null}
|
|
1194
|
+
</div>
|
|
1195
|
+
</div>
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** The project selector: the global section plus every known project, as links, with the current one marked. A link list is the whole mechanism; the section never auto-creates a project. */
|
|
1200
|
+
function StyleGuideProjects({ vm }: { vm: StyleGuideViewModel }) {
|
|
1201
|
+
const activeSlug = vm.unknown ? null : vm.projectSlug;
|
|
1202
|
+
return (
|
|
1203
|
+
<nav class="style-guide-projects" aria-label="Style guide sections">
|
|
1204
|
+
<ProjectSectionLink href="/style-guide" label="Global" active={activeSlug === null} />
|
|
1205
|
+
{vm.projects.map((project) => (
|
|
1206
|
+
<ProjectSectionLink
|
|
1207
|
+
key={project.slug}
|
|
1208
|
+
href={`/style-guide?project=${project.slug}`}
|
|
1209
|
+
label={project.slug}
|
|
1210
|
+
active={activeSlug === project.slug}
|
|
1211
|
+
/>
|
|
1212
|
+
))}
|
|
1213
|
+
</nav>
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function ProjectSectionLink({ href, label, active }: { href: string; label: string; active: boolean }) {
|
|
1218
|
+
return (
|
|
1219
|
+
<a
|
|
1220
|
+
class={`btn btn-sm ${active ? "btn-primary" : "btn-secondary"}`}
|
|
1221
|
+
href={href}
|
|
1222
|
+
aria-current={active ? "page" : undefined}
|
|
1223
|
+
>
|
|
1224
|
+
{label}
|
|
1225
|
+
</a>
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** The unknown section: the notice and the way onward, no form. An unknown slug must never re-target the save at the global section by accident, so the editor form does not render at all. */
|
|
1230
|
+
function StyleGuideUnknownRegion({ slug }: { slug: string }) {
|
|
1231
|
+
return (
|
|
1232
|
+
<div id="style-guide-region">
|
|
1233
|
+
<DangerCallout title={`Project ${slug} doesn't exist`} body="A project section appears once the project has entries." />
|
|
1234
|
+
<a href="/style-guide" class="btn btn-secondary">Edit the global guide</a>
|
|
1235
|
+
</div>
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** The region htmx swaps: the settled notice on top, then the editor form. A save re-renders the stored values (normalized), a refusal keeps the submitted ones. */
|
|
1240
|
+
function StyleGuideRegion({ vm, state }: { vm: StyleGuideViewModel; state: StyleGuideEditorState | undefined }) {
|
|
1241
|
+
if (vm.unknown || state?.notice.kind === "unknown_project") {
|
|
1242
|
+
return <StyleGuideUnknownRegion slug={vm.projectSlug ?? ""} />;
|
|
1243
|
+
}
|
|
1244
|
+
const formValues = state !== undefined && state.notice.kind === "refusal" ? state.submitted : sectionToForm(vm.section);
|
|
1245
|
+
return (
|
|
1246
|
+
<div id="style-guide-region">
|
|
1247
|
+
{state?.notice.kind === "saved" ? (
|
|
1248
|
+
<div class="callout callout-success" role="status">
|
|
1249
|
+
<div class="callout-icon" style="color: var(--success)">
|
|
1250
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
1251
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
1252
|
+
<path d="M5 8l2 2 4-4" />
|
|
1253
|
+
</svg>
|
|
1254
|
+
</div>
|
|
1255
|
+
<div class="callout-content">
|
|
1256
|
+
<div class="callout-title">Guide saved</div>
|
|
1257
|
+
<div class="callout-body">
|
|
1258
|
+
{vm.projectSlug === null
|
|
1259
|
+
? "The global guide now applies to every project."
|
|
1260
|
+
: `The ${vm.projectSlug} section now applies below the global guide.`}
|
|
1261
|
+
</div>
|
|
1262
|
+
</div>
|
|
1263
|
+
</div>
|
|
1264
|
+
) : null}
|
|
1265
|
+
{state?.notice.kind === "refusal" ? <DangerCallout title={state.notice.title} body={state.notice.body} /> : null}
|
|
1266
|
+
{!vm.hasRow ? (
|
|
1267
|
+
<InfoCallout
|
|
1268
|
+
title={vm.projectSlug === null ? "No guide yet" : "No guide for this project yet"}
|
|
1269
|
+
body="Your save creates it."
|
|
1270
|
+
/>
|
|
1271
|
+
) : null}
|
|
1272
|
+
<form
|
|
1273
|
+
id="style-guide-form"
|
|
1274
|
+
class="card style-guide-card"
|
|
1275
|
+
method="post"
|
|
1276
|
+
action="/style-guide/save"
|
|
1277
|
+
hx-post="/style-guide/save"
|
|
1278
|
+
hx-target="#style-guide-region"
|
|
1279
|
+
hx-swap="outerHTML"
|
|
1280
|
+
hx-indicator="#style-guide-loading"
|
|
1281
|
+
>
|
|
1282
|
+
<input type="hidden" name="project" value={vm.projectSlug ?? ""} />
|
|
1283
|
+
<div class="card-header">
|
|
1284
|
+
<div class="card-title">{vm.projectSlug === null ? "Global guide" : `${vm.projectSlug} guide`}</div>
|
|
1285
|
+
</div>
|
|
1286
|
+
<div class="card-content">
|
|
1287
|
+
<div class="field">
|
|
1288
|
+
<label class="field-label" for="style-guide-markdown">Markdown</label>
|
|
1289
|
+
<textarea class="input" id="style-guide-markdown" name="markdown" rows={10} aria-label="Guide markdown">
|
|
1290
|
+
{formValues.markdown}
|
|
1291
|
+
</textarea>
|
|
1292
|
+
{vm.hasRow && vm.section.markdown === "" ? <p class="field-hint">No markdown yet.</p> : null}
|
|
1293
|
+
</div>
|
|
1294
|
+
<div class="field">
|
|
1295
|
+
<label class="field-label" for="style-guide-banned">Banned words</label>
|
|
1296
|
+
<input class="input" id="style-guide-banned" name="banned_words" type="text" value={formValues.bannedWordsCsv} />
|
|
1297
|
+
<p class="field-hint">Comma-separated. Extra whitespace and duplicates are dropped.</p>
|
|
1298
|
+
{vm.hasRow && vm.section.bannedWords.length === 0 ? <p class="field-hint">No banned words yet.</p> : null}
|
|
1299
|
+
</div>
|
|
1300
|
+
<div class="field">
|
|
1301
|
+
<label class="field-label" for="style-guide-glossary">Glossary</label>
|
|
1302
|
+
<textarea class="input" id="style-guide-glossary" name="glossary" rows={6} aria-label="Glossary entries">
|
|
1303
|
+
{formValues.glossaryText}
|
|
1304
|
+
</textarea>
|
|
1305
|
+
<p class="field-hint">One key: value pair per line. A line without a colon is refused.</p>
|
|
1306
|
+
{vm.hasRow && Object.keys(vm.section.glossary).length === 0 ? (
|
|
1307
|
+
<p class="field-hint">No glossary entries yet.</p>
|
|
1308
|
+
) : null}
|
|
1309
|
+
</div>
|
|
1310
|
+
<div class="editor-actions">
|
|
1311
|
+
<button class="btn btn-primary" type="submit" hx-disabled-elt="this">Save guide</button>
|
|
1312
|
+
<span id="style-guide-loading" class="htmx-indicator" role="status">
|
|
1313
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
1314
|
+
Saving guide
|
|
1315
|
+
</span>
|
|
1316
|
+
</div>
|
|
1317
|
+
</div>
|
|
1318
|
+
</form>
|
|
1319
|
+
</div>
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** The error box htmx failures clone into the style guide region; the retry button re-submits the save form. */
|
|
1324
|
+
function StyleGuideErrorBoxTemplate() {
|
|
1325
|
+
return (
|
|
1326
|
+
<template id="style-guide-error-box">
|
|
1327
|
+
<div class="callout callout-danger" role="alert">
|
|
1328
|
+
<div class="callout-icon" style="color: var(--danger)">
|
|
1329
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
1330
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
1331
|
+
<path d="M10 6L6 10M6 6l4 4" />
|
|
1332
|
+
</svg>
|
|
1333
|
+
</div>
|
|
1334
|
+
<div class="callout-content">
|
|
1335
|
+
<div class="callout-title">The guide could not be saved</div>
|
|
1336
|
+
<div class="callout-body">The save request failed. Try it again.</div>
|
|
1337
|
+
<div class="callout-actions">
|
|
1338
|
+
<button class="btn btn-secondary btn-sm" type="button" data-retry>Retry</button>
|
|
1339
|
+
</div>
|
|
1340
|
+
</div>
|
|
1341
|
+
</div>
|
|
1342
|
+
</template>
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/** The full style guide page; the mount wraps it in the doctype. `signedIn` is whether the navbar offers sign-out. */
|
|
1347
|
+
export function styleGuidePage(
|
|
1348
|
+
vm: StyleGuideViewModel,
|
|
1349
|
+
state: StyleGuideEditorState | undefined = undefined,
|
|
1350
|
+
signedIn = false,
|
|
1351
|
+
): JSX.Element {
|
|
1352
|
+
return (
|
|
1353
|
+
<html lang="en" data-theme="dark">
|
|
1354
|
+
<head>
|
|
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>
|
|
1366
|
+
<body>
|
|
1367
|
+
<div class="app-shell">
|
|
1368
|
+
<Navbar signedIn={signedIn} active="style-guide" />
|
|
1369
|
+
<main class="main">
|
|
1370
|
+
<header class="page-header">
|
|
1371
|
+
<div class="label page-eyebrow">Voice</div>
|
|
1372
|
+
<h1 class="page-title">Style guide</h1>
|
|
1373
|
+
<p class="page-lede">The voice agents read before drafting, starting with the global guide and adding each project's own rules below.</p>
|
|
1374
|
+
</header>
|
|
1375
|
+
<StyleGuideProjects vm={vm} />
|
|
1376
|
+
<StyleGuideRegion vm={vm} state={state} />
|
|
1377
|
+
<StyleGuideErrorBoxTemplate />
|
|
1378
|
+
</main>
|
|
1379
|
+
</div>
|
|
1380
|
+
</body>
|
|
1381
|
+
</html>
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** The style guide region only, for htmx responses; the selector, the page shell, and the error template stay put. */
|
|
1386
|
+
export function styleGuideRegionFragment(vm: StyleGuideViewModel, state: StyleGuideEditorState | undefined = undefined): JSX.Element {
|
|
1387
|
+
return <StyleGuideRegion vm={vm} state={state} />;
|
|
1388
|
+
}
|