@deskwork/studio 0.9.5 → 0.9.6

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.
@@ -0,0 +1,457 @@
1
+ /**
2
+ * The Compositor's Manual — `/dev/editorial-help`.
3
+ *
4
+ * Static (read-only) operator manual. Renders six sections:
5
+ * I — the working model (calendar stages + review states diagrams)
6
+ * II — three tracks (longform / shortform / distribution)
7
+ * III — the skill catalogue (specimen grid)
8
+ * IV — the studio surfaces, described
9
+ * V — a run-through, idea to cross-post
10
+ * VI — a reference card (keyboard / URLs / transitions / files)
11
+ *
12
+ * The skill catalogue is the only data-driven section; everything else
13
+ * is verbatim prose. The skills come from
14
+ * `editorial-skills-catalogue.ts` so a single edit shows up here, in
15
+ * any future docs generator, and in any CLI inventory.
16
+ *
17
+ * Ported from `editorial-help.astro`. The audiocontrol original
18
+ * referenced the two hardcoded sites (`audiocontrol.org ·
19
+ * editorialcontrol.org`) in the cover imprint; here we render the
20
+ * configured site hosts instead.
21
+ */
22
+
23
+ import {
24
+ KIND_LABEL,
25
+ SKILLS_SORTED,
26
+ type Skill,
27
+ } from '../lib/editorial-skills-catalogue.ts';
28
+ import type { StudioContext } from '../routes/api.ts';
29
+ import { html, unsafe, type RawHtml } from './html.ts';
30
+ import { layout } from './layout.ts';
31
+ import { renderEditorialFolio } from './chrome.ts';
32
+
33
+ const MONTH_NAMES = [
34
+ 'January', 'February', 'March', 'April', 'May', 'June',
35
+ 'July', 'August', 'September', 'October', 'November', 'December',
36
+ ];
37
+
38
+ function formatIssueDate(now: Date): string {
39
+ return `${now.getDate()} ${MONTH_NAMES[now.getMonth()]} ${now.getFullYear()}`;
40
+ }
41
+
42
+ function renderCover(ctx: StudioContext, now: Date): RawHtml {
43
+ const sitesInline = Object.values(ctx.config.sites)
44
+ .map((s) => s.host)
45
+ .join(' · ');
46
+ return unsafe(html`
47
+ <header class="er-pagehead er-pagehead--centered eh-cover">
48
+ <p class="er-pagehead__kicker eh-cover-kicker">
49
+ Vol. 01 <span class="dot">·</span> Manual <span class="dot">·</span> Internal — for operators
50
+ </p>
51
+ <h1 class="er-pagehead__title eh-cover-title">
52
+ The Compositor's <em>Manual</em>
53
+ </h1>
54
+ <p class="er-pagehead__deck eh-cover-dek">
55
+ Everything you need to move a thought from notebook to published dispatch without asking a colleague. The editorial calendar, the review pipelines, the skills that drive them, and the desk where you watch the whole thing happen.
56
+ </p>
57
+ <p class="er-pagehead__imprint eh-imprint">
58
+ <strong>Sites</strong><span>${sitesInline || ctx.projectRoot}</span>
59
+ <span class="sep">§</span>
60
+ <strong>Issued</strong><span>${formatIssueDate(now)}</span>
61
+ <span class="sep">§</span>
62
+ <strong>Revision</strong><span>1.0</span>
63
+ <span class="sep">§</span>
64
+ <strong>Desk</strong><a href="/dev/editorial-studio">/dev/editorial-studio</a>
65
+ </p>
66
+ </header>`);
67
+ }
68
+
69
+ const TOC_ENTRIES = [
70
+ { id: 'sec-model', num: '§ I', title: 'The working model — stages and states', page: 'p. 01' },
71
+ { id: 'sec-tracks', num: '§ II', title: 'Three tracks — longform, shortform, distribution', page: 'p. 02' },
72
+ { id: 'sec-catalogue', num: '§ III', title: 'The skills, alphabetised', page: 'p. 03' },
73
+ { id: 'sec-studio', num: '§ IV', title: 'The Editorial Studio, described', page: 'p. 08' },
74
+ { id: 'sec-runthrough', num: '§ V', title: 'A run-through, idea to cross-post', page: 'p. 10' },
75
+ { id: 'sec-reference', num: '§ VI', title: 'Reference card', page: 'p. 13' },
76
+ ];
77
+
78
+ function renderToc(): RawHtml {
79
+ return unsafe(html`
80
+ <nav class="eh-toc" aria-label="Manual contents">
81
+ <p class="eh-toc-label">Contents</p>
82
+ ${TOC_ENTRIES.map(
83
+ (e) => unsafe(html`<a href="#${e.id}"><span class="eh-toc-num">${e.num}</span><span>${e.title}</span><span class="eh-toc-page">${e.page}</span></a>`),
84
+ )}
85
+ </nav>`);
86
+ }
87
+
88
+ function renderModelSection(): RawHtml {
89
+ return unsafe(html`
90
+ <section class="eh-section" id="sec-model">
91
+ <header class="eh-section-head">
92
+ <span class="eh-section-num">§ I</span>
93
+ <h2 class="eh-section-title">The working model</h2>
94
+ <span class="eh-section-sig">Stages · States</span>
95
+ </header>
96
+ <p class="eh-lead">
97
+ Two state machines run in parallel. The <em>calendar stage</em> tracks where a piece of content lives in its lifecycle. The <em>review pipeline</em> tracks whether a specific draft version has been annotated, revised, and approved. They are orthogonal — a piece in <em>Drafting</em> may have three review workflows open against three different draft files, and a piece can reach <em>Published</em> without ever having been through review.
98
+ </p>
99
+ <div class="eh-state-diagram" aria-label="Calendar stages">
100
+ <span class="eh-state-label">Fig. 1 — Calendar stages</span>
101
+ <div class="eh-stage-chain">
102
+ <div class="eh-stage-box"><span class="num">01</span><div class="ornament">◇</div><div class="name">Ideas</div><div class="hint">captured</div></div>
103
+ <div class="eh-stage-arrow">→</div>
104
+ <div class="eh-stage-box"><span class="num">02</span><div class="ornament">§</div><div class="name">Planned</div><div class="hint">keywords</div></div>
105
+ <div class="eh-stage-arrow">→</div>
106
+ <div class="eh-stage-box"><span class="num">03</span><div class="ornament">✎</div><div class="name">Drafting</div><div class="hint">writing</div></div>
107
+ <div class="eh-stage-arrow">→</div>
108
+ <div class="eh-stage-box"><span class="num">04</span><div class="ornament">※</div><div class="name">Review</div><div class="hint">iteration</div></div>
109
+ <div class="eh-stage-arrow">→</div>
110
+ <div class="eh-stage-box"><span class="num">05</span><div class="ornament">✓</div><div class="name">Published</div><div class="hint">live</div></div>
111
+ </div>
112
+ <p class="eh-state-caption">Forward-only. An entry can be paused at any stage but does not walk backwards.</p>
113
+ </div>
114
+ <div class="eh-state-diagram" aria-label="Review pipeline states">
115
+ <span class="eh-state-label">Fig. 2 — Review pipeline (per draft, orthogonal)</span>
116
+ <div class="eh-review-loop">
117
+ <div class="loop-node">open</div>
118
+ <div class="loop-arrow">→</div>
119
+ <div class="loop-node">in-review</div>
120
+ <div class="loop-arrow">⇄</div>
121
+ <div class="loop-node">iterating</div>
122
+ <div class="loop-arrow" style="grid-column: 3;">↓</div>
123
+ <div class="loop-node terminal-ok" style="grid-column: 4;">approved</div>
124
+ <div class="loop-arrow" style="grid-column: 5;">→</div>
125
+ <div class="loop-node terminal-ok" style="grid-row: 3; grid-column: 1;">applied</div>
126
+ <div class="loop-arrow" style="grid-row: 3; grid-column: 2;">←</div>
127
+ <div class="loop-node terminal-x" style="grid-row: 3; grid-column: 3 / span 3;">cancelled (any state → here)</div>
128
+ </div>
129
+ <p class="eh-state-caption">Every transition is validated by the pipeline's <code>VALID_TRANSITIONS</code>. <em>applied</em> means the draft has been written to its destination file; <em>cancelled</em> means the workflow was abandoned with the source untouched.</p>
130
+ </div>
131
+ </section>`);
132
+ }
133
+
134
+ function renderTracksSection(): RawHtml {
135
+ return unsafe(html`
136
+ <section class="eh-section" id="sec-tracks">
137
+ <header class="eh-section-head">
138
+ <span class="eh-section-num">§ II</span>
139
+ <h2 class="eh-section-title">Three tracks</h2>
140
+ <span class="eh-section-sig">Longform · Shortform · Distribution</span>
141
+ </header>
142
+ <p>Each track is a canonical run order for its kind of content. The studio surfaces the next move per track and per entry; these lists are for when you are driving Claude Code directly.</p>
143
+ <div class="eh-tracks">
144
+ <div class="eh-track">
145
+ <p class="eh-track-title">Longform</p>
146
+ <p class="eh-track-sub">Blog posts · dispatches</p>
147
+ <ol class="eh-track-steps">
148
+ <li>Capture the idea.<code>/editorial-add "Title"</code></li>
149
+ <li>Promote and set keywords.<code>/editorial-plan &lt;slug&gt;</code></li>
150
+ <li>Scaffold the draft file.<code>/editorial-draft &lt;slug&gt;</code><span class="note">or click "scaffold" in the studio.</span></li>
151
+ <li>Write the prose. No skill; this is the human doing the work.</li>
152
+ <li>Open a review workflow.<code>/editorial-draft-review &lt;slug&gt;</code></li>
153
+ <li>Annotate in the browser, then iterate.<code>/editorial-iterate</code></li>
154
+ <li>Approve — writes to the destination file.<code>/editorial-approve</code></li>
155
+ <li>Mark published. Then commit + push by hand.<code>/editorial-publish &lt;slug&gt;</code></li>
156
+ </ol>
157
+ </div>
158
+ <div class="eh-track">
159
+ <p class="eh-track-title">Shortform</p>
160
+ <p class="eh-track-sub">Social copy · cross-posts</p>
161
+ <ol class="eh-track-steps">
162
+ <li>Draft per platform.<code>/editorial-shortform-draft &lt;slug&gt; &lt;platform&gt;</code><span class="note">Reddit, YouTube, LinkedIn, newsletter.</span></li>
163
+ <li>Review the same way as longform (same page, shortform mode).<span class="note">/dev/editorial-review-shortform</span></li>
164
+ <li>Iterate or approve as with longform.<code>/editorial-iterate · /editorial-approve</code></li>
165
+ <li>Post the copy yourself to the platform.</li>
166
+ <li>Record the distribution.<code>/editorial-distribute &lt;slug&gt; &lt;platform&gt; &lt;url&gt;</code></li>
167
+ </ol>
168
+ </div>
169
+ <div class="eh-track">
170
+ <p class="eh-track-title">Distribution</p>
171
+ <p class="eh-track-sub">Audit · analytics · reconcile</p>
172
+ <ol class="eh-track-steps">
173
+ <li>Reconcile Reddit state.<code>/editorial-reddit-sync</code></li>
174
+ <li>Find cross-post holes.<code>/editorial-social-review</code></li>
175
+ <li>Plan the next wave.<code>/editorial-reddit-opportunities &lt;slug&gt;</code></li>
176
+ <li>Audit bidirectional links (blog ↔ YouTube).<code>/editorial-cross-link-review</code></li>
177
+ <li>Check performance; flag underperformers.<code>/editorial-performance</code></li>
178
+ <li>Feed observations back into ideas.<code>/editorial-suggest</code></li>
179
+ </ol>
180
+ </div>
181
+ </div>
182
+ </section>`);
183
+ }
184
+
185
+ function renderSpecimen(s: Skill): RawHtml {
186
+ const flagsRow = s.flags
187
+ ? unsafe(html`<span class="row"><strong>flags</strong>${s.flags}</span>`)
188
+ : '';
189
+ return unsafe(html`
190
+ <article class="eh-specimen">
191
+ <code class="eh-specimen-slug">${s.slug}</code>
192
+ <span class="eh-specimen-stamp" data-kind="${s.kind}">${KIND_LABEL[s.kind]}</span>
193
+ <p class="eh-specimen-desc">${s.desc}</p>
194
+ <div class="eh-specimen-meta">
195
+ <span class="row"><strong>when</strong><em>${s.when}</em></span>
196
+ <span class="row"><strong>changes</strong>${s.changes}</span>
197
+ ${flagsRow}
198
+ </div>
199
+ </article>`);
200
+ }
201
+
202
+ function renderCatalogueSection(): RawHtml {
203
+ return unsafe(html`
204
+ <section class="eh-section" id="sec-catalogue">
205
+ <header class="eh-section-head">
206
+ <span class="eh-section-num">§ III</span>
207
+ <h2 class="eh-section-title">The skills, alphabetised</h2>
208
+ <span class="eh-section-sig">${SKILLS_SORTED.length} specimens</span>
209
+ </header>
210
+ <p>Every skill that ships with this repository, tagged by kind. Invoke as a slash command inside Claude Code. Voice skills are not meant to be invoked directly — they are called by the cognitive skills that need a register.</p>
211
+ <p class="eh-legend">
212
+ <span><span class="swatch s-cog"></span>cognitive — Claude does writing work</span>
213
+ <span><span class="swatch s-mech"></span>mechanical — disk writes, state transitions</span>
214
+ <span><span class="swatch s-ro"></span>read-only — reports and audits</span>
215
+ <span><span class="swatch s-voice"></span>voice — register helpers</span>
216
+ </p>
217
+ <div class="eh-specimens">
218
+ ${SKILLS_SORTED.map(renderSpecimen)}
219
+ </div>
220
+ </section>`);
221
+ }
222
+
223
+ function renderStudioSection(): RawHtml {
224
+ return unsafe(html`
225
+ <section class="eh-section" id="sec-studio">
226
+ <header class="eh-section-head">
227
+ <span class="eh-section-num">§ IV</span>
228
+ <h2 class="eh-section-title">The Editorial Studio, described</h2>
229
+ <span class="eh-section-sig">/dev/editorial-studio</span>
230
+ </header>
231
+ <p>The studio is where the operator watches state and presses mechanical buttons. Cognitive work — drafting, revising, approving prose — still happens in Claude Code. The studio never writes prose; the skills never touch the UI.</p>
232
+ <div class="eh-studio-map">
233
+ <div class="eh-panel">
234
+ <p class="eh-panel-head">Primary surfaces</p>
235
+ <h4>Calendar panels</h4>
236
+ <p>Five columns: <em>Ideas · Planned · Drafting · Review · Published</em>. Each row shows slug, title, stage-specific metadata (keywords for Planned; dates for Published), a file-present dot, and the active review workflow stamp when one exists.</p>
237
+ <h4>Next-move column</h4>
238
+ <p>Per row, the studio surfaces either a copy-to-clipboard command (for cognitive work that lives in Claude Code) or a one-click button (for mechanical transitions). <code>scaffold →</code> calls <code>/editorial-draft</code>. <code>publish →</code> calls <code>/editorial-publish</code>.</p>
239
+ <h4>Shortform coverage matrix</h4>
240
+ <p>For each <em>Published</em> blog, a row of platform cells (reddit, linkedin, youtube, instagram). Shaded cells are covered by a DistributionRecord; empty cells surface the exact <code>/editorial-shortform-draft</code> command to copy.</p>
241
+ <h4>Voice-drift signal</h4>
242
+ <p>A small panel on the right, backed by <code>/editorial-review-report</code>. Names the two voice-skill categories that are producing the most operator corrections. Shows once you have at least five terminal workflows on record.</p>
243
+ </div>
244
+ <div class="eh-panel">
245
+ <p class="eh-panel-head">Secondary surfaces</p>
246
+ <h4>Longform review</h4>
247
+ <p><code>/dev/editorial-review/&lt;slug&gt;</code>. The draft renders inside the review surface. Select text for a margin note; double-click anywhere to edit the markdown in place; approve, iterate, or reject from the fixed strip.</p>
248
+ <h4>Shortform review</h4>
249
+ <p><code>/dev/editorial-review-shortform</code>. Cards grouped by platform. Each card has a version header, an editable textarea, and save · approve · iterate · reject controls.</p>
250
+ <h4>Keyboard</h4>
251
+ <p>In the studio: <kbd>1</kbd>–<kbd>5</kbd> jump to stage columns. In a longform review: <kbd>e</kbd> / double-click toggles edit mode; <kbd>a</kbd> approves; <kbd>i</kbd> iterates; <kbd>r</kbd> rejects; <kbd>j</kbd>/<kbd>k</kbd> step through margin notes; <kbd>?</kbd> shows a full shortcuts overlay.</p>
252
+ <h4>Polling</h4>
253
+ <p>Both routes poll every 8–10 seconds when idle. If the agent runs <code>/editorial-iterate</code> in Claude Code, a new draft version shows up in the browser without a reload.</p>
254
+ </div>
255
+ </div>
256
+ </section>`);
257
+ }
258
+
259
+ const RUNTHROUGH_STEPS: ReadonlyArray<{ title: string; op: string; body: RawHtml }> = [
260
+ {
261
+ title: 'Capture an idea',
262
+ op: 'terminal',
263
+ body: unsafe('<p>You have a title in mind. Run <code>/editorial-add "Your Title"</code>. A row lands in <em>Ideas</em>. Slug is generated; calendar is committed-able.</p>'),
264
+ },
265
+ {
266
+ title: 'Promote to Planned',
267
+ op: 'terminal',
268
+ body: unsafe('<p>The idea has shape. Run <code>/editorial-plan &lt;slug&gt;</code>. You are prompted for target keywords and topic tags; they land on the calendar row. The studio\'s Planned column now shows it with a tag strip.</p>'),
269
+ },
270
+ {
271
+ title: 'Scaffold the draft',
272
+ op: 'studio or terminal',
273
+ body: unsafe('<p>Click the <code>scaffold →</code> button on the row, or run <code>/editorial-draft --site &lt;site&gt; &lt;slug&gt;</code>. The blog file appears with frontmatter filled in, and the entry moves to <em>Drafting</em>.</p>'),
274
+ },
275
+ {
276
+ title: 'Write the prose',
277
+ op: 'editor',
278
+ body: unsafe('<p>This is the human half. Open the scaffolded file and write the dispatch. The voice skill is not invoked yet — it comes in at review time.</p>'),
279
+ },
280
+ {
281
+ title: 'Open the review workflow',
282
+ op: 'terminal',
283
+ body: unsafe('<p><code>/editorial-draft-review &lt;slug&gt;</code> prints the dev URL: <code>/dev/editorial-review/&lt;slug&gt;</code>. The draft renders inside the review chrome. State: <em>open</em>.</p>'),
284
+ },
285
+ {
286
+ title: 'Annotate in the browser',
287
+ op: 'browser',
288
+ body: unsafe('<p>Select text → the <code>Mark</code> pencil appears → category dropdown → type a note → <em>Leave mark</em>. Repeat for each correction. The state flips to <em>in-review</em> on your first action.</p>'),
289
+ },
290
+ {
291
+ title: 'Iterate',
292
+ op: 'browser then terminal',
293
+ body: unsafe('<p>Click <em>Iterate</em>. State becomes <em>iterating</em>. Back in Claude Code, run <code>/editorial-iterate</code>. The agent revises using the site voice skill, writes v2 to the journal, flips back to <em>in-review</em>. Polling surfaces v2 in the browser without a reload.</p>'),
294
+ },
295
+ {
296
+ title: 'Approve and write',
297
+ op: 'browser then terminal',
298
+ body: unsafe('<p>Click <em>Approve</em>. State becomes <em>approved</em>. Run <code>/editorial-approve</code>. The approved version is written to the blog file on disk; state becomes <em>applied</em>.</p>'),
299
+ },
300
+ {
301
+ title: 'Commit by hand',
302
+ op: 'terminal',
303
+ body: unsafe('<p>The UI does not commit. Review the diff, commit and push when ready. This is deliberate — the operator holds the pen.</p>'),
304
+ },
305
+ {
306
+ title: 'Mark published',
307
+ op: 'studio or terminal',
308
+ body: unsafe('<p>Click <code>publish →</code> on the Drafting or Review row, or run <code>/editorial-publish &lt;slug&gt;</code>. The entry moves to <em>Published</em>; today\'s date is stamped as <code>datePublished</code>.</p>'),
309
+ },
310
+ {
311
+ title: 'Cross-post',
312
+ op: 'terminal × platform',
313
+ body: unsafe('<p>For each platform worth posting to: <code>/editorial-shortform-draft &lt;slug&gt; &lt;platform&gt;</code>. Review it exactly like a longform workflow. Approve, then post it yourself. Record the URL with <code>/editorial-distribute &lt;slug&gt; &lt;platform&gt; &lt;url&gt;</code>.</p>'),
314
+ },
315
+ {
316
+ title: 'Reconcile and reflect',
317
+ op: 'cadence',
318
+ body: unsafe('<p><code>/editorial-reddit-sync</code> to pull external state; <code>/editorial-social-review</code> to see the coverage matrix; <code>/editorial-review-report</code> to see which voice-skill principles are drifting. Feed the observations back into the voice skills. The cycle compounds.</p>'),
319
+ },
320
+ ];
321
+
322
+ function renderRunthroughSection(): RawHtml {
323
+ return unsafe(html`
324
+ <section class="eh-section" id="sec-runthrough">
325
+ <header class="eh-section-head">
326
+ <span class="eh-section-num">§ V</span>
327
+ <h2 class="eh-section-title">A run-through</h2>
328
+ <span class="eh-section-sig">Idea to cross-post, in order</span>
329
+ </header>
330
+ <p class="eh-lead">Numbered events, left-to-right. Each step names the surface where the action happens — the terminal for Claude Code skills, the browser for studio buttons, or the editor for hand-editing the file.</p>
331
+ <div class="eh-walkthrough">
332
+ ${RUNTHROUGH_STEPS.map(
333
+ (s) => unsafe(html`<div class="eh-walkthrough-step">
334
+ <div>
335
+ <h4>${s.title} <span class="op">${s.op}</span></h4>
336
+ ${s.body}
337
+ </div>
338
+ </div>`),
339
+ )}
340
+ </div>
341
+ </section>`);
342
+ }
343
+
344
+ function renderReferenceSection(): RawHtml {
345
+ return unsafe(html`
346
+ <section class="eh-section" id="sec-reference">
347
+ <header class="eh-section-head">
348
+ <span class="eh-section-num">§ VI</span>
349
+ <h2 class="eh-section-title">Reference card</h2>
350
+ <span class="eh-section-sig">Pin this to the desk</span>
351
+ </header>
352
+ <div class="eh-reference">
353
+ <div class="eh-ref-block">
354
+ <h4>Keyboard — longform review</h4>
355
+ <dl>
356
+ <dt><kbd>e</kbd> / dbl-click</dt><dd>toggle edit mode</dd>
357
+ <dt>select text</dt><dd>leave a margin note</dd>
358
+ <dt><kbd>a</kbd></dt><dd>approve the draft</dd>
359
+ <dt><kbd>i</kbd></dt><dd>iterate (hand off to Claude Code)</dd>
360
+ <dt><kbd>r</kbd></dt><dd>reject — cancels the workflow</dd>
361
+ <dt><kbd>j</kbd> / <kbd>k</kbd></dt><dd>step through margin notes</dd>
362
+ <dt><kbd>?</kbd></dt><dd>shortcuts overlay</dd>
363
+ <dt><kbd>esc</kbd></dt><dd>close overlay or cancel comment</dd>
364
+ </dl>
365
+ </div>
366
+ <div class="eh-ref-block">
367
+ <h4>Keyboard — studio</h4>
368
+ <dl>
369
+ <dt><kbd>1</kbd></dt><dd>jump to Ideas column</dd>
370
+ <dt><kbd>2</kbd></dt><dd>jump to Planned</dd>
371
+ <dt><kbd>3</kbd></dt><dd>jump to Drafting</dd>
372
+ <dt><kbd>4</kbd></dt><dd>jump to Review</dd>
373
+ <dt><kbd>5</kbd></dt><dd>jump to Published</dd>
374
+ </dl>
375
+ </div>
376
+ <div class="eh-ref-block eh-ref-block--stacked">
377
+ <h4>URL patterns</h4>
378
+ <dl>
379
+ <dt>/dev/editorial-studio</dt><dd>calendar desk</dd>
380
+ <dt>/dev/editorial-review/&lt;slug&gt;</dt><dd>longform review</dd>
381
+ <dt>/dev/editorial-review-shortform</dt><dd>shortform cards</dd>
382
+ <dt>/dev/editorial-help</dt><dd>this manual</dd>
383
+ </dl>
384
+ </div>
385
+ <div class="eh-ref-block">
386
+ <h4>Review state transitions</h4>
387
+ <table class="eh-transitions">
388
+ <thead><tr><th>from</th><th>to</th></tr></thead>
389
+ <tbody>
390
+ <tr><td>open</td><td class="arrow">→ in-review, cancelled</td></tr>
391
+ <tr><td>in-review</td><td class="arrow">→ iterating, approved, cancelled</td></tr>
392
+ <tr><td>iterating</td><td class="arrow">→ in-review, cancelled</td></tr>
393
+ <tr><td>approved</td><td class="arrow">→ applied, cancelled</td></tr>
394
+ <tr><td>applied</td><td>— terminal</td></tr>
395
+ <tr><td>cancelled</td><td>— terminal</td></tr>
396
+ </tbody>
397
+ </table>
398
+ </div>
399
+ <div class="eh-ref-block eh-ref-block--stacked">
400
+ <h4>File locations</h4>
401
+ <dl>
402
+ <dt>(per-site calendar — see config)</dt><dd>the single calendar file per site</dd>
403
+ <dt>.deskwork/review-journal/pipeline/</dt><dd>per-workflow review state, one JSON per id</dd>
404
+ <dt>.deskwork/review-journal/history/</dt><dd>every event (versions, states, comments)</dd>
405
+ <dt>(blog content dir — see config)</dt><dd>blog post source directory</dd>
406
+ <dt>plugins/&lt;plugin&gt;/skills/&lt;name&gt;/</dt><dd>one skill = one directory</dd>
407
+ </dl>
408
+ </div>
409
+ <div class="eh-ref-block">
410
+ <h4>First-run tripwires</h4>
411
+ <dl>
412
+ <dt>404 on /dev/*</dt><dd>the dev routes only run when <code>deskwork-studio</code> is up. Start it: <code>npx tsx packages/studio/src/server.ts</code>.</dd>
413
+ <dt>no galley to review</dt><dd>start one with <code>/editorial-draft-review --site &lt;site&gt; &lt;slug&gt;</code>.</dd>
414
+ <dt>iterate doesn't trigger</dt><dd>the agent has to run <code>/editorial-iterate</code>. The browser button just marks the workflow; Claude does the writing.</dd>
415
+ </dl>
416
+ </div>
417
+ </div>
418
+ </section>`);
419
+ }
420
+
421
+ function renderColophon(): RawHtml {
422
+ return unsafe(html`
423
+ <footer class="eh-colophon">
424
+ <span>End of manual</span><span>·</span><span>revision 1.0</span><span>·</span><em>The cycle compounds.</em><span>·</span>
425
+ <a href="/dev/editorial-studio" style="color: inherit;">back to the studio</a>
426
+ </footer>`);
427
+ }
428
+
429
+ export function renderHelpPage(ctx: StudioContext): string {
430
+ const now = ctx.now ? ctx.now() : new Date();
431
+ const body = html`
432
+ ${renderEditorialFolio('manual', "compositor's manual")}
433
+ <a class="eh-back" href="/dev/editorial-studio">back to the studio</a>
434
+ <div class="eh-rail" aria-hidden="true"></div>
435
+ <div class="eh-container">
436
+ ${renderCover(ctx, now)}
437
+ ${renderToc()}
438
+ ${renderModelSection()}
439
+ ${renderTracksSection()}
440
+ ${renderCatalogueSection()}
441
+ ${renderStudioSection()}
442
+ ${renderRunthroughSection()}
443
+ ${renderReferenceSection()}
444
+ ${renderColophon()}
445
+ </div>`;
446
+ return layout({
447
+ title: "The Compositor's Manual — Editorial Calendar — dev",
448
+ cssHrefs: [
449
+ '/static/css/editorial-review.css',
450
+ '/static/css/editorial-nav.css',
451
+ '/static/css/editorial-help.css',
452
+ ],
453
+ bodyAttrs: 'data-review-ui="manual"',
454
+ bodyHtml: body,
455
+ scriptModules: [],
456
+ });
457
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * HTML tagged-template helper.
3
+ *
4
+ * `html` escapes interpolated values by default. `unsafe(s)` wraps a
5
+ * pre-built HTML string in an opaque marker so the tag inlines it raw.
6
+ *
7
+ * Behaviour summary for interpolated values:
8
+ * - `null` / `undefined` / `false` → ''
9
+ * - `unsafe(...)` (object with `__raw`) → inserted verbatim
10
+ * - Array<string> → joined with no separator
11
+ * (so `${items.map(i => html`...`)}` Just Works)
12
+ * - everything else → escaped via `escapeHtml`
13
+ *
14
+ * No JSX, no virtual DOM — just string concatenation. The studio is a
15
+ * tiny dev-only surface, so the simplest thing that gets escaping
16
+ * right is the right thing.
17
+ */
18
+
19
+ /** Marker shape for raw-HTML embeds. */
20
+ export interface RawHtml {
21
+ readonly __raw: string;
22
+ }
23
+
24
+ /** Wrap a pre-built HTML string so the `html` tag inlines it without escaping. */
25
+ export function unsafe(s: string): RawHtml {
26
+ return { __raw: s };
27
+ }
28
+
29
+ /** Type guard for `unsafe(...)` values. */
30
+ function isRaw(value: unknown): value is RawHtml {
31
+ return (
32
+ typeof value === 'object' &&
33
+ value !== null &&
34
+ '__raw' in value &&
35
+ typeof (value as { __raw: unknown }).__raw === 'string'
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Escape a string for safe insertion into HTML text or attribute context.
41
+ * `&` first, then `<`, `>`, `"`, `'`. The single-quote escape lets the
42
+ * same function serve both contexts without a separate `escapeAttr`.
43
+ */
44
+ export function escapeHtml(s: string): string {
45
+ return s
46
+ .replaceAll('&', '&amp;')
47
+ .replaceAll('<', '&lt;')
48
+ .replaceAll('>', '&gt;')
49
+ .replaceAll('"', '&quot;')
50
+ .replaceAll("'", '&#39;');
51
+ }
52
+
53
+ function renderValue(value: unknown): string {
54
+ if (value === null || value === undefined || value === false) return '';
55
+ if (isRaw(value)) return value.__raw;
56
+ if (Array.isArray(value)) return value.map(renderValue).join('');
57
+ if (typeof value === 'string') return escapeHtml(value);
58
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value);
59
+ if (typeof value === 'boolean') return value ? 'true' : '';
60
+ // Stringify objects defensively — the only legitimate object case is the
61
+ // `unsafe(...)` marker, which we handled above. Anything else is a
62
+ // template author bug; show it loudly so it gets caught in tests.
63
+ return escapeHtml(String(value));
64
+ }
65
+
66
+ /**
67
+ * Tagged-template helper. Static parts go in verbatim; interpolated
68
+ * values run through `renderValue`.
69
+ */
70
+ export function html(
71
+ strings: TemplateStringsArray,
72
+ ...values: unknown[]
73
+ ): string {
74
+ let out = '';
75
+ for (let i = 0; i < strings.length; i++) {
76
+ out += strings[i];
77
+ if (i < values.length) out += renderValue(values[i]);
78
+ }
79
+ return out;
80
+ }