@deskwork/studio 0.12.1 → 0.14.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.
Files changed (131) hide show
  1. package/dist/components/scrapbook-item.d.ts +9 -1
  2. package/dist/components/scrapbook-item.d.ts.map +1 -1
  3. package/dist/components/scrapbook-item.js +11 -2
  4. package/dist/components/scrapbook-item.js.map +1 -1
  5. package/dist/data/glossary.json +62 -0
  6. package/dist/lib/glossary-helper.d.ts +16 -0
  7. package/dist/lib/glossary-helper.d.ts.map +1 -0
  8. package/dist/lib/glossary-helper.js +26 -0
  9. package/dist/lib/glossary-helper.js.map +1 -0
  10. package/dist/pages/chrome.d.ts +24 -13
  11. package/dist/pages/chrome.d.ts.map +1 -1
  12. package/dist/pages/chrome.js +25 -24
  13. package/dist/pages/chrome.js.map +1 -1
  14. package/dist/pages/chrome.ts +38 -27
  15. package/dist/pages/content-detail.d.ts.map +1 -1
  16. package/dist/pages/content-detail.js +6 -5
  17. package/dist/pages/content-detail.js.map +1 -1
  18. package/dist/pages/content-detail.ts +6 -5
  19. package/dist/pages/content.d.ts.map +1 -1
  20. package/dist/pages/content.js +4 -6
  21. package/dist/pages/content.js.map +1 -1
  22. package/dist/pages/content.ts +4 -6
  23. package/dist/pages/dashboard/affordances.d.ts +6 -1
  24. package/dist/pages/dashboard/affordances.d.ts.map +1 -1
  25. package/dist/pages/dashboard/affordances.js +17 -2
  26. package/dist/pages/dashboard/affordances.js.map +1 -1
  27. package/dist/pages/dashboard/affordances.ts +18 -2
  28. package/dist/pages/dashboard/header.js +2 -2
  29. package/dist/pages/dashboard/header.ts +2 -2
  30. package/dist/pages/dashboard/section.d.ts +2 -2
  31. package/dist/pages/dashboard/section.d.ts.map +1 -1
  32. package/dist/pages/dashboard/section.js +5 -5
  33. package/dist/pages/dashboard/section.js.map +1 -1
  34. package/dist/pages/dashboard/section.ts +9 -5
  35. package/dist/pages/dashboard.d.ts.map +1 -1
  36. package/dist/pages/dashboard.js +3 -2
  37. package/dist/pages/dashboard.js.map +1 -1
  38. package/dist/pages/dashboard.ts +3 -2
  39. package/dist/pages/entry-review/data.d.ts +54 -0
  40. package/dist/pages/entry-review/data.d.ts.map +1 -0
  41. package/dist/pages/entry-review/data.js +116 -0
  42. package/dist/pages/entry-review/data.js.map +1 -0
  43. package/dist/pages/entry-review/decision-strip.d.ts +37 -0
  44. package/dist/pages/entry-review/decision-strip.d.ts.map +1 -0
  45. package/dist/pages/entry-review/decision-strip.js +137 -0
  46. package/dist/pages/entry-review/decision-strip.js.map +1 -0
  47. package/dist/pages/entry-review/edit-panes.d.ts +12 -0
  48. package/dist/pages/entry-review/edit-panes.d.ts.map +1 -0
  49. package/dist/pages/entry-review/edit-panes.js +28 -0
  50. package/dist/pages/entry-review/edit-panes.js.map +1 -0
  51. package/dist/pages/entry-review/edit-toolbar.d.ts +20 -0
  52. package/dist/pages/entry-review/edit-toolbar.d.ts.map +1 -0
  53. package/dist/pages/entry-review/edit-toolbar.js +46 -0
  54. package/dist/pages/entry-review/edit-toolbar.js.map +1 -0
  55. package/dist/pages/entry-review/index.d.ts +50 -0
  56. package/dist/pages/entry-review/index.d.ts.map +1 -0
  57. package/dist/pages/entry-review/index.js +219 -0
  58. package/dist/pages/entry-review/index.js.map +1 -0
  59. package/dist/pages/entry-review/marginalia.d.ts +28 -0
  60. package/dist/pages/entry-review/marginalia.d.ts.map +1 -0
  61. package/dist/pages/entry-review/marginalia.js +67 -0
  62. package/dist/pages/entry-review/marginalia.js.map +1 -0
  63. package/dist/pages/entry-review/not-found.d.ts +9 -0
  64. package/dist/pages/entry-review/not-found.d.ts.map +1 -0
  65. package/dist/pages/entry-review/not-found.js +34 -0
  66. package/dist/pages/entry-review/not-found.js.map +1 -0
  67. package/dist/pages/entry-review/outline-drawer.d.ts +12 -0
  68. package/dist/pages/entry-review/outline-drawer.d.ts.map +1 -0
  69. package/dist/pages/entry-review/outline-drawer.js +28 -0
  70. package/dist/pages/entry-review/outline-drawer.js.map +1 -0
  71. package/dist/pages/entry-review/shortcuts.d.ts +14 -0
  72. package/dist/pages/entry-review/shortcuts.d.ts.map +1 -0
  73. package/dist/pages/entry-review/shortcuts.js +36 -0
  74. package/dist/pages/entry-review/shortcuts.js.map +1 -0
  75. package/dist/pages/entry-review/version-strip.d.ts +33 -0
  76. package/dist/pages/entry-review/version-strip.d.ts.map +1 -0
  77. package/dist/pages/entry-review/version-strip.js +81 -0
  78. package/dist/pages/entry-review/version-strip.js.map +1 -0
  79. package/dist/pages/entry-review.d.ts +6 -21
  80. package/dist/pages/entry-review.d.ts.map +1 -1
  81. package/dist/pages/entry-review.js +6 -144
  82. package/dist/pages/entry-review.js.map +1 -1
  83. package/dist/pages/entry-review.ts +11 -181
  84. package/dist/pages/html.d.ts +2 -0
  85. package/dist/pages/html.d.ts.map +1 -1
  86. package/dist/pages/html.js +2 -0
  87. package/dist/pages/html.js.map +1 -1
  88. package/dist/pages/html.ts +4 -0
  89. package/dist/pages/index.d.ts.map +1 -1
  90. package/dist/pages/index.js +6 -10
  91. package/dist/pages/index.js.map +1 -1
  92. package/dist/pages/index.ts +6 -10
  93. package/dist/pages/layout.d.ts.map +1 -1
  94. package/dist/pages/layout.js +6 -0
  95. package/dist/pages/layout.js.map +1 -1
  96. package/dist/pages/layout.ts +7 -0
  97. package/dist/pages/review-scrapbook-drawer.d.ts +7 -0
  98. package/dist/pages/review-scrapbook-drawer.d.ts.map +1 -1
  99. package/dist/pages/review-scrapbook-drawer.js +45 -6
  100. package/dist/pages/review-scrapbook-drawer.js.map +1 -1
  101. package/dist/pages/review-scrapbook-drawer.ts +50 -6
  102. package/dist/pages/review.d.ts.map +1 -1
  103. package/dist/pages/review.js +168 -41
  104. package/dist/pages/review.js.map +1 -1
  105. package/dist/pages/review.ts +192 -41
  106. package/dist/pages/scrapbook.d.ts +7 -14
  107. package/dist/pages/scrapbook.d.ts.map +1 -1
  108. package/dist/pages/scrapbook.js +548 -192
  109. package/dist/pages/scrapbook.js.map +1 -1
  110. package/dist/pages/scrapbook.ts +588 -221
  111. package/dist/pages/shortform-review.d.ts +32 -0
  112. package/dist/pages/shortform-review.d.ts.map +1 -0
  113. package/dist/pages/shortform-review.js +270 -0
  114. package/dist/pages/shortform-review.js.map +1 -0
  115. package/dist/pages/shortform-review.ts +342 -0
  116. package/dist/pages/shortform.js +1 -1
  117. package/dist/pages/shortform.js.map +1 -1
  118. package/dist/pages/shortform.ts +1 -1
  119. package/dist/routes/api.d.ts +8 -0
  120. package/dist/routes/api.d.ts.map +1 -1
  121. package/dist/routes/api.js +115 -0
  122. package/dist/routes/api.js.map +1 -1
  123. package/dist/routes/entry-annotation-body.d.ts +26 -0
  124. package/dist/routes/entry-annotation-body.d.ts.map +1 -0
  125. package/dist/routes/entry-annotation-body.js +152 -0
  126. package/dist/routes/entry-annotation-body.js.map +1 -0
  127. package/dist/server.d.ts +1 -1
  128. package/dist/server.d.ts.map +1 -1
  129. package/dist/server.js +56 -196
  130. package/dist/server.js.map +1 -1
  131. package/package.json +4 -4
@@ -1,247 +1,609 @@
1
1
  /**
2
- * Scrapbook viewer — `/dev/scrapbook/:site/<path>` (path may include `/`).
2
+ * Scrapbook viewer — `/dev/scrapbook/:site/<path>`.
3
3
  *
4
- * Reads the scrapbook directory at the given path and lists every
5
- * file with type chips + relative timestamps, plus secret items
6
- * (inside `scrapbook/secret/`) in a quiet second section. Empty
7
- * scrapbooks render an empty state with quick-add affordances.
4
+ * Issue #161 redesign: aside-left folder card with numbered item list,
5
+ * vertical card grid with per-kind colored ribbons + always-visible foot
6
+ * toolbar + per-kind preview rendering, drop zone, secret section,
7
+ * single-expanded card invariant, aside cross-linking.
8
8
  *
9
- * The `path` argument is the hierarchical address of the scrapbook
10
- * any slash-separated kebab-case identifier under the site's
11
- * contentDir. It does not need to correspond to a calendar entry;
12
- * organizational nodes (e.g. `the-outbound/characters` with no
13
- * own README) can host their own scrapbooks too.
14
- *
15
- * Port of `pages/dev/scrapbook/[site]/[slug].astro`. Layout swap
16
- * (Astro `<Layout>` → studio shell) and CSS link added; structurally
17
- * similar otherwise.
9
+ * Mockup: docs/superpowers/frontend-design/2026-05-02-review-redesign/scrapbook-redesign.html
10
+ * Spec: docs/superpowers/specs/2026-05-02-scrapbook-redesign-impl-spec.md
18
11
  */
19
12
 
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { readCalendar } from '@deskwork/core/calendar';
15
+ import { findEntry } from '@deskwork/core/calendar-mutations';
16
+ import { resolveCalendarPath } from '@deskwork/core/paths';
20
17
  import {
21
18
  formatRelativeTime,
22
19
  formatSize,
23
20
  listScrapbook,
21
+ scrapbookFilePath,
24
22
  type ScrapbookItem,
23
+ type ScrapbookItemKind,
25
24
  } from '@deskwork/core/scrapbook';
26
25
  import type { StudioContext } from '../routes/api.ts';
27
26
  import { html, unsafe, type RawHtml } from './html.ts';
28
27
  import { layout } from './layout.ts';
29
28
  import { renderEditorialFolio } from './chrome.ts';
30
29
 
31
- interface RenderItemRowOptions {
32
- /** Mark the row visually as belonging to the secret section. */
33
- secret?: boolean;
34
- /**
35
- * When true, render disclosure controls + toolbar. Both public AND
36
- * secret rows now ship the toolbar (#28); the per-section tools
37
- * include a "Mark secret" / "Mark public" toggle that the client
38
- * resolves into a cross-section rename. Pre-#28 secret rows were
39
- * read-only — that decision is reversed here so operators have full
40
- * CRUD over secret/ items from the standalone viewer.
41
- */
42
- withTools?: boolean;
30
+ const KIND_LABEL: Record<ScrapbookItemKind, string> = {
31
+ md: 'MD',
32
+ img: 'IMG',
33
+ json: 'JSON',
34
+ js: 'JS',
35
+ txt: 'TXT',
36
+ other: '·',
37
+ };
38
+
39
+ function escapeHtml(s: string): string {
40
+ return s
41
+ .replace(/&/g, '&amp;')
42
+ .replace(/</g, '&lt;')
43
+ .replace(/>/g, '&gt;')
44
+ .replace(/"/g, '&quot;')
45
+ .replace(/'/g, '&#39;');
46
+ }
47
+
48
+ /**
49
+ * Strip a YAML frontmatter block from the top of an md file. Only strips
50
+ * the leading `---\n...\n---\n` block; body-level `---` separators (Setext
51
+ * H2 underline, thematic break) are preserved because the function only
52
+ * looks at the first 4 chars for the opener.
53
+ */
54
+ function stripFrontmatter(text: string): string {
55
+ if (!text.startsWith('---\n')) return text;
56
+ const closeIdx = text.indexOf('\n---\n', 4);
57
+ if (closeIdx < 0) return text;
58
+ return text.slice(closeIdx + 5).replace(/^\n+/, '');
59
+ }
60
+
61
+ /**
62
+ * Build the closed-state preview excerpt for md/json/txt. Returns null
63
+ * when there's nothing useful to render — empty file, frontmatter-only
64
+ * file, or binary masquerading as text — so the caller can omit the
65
+ * preview block entirely (matches "other" kind treatment, avoids the
66
+ * 6rem min-height void).
67
+ *
68
+ * For json: pretty-print via JSON.parse + JSON.stringify(_, null, 2) so
69
+ * minified single-line files still render multi-line. Falls back to raw
70
+ * content on parse error (bad JSON is still readable as text).
71
+ *
72
+ * Binary detection: NUL byte presence after UTF-8 decode. Real text
73
+ * almost never has NUL; binary files have it within the first KB.
74
+ */
75
+ function previewExcerpt(buf: Buffer, kind: 'md' | 'json' | 'txt'): string | null {
76
+ let text = buf.subarray(0, Math.min(buf.byteLength, 2400)).toString('utf-8');
77
+ if (text.indexOf('\0') >= 0) return null;
78
+ if (kind === 'md') text = stripFrontmatter(text);
79
+ if (kind === 'json') {
80
+ try {
81
+ const fullText = buf.toString('utf-8');
82
+ text = JSON.stringify(JSON.parse(fullText), null, 2);
83
+ } catch {
84
+ // Invalid JSON — fall through to the raw-text excerpt below.
85
+ }
86
+ }
87
+ const excerpt = text.split('\n').slice(0, 8).join('\n').slice(0, 600);
88
+ if (excerpt.trim() === '') return null;
89
+ return excerpt;
90
+ }
91
+
92
+ /**
93
+ * Count lines in a text file: number of `\n` bytes plus 1 if the last
94
+ * byte isn't `\n` (so a 3-line file whether or not it has a trailing
95
+ * newline reports 3).
96
+ */
97
+ function countLines(buf: Buffer): number {
98
+ let count = 0;
99
+ for (const b of buf) if (b === 0x0a) count++;
100
+ if (buf.length > 0 && buf[buf.length - 1] !== 0x0a) count++;
101
+ return count;
102
+ }
103
+
104
+ /**
105
+ * Count top-level keys in a JSON object. Returns null if the file is not
106
+ * valid JSON or its root is not a plain object (arrays, primitives →
107
+ * null; caller renders no extra meta).
108
+ */
109
+ function countJsonKeys(buf: Buffer): number | null {
110
+ try {
111
+ const obj: unknown = JSON.parse(buf.toString('utf-8'));
112
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
113
+ return Object.keys(obj).length;
114
+ }
115
+ return null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ interface ImageDimensions { readonly width: number; readonly height: number; }
122
+
123
+ /**
124
+ * Read width × height from a buffer. Recognizes PNG, JPEG, WebP, and
125
+ * GIF; returns null for unrecognized signatures or truncated/malformed
126
+ * buffers. Used by the scrapbook card meta to render `{W} × {H}` next
127
+ * to the image kind chip.
128
+ *
129
+ * Each format is parsed from its file-header structure (no external
130
+ * dependency); each branch returns null on any unexpected byte rather
131
+ * than throwing, so a corrupt image still renders with empty meta.
132
+ */
133
+ function readImageDimensions(buf: Buffer): ImageDimensions | null {
134
+ if (buf.length < 12) return null;
135
+ // PNG: 89 50 4E 47 0D 0A 1A 0A then IHDR (chunk-length 4, "IHDR" 4,
136
+ // width 4, height 4 — width@16, height@20).
137
+ if (
138
+ buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47
139
+ ) {
140
+ if (buf.length < 24) return null;
141
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
142
+ }
143
+ // GIF: "GIF87a" or "GIF89a" + logical screen descriptor (width LE @6,
144
+ // height LE @8).
145
+ if (
146
+ buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 &&
147
+ buf[3] === 0x38 && (buf[4] === 0x37 || buf[4] === 0x39) && buf[5] === 0x61
148
+ ) {
149
+ if (buf.length < 10) return null;
150
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
151
+ }
152
+ // JPEG: starts FF D8. Width/height live in a Start-Of-Frame marker
153
+ // (FF C0–CF, excluding DHT C4 / JPG C8 / DAC CC). Walk markers
154
+ // skipping their payloads until the SOF is found.
155
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
156
+ return readJpegDimensions(buf);
157
+ }
158
+ // WebP: "RIFF" {size} "WEBP" then a chunk (VP8 / VP8L / VP8X).
159
+ if (
160
+ buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
161
+ buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50
162
+ ) {
163
+ return readWebpDimensions(buf);
164
+ }
165
+ return null;
166
+ }
167
+
168
+ function readJpegDimensions(buf: Buffer): ImageDimensions | null {
169
+ // Marker walk: skip FF D8 (SOI), then each marker is FF Xn followed
170
+ // by a 2-byte big-endian segment length (which includes its own 2
171
+ // bytes). The SOF segment's payload is: 1 byte precision, 2 bytes
172
+ // height, 2 bytes width (the rest is component info).
173
+ let i = 2;
174
+ while (i + 4 <= buf.length) {
175
+ if (buf[i] !== 0xff) return null;
176
+ let marker = buf[i + 1] ?? 0;
177
+ // Skip fill bytes (0xff padding before the actual marker byte).
178
+ while (marker === 0xff && i + 2 < buf.length) {
179
+ i++;
180
+ marker = buf[i + 1] ?? 0;
181
+ }
182
+ i += 2;
183
+ // Standalone markers (no length): RST0–7 (D0–D7) and SOI/EOI/TEM.
184
+ if (marker === 0xd9 || marker === 0xd8 || marker === 0x01) return null;
185
+ if (marker >= 0xd0 && marker <= 0xd7) continue;
186
+ if (i + 2 > buf.length) return null;
187
+ const segLen = buf.readUInt16BE(i);
188
+ // SOF markers carry the dimensions. Exclusions per JPEG spec:
189
+ // C4 (DHT — Huffman tables), C8 (JPG reserved), CC (DAC — arithmetic
190
+ // coding conditioning).
191
+ const isSof =
192
+ marker >= 0xc0 && marker <= 0xcf &&
193
+ marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
194
+ if (isSof) {
195
+ if (i + 7 > buf.length) return null;
196
+ return {
197
+ width: buf.readUInt16BE(i + 5),
198
+ height: buf.readUInt16BE(i + 3),
199
+ };
200
+ }
201
+ i += segLen;
202
+ }
203
+ return null;
43
204
  }
44
205
 
45
- function renderItemRow(
206
+ function readWebpDimensions(buf: Buffer): ImageDimensions | null {
207
+ // Three sub-formats (per RFC 6386 / VP8L spec / WebP container spec):
208
+ // - "VP8 " (lossy): header @20-22: signature 9D 01 2A, then
209
+ // 14-bit width LE @23, 14-bit height LE @25
210
+ // (each masked to 14 bits — top 2 bits are
211
+ // horizontal/vertical scale).
212
+ // - "VP8L" (lossless): header @20: signature 0x2F, then 32 bits
213
+ // packed: width-1 (14 bits) + height-1 (14
214
+ // bits) + alpha-flag + version (4 bits).
215
+ // - "VP8X" (extended): flags @20, reserved @21-23, width-1 (24
216
+ // bits LE) @24, height-1 (24 bits LE) @27.
217
+ // Need at least the RIFF header + chunk fourcc; per-variant length
218
+ // checks below cover the variant-specific payload sizes.
219
+ if (buf.length < 16) return null;
220
+ const fourcc = buf.subarray(12, 16).toString('ascii');
221
+ if (fourcc === 'VP8 ') {
222
+ if (buf.length < 30) return null;
223
+ if (buf[23] !== 0x9d || buf[24] !== 0x01 || buf[25] !== 0x2a) return null;
224
+ const w = buf.readUInt16LE(26) & 0x3fff;
225
+ const h = buf.readUInt16LE(28) & 0x3fff;
226
+ return { width: w, height: h };
227
+ }
228
+ if (fourcc === 'VP8L') {
229
+ if (buf.length < 25) return null;
230
+ if (buf[20] !== 0x2f) return null;
231
+ // Read 4 bytes LE at offset 21, then unpack.
232
+ const packed = buf.readUInt32LE(21);
233
+ const widthMinus1 = packed & 0x3fff;
234
+ const heightMinus1 = (packed >>> 14) & 0x3fff;
235
+ return { width: widthMinus1 + 1, height: heightMinus1 + 1 };
236
+ }
237
+ if (fourcc === 'VP8X') {
238
+ if (buf.length < 30) return null;
239
+ // 24-bit little-endian: low byte + (mid << 8) + (high << 16).
240
+ const w =
241
+ ((buf[24] ?? 0) | ((buf[25] ?? 0) << 8) | ((buf[26] ?? 0) << 16)) + 1;
242
+ const h =
243
+ ((buf[27] ?? 0) | ((buf[28] ?? 0) << 8) | ((buf[29] ?? 0) << 16)) + 1;
244
+ return { width: w, height: h };
245
+ }
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * Compute the per-kind extra meta string shown after the kind chip + size:
251
+ * md / txt → "{N} lines"
252
+ * json → "{N} keys" (root must be a plain object; otherwise empty)
253
+ * img → "{W} × {H}" (PNG only; other formats → empty)
254
+ * other → empty
255
+ *
256
+ * ENOENT (race-window with delete) returns empty so the card still
257
+ * renders; other errors propagate to the page renderer.
258
+ */
259
+ function computeKindMeta(
260
+ ctx: StudioContext,
261
+ site: string,
262
+ path: string,
46
263
  item: ScrapbookItem,
47
- index: number,
48
- opts: RenderItemRowOptions = {},
49
- ): RawHtml {
50
- const { secret = false, withTools = true } = opts;
51
- const editBtn =
52
- withTools && item.kind === 'md'
53
- ? unsafe(html`<button type="button" class="scrapbook-tool" data-action="edit">edit</button>`)
54
- : '';
55
- const seq = String(index + 1).padStart(2, '0');
56
- const kindLabel = item.kind === 'other' ? '·' : item.kind.toUpperCase();
57
- const idPrefix = secret ? 'secret-' : '';
58
- const dataSecret = secret ? ' data-secret="true"' : '';
59
- // The "mark secret/public" toggle is the cross-section rename
60
- // affordance. The button label flips with the source section.
61
- const sectionToggleLabel = secret ? 'mark public' : 'mark secret';
62
- const toolbar = withTools
63
- ? unsafe(html`<div class="scrapbook-toolbar" data-toolbar>
64
- ${editBtn}
65
- <button type="button" class="scrapbook-tool" data-action="rename">rename</button>
66
- <button type="button" class="scrapbook-tool" data-action="toggle-secret">${sectionToggleLabel}</button>
67
- <button type="button" class="scrapbook-tool scrapbook-tool--delete" data-action="delete">delete</button>
68
- </div>`)
69
- : '';
70
- return unsafe(html`
71
- <li class="scrapbook-item${secret ? ' scrapbook-item--secret' : ''}" data-state="closed" data-open="false"
72
- data-filename="${item.name}" data-kind="${item.kind}"
73
- data-size="${item.size}" data-mtime="${item.mtime}"${unsafe(dataSecret)}
74
- id="${idPrefix}item-${encodeURIComponent(item.name)}">
75
- <button type="button" class="scrapbook-item-header" aria-expanded="false">
76
- <span class="scrapbook-seq" aria-hidden="true">§ ${seq}</span>
77
- <span class="scrapbook-kind scrapbook-kind--${item.kind}" aria-hidden="true">${kindLabel}</span>
78
- <span class="scrapbook-filename" data-filename-cell>${item.name}</span>
79
- <time class="scrapbook-mtime" datetime="${item.mtime}">${formatRelativeTime(item.mtime)}</time>
80
- <span class="scrapbook-disclosure" aria-hidden="true">▸</span>
81
- </button>
82
- ${toolbar}
83
- <div class="scrapbook-perforation" aria-hidden="true"></div>
84
- <div class="scrapbook-item-body" data-body>
85
- <div data-body-content></div>
86
- </div>
87
- </li>`);
264
+ ): string {
265
+ if (item.kind !== 'md' && item.kind !== 'txt' && item.kind !== 'json' && item.kind !== 'img') {
266
+ return '';
267
+ }
268
+ let buf: Buffer;
269
+ try {
270
+ const fullPath = scrapbookFilePath(ctx.projectRoot, ctx.config, site, path, item.name);
271
+ buf = readFileSync(fullPath);
272
+ } catch (e) {
273
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT') return '';
274
+ throw e;
275
+ }
276
+ if (item.kind === 'md' || item.kind === 'txt') return `${countLines(buf)} lines`;
277
+ if (item.kind === 'json') {
278
+ const keys = countJsonKeys(buf);
279
+ return keys !== null ? `${keys} keys` : '';
280
+ }
281
+ // img
282
+ const dims = readImageDimensions(buf);
283
+ return dims ? `${dims.width} × ${dims.height}` : '';
88
284
  }
89
285
 
90
286
  /**
91
- * Build a hierarchical breadcrumb from the path. Each segment links to
92
- * the scrapbook view for its prefix. The root segment (site) just
93
- * goes back to the editorial dashboard.
287
+ * Server-side preview for the closed-state card. Img bg-frame URL;
288
+ * md italic Newsreader excerpt with frontmatter stripped; json mono
289
+ * pre with parse-then-stringify pretty-print; txt → mono pre raw excerpt.
290
+ * Other / empty / binary-as-text → no preview block.
94
291
  */
95
- function renderBreadcrumb(site: string, path: string): RawHtml {
96
- const segments = path.split('/');
97
- const links: string[] = [];
98
- for (let i = 0; i < segments.length; i++) {
99
- const prefix = segments.slice(0, i + 1).join('/');
100
- const isLast = i === segments.length - 1;
101
- if (isLast) {
102
- links.push(html`<span class="scrapbook-breadcrumb-current">${segments[i]}</span>`);
103
- } else {
104
- links.push(
105
- html`<a class="scrapbook-breadcrumb-link" href="/dev/scrapbook/${site}/${prefix}">${segments[i]}</a>`,
106
- );
292
+ function renderPreview(
293
+ ctx: StudioContext,
294
+ site: string,
295
+ path: string,
296
+ item: ScrapbookItem,
297
+ opts: { secret?: boolean } = {},
298
+ ): RawHtml {
299
+ const { secret = false } = opts;
300
+ if (item.kind === 'img') {
301
+ const params = new URLSearchParams({ site, path, name: item.name });
302
+ if (secret) params.set('secret', '1');
303
+ const url = `/api/dev/scrapbook-file?${params.toString()}`;
304
+ return unsafe(html`
305
+ <div class="scrap-preview scrap-preview--img" aria-hidden="true">
306
+ <div class="scrap-preview--img-frame" style="background-image: url(&quot;${url}&quot;);"></div>
307
+ </div>`);
308
+ }
309
+ if (item.kind !== 'md' && item.kind !== 'txt' && item.kind !== 'json') {
310
+ return unsafe('');
311
+ }
312
+ try {
313
+ const fullPath = scrapbookFilePath(
314
+ ctx.projectRoot,
315
+ ctx.config,
316
+ site,
317
+ path,
318
+ item.name,
319
+ secret ? { secret: true } : {},
320
+ );
321
+ const buf = readFileSync(fullPath);
322
+ const excerpt = previewExcerpt(buf, item.kind);
323
+ if (excerpt === null) return unsafe('');
324
+ const safe = escapeHtml(excerpt);
325
+ if (item.kind === 'json' || item.kind === 'txt') {
326
+ return unsafe(html`
327
+ <pre class="scrap-preview scrap-preview--mono" aria-hidden="true">${unsafe(safe)}</pre>`);
328
+ }
329
+ return unsafe(html`
330
+ <div class="scrap-preview scrap-preview-md" aria-hidden="true"><p>${unsafe(safe)}</p></div>`);
331
+ } catch (e) {
332
+ // ENOENT = file disappeared between listScrapbook and this read (race
333
+ // window with delete); rendering an empty preview is the right call.
334
+ // Anything else (EACCES, EISDIR, encoding bugs) propagates so the
335
+ // operator sees a real error instead of a silently-broken page.
336
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
337
+ return unsafe('');
107
338
  }
339
+ throw e;
108
340
  }
109
- const sep = '<span class="scrapbook-breadcrumb-sep" aria-hidden="true">›</span>';
110
- const joined = links.join(`\n${sep}\n`);
341
+ }
342
+
343
+ interface KindCounts {
344
+ all: number;
345
+ md: number;
346
+ img: number;
347
+ json: number;
348
+ js: number;
349
+ txt: number;
350
+ other: number;
351
+ }
352
+
353
+ function countByKind(items: readonly ScrapbookItem[]): KindCounts {
354
+ const counts: KindCounts = {
355
+ all: items.length,
356
+ md: 0,
357
+ img: 0,
358
+ json: 0,
359
+ js: 0,
360
+ txt: 0,
361
+ other: 0,
362
+ };
363
+ for (const i of items) counts[i.kind]++;
364
+ return counts;
365
+ }
366
+
367
+ function renderFilterChips(counts: KindCounts): RawHtml {
368
+ const chip = (kind: keyof KindCounts, label: string, isAll = false): RawHtml =>
369
+ unsafe(html`
370
+ <button class="scrap-filter" type="button" data-filter="${kind}"
371
+ aria-pressed="${isAll ? 'true' : 'false'}">${label} · ${counts[kind]}</button>`);
111
372
  return unsafe(html`
112
- <nav class="scrapbook-breadcrumb" aria-label="scrapbook hierarchy">
113
- <a class="scrapbook-breadcrumb-link" href="/dev/editorial-studio">${site}</a>
114
- <span class="scrapbook-breadcrumb-sep" aria-hidden="true">›</span>
115
- ${unsafe(joined)}
373
+ <div class="scrap-filters" role="toolbar" aria-label="filter by kind">
374
+ ${chip('all', 'all', true)}
375
+ ${chip('md', 'md')}
376
+ ${chip('img', 'img')}
377
+ ${chip('json', 'json')}
378
+ ${chip('txt', 'txt')}
379
+ ${chip('other', 'other')}
380
+ </div>`);
381
+ }
382
+
383
+ function renderSearch(): RawHtml {
384
+ return unsafe(html`
385
+ <div class="scrap-search">
386
+ <input type="search" placeholder="filter by name or content" aria-label="filter scrapbook" data-scrap-search />
387
+ <span class="scrap-search-kbd">/</span>
388
+ </div>`);
389
+ }
390
+
391
+ function renderBreadcrumb(site: string, path: string): RawHtml {
392
+ const segments = path.split('/').filter(Boolean);
393
+ const last = segments[segments.length - 1] ?? path;
394
+ return unsafe(html`
395
+ <nav class="scrap-breadcrumb" aria-label="hierarchy">
396
+ <a href="/dev/content/${site}">${site}</a><span class="sep">›</span>
397
+ <b>${last}</b>
116
398
  </nav>`);
117
399
  }
118
400
 
119
- function renderIndexSidebar(items: readonly ScrapbookItem[], site: string, path: string): RawHtml {
120
- const totalBytes = items.reduce((acc, item) => acc + item.size, 0);
121
- const lastModified =
122
- items.length > 0
123
- ? items.reduce((a, b) => (a.mtime > b.mtime ? a : b)).mtime
124
- : null;
401
+ function renderAside(
402
+ site: string,
403
+ path: string,
404
+ items: readonly ScrapbookItem[],
405
+ totalSize: number,
406
+ lastModified: string | null,
407
+ secretCount: number,
408
+ reviewLink: string | null,
409
+ ): RawHtml {
410
+ const lastModifiedLabel = lastModified ? formatRelativeTime(lastModified) : '—';
411
+ const publicCount = items.length;
412
+ const sizeLabel = formatSize(totalSize);
413
+ const folderLabel = path.split('/').filter(Boolean).pop() ?? path;
414
+ const fullPath = `${site}/${path}/scrapbook/`;
415
+ // #168 Phase 34 ship-pass — when this scrapbook belongs to a tracked
416
+ // calendar entry, expose a "← back to review" link so the operator
417
+ // who arrived from the entry-review surface (or via the dashboard's
418
+ // scrapbook chip) has an obvious path back. Pre-fix the only nav
419
+ // affordance was the breadcrumb's site link, which lands on the
420
+ // content tree, not the entry-review.
421
+ const backLink: RawHtml = reviewLink !== null
422
+ ? unsafe(html`<p class="scrap-aside-back"><a href="${reviewLink}">← back to review</a></p><hr />`)
423
+ : unsafe('');
125
424
  return unsafe(html`
126
- <aside class="scrapbook-index">
127
- <p class="scrapbook-index-kicker">
128
- <span aria-hidden="true">§</span> The folder
425
+ <aside class="scrap-aside">
426
+ ${backLink}
427
+ <p class="scrap-aside-kicker"><em>§</em> The folder</p>
428
+ <h1 class="scrap-aside-title">${folderLabel}</h1>
429
+ <p class="scrap-aside-meta">${site}</p>
430
+ <hr />
431
+ <p class="scrap-aside-totals">
432
+ <strong>${publicCount}</strong> public ·
433
+ <strong>${secretCount}</strong> secret ·
434
+ <em>${sizeLabel}</em>
129
435
  </p>
130
- <p class="scrapbook-index-meta">${path}</p>
131
- <p class="scrapbook-index-meta">${site}</p>
436
+ <p class="scrap-aside-meta">last modified ${lastModifiedLabel}</p>
132
437
  <hr />
133
- <ol class="scrapbook-index-list" data-scrapbook-index>
134
- ${items.map(
135
- (item, i) => unsafe(html`<li data-index-for="${item.name}">
136
- <span class="scrapbook-index-num">No. ${String(i + 1).padStart(2, '0')}</span>
137
- <a href="#item-${encodeURIComponent(item.name)}">${item.name}</a>
138
- </li>`),
139
- )}
438
+ <ol class="scrap-aside-list" data-scrap-aside-list>
439
+ ${items.map((item, i) => {
440
+ const seq = String(i + 1).padStart(2, '0');
441
+ return unsafe(html`<li><span class="num">${seq}</span><a href="#item-${i + 1}" data-scrap-aside-link>${item.name}</a></li>`);
442
+ })}
140
443
  </ol>
141
444
  <hr />
142
- <p class="scrapbook-index-totals">${items.length} ${items.length === 1 ? 'item' : 'items'} · ${formatSize(totalBytes)}</p>
143
- ${
144
- lastModified
145
- ? unsafe(html`<p class="scrapbook-index-subtotal">last modified ${formatRelativeTime(lastModified)}</p>`)
146
- : ''
147
- }
148
- <hr />
149
- <div class="scrapbook-index-actions">
150
- <button type="button" class="scrapbook-index-btn" data-action="new-note">+ new note</button>
151
- <button type="button" class="scrapbook-index-btn" data-action="upload">+ upload file</button>
445
+ <div class="scrap-aside-actions">
446
+ <button class="scrap-aside-btn scrap-aside-btn--primary" type="button" data-action="new-note">+ new note</button>
447
+ <button class="scrap-aside-btn" type="button" data-action="upload">+ upload file</button>
152
448
  </div>
153
449
  <hr />
154
- <p class="scrapbook-index-path">${site}/${path}/scrapbook/</p>
450
+ <p class="scrap-aside-path">${fullPath}</p>
155
451
  </aside>`);
156
452
  }
157
453
 
158
- function renderEmpty(): RawHtml {
454
+ function renderCard(
455
+ ctx: StudioContext,
456
+ site: string,
457
+ path: string,
458
+ item: ScrapbookItem,
459
+ index: number,
460
+ opts: { secret?: boolean } = {},
461
+ ): RawHtml {
462
+ const { secret = false } = opts;
463
+ const seq = String(index + 1).padStart(2, '0');
464
+ const kindLabel = KIND_LABEL[item.kind];
465
+ const kindClass = item.kind === 'other' ? '' : `scrap-kind--${item.kind}`;
466
+ const time = item.mtime
467
+ ? html`<time class="scrap-time" datetime="${item.mtime}">${formatRelativeTime(item.mtime)}</time>`
468
+ : '';
469
+ const preview = renderPreview(ctx, site, path, item, { secret });
470
+ const kindMeta = computeKindMeta(ctx, site, path, item);
471
+ const kindMetaHtml: RawHtml = kindMeta
472
+ ? unsafe(html`<span>·</span><span>${kindMeta}</span>`)
473
+ : unsafe('');
474
+ const editBtn = item.kind === 'img'
475
+ ? unsafe('')
476
+ : unsafe(html`<button class="scrap-tool" type="button" data-action="edit">edit</button>`);
477
+ // Secret cards get id="secret-item-N" to disambiguate from public ids in
478
+ // restoreFromHash + aside cross-link lookups (F4 contract); the
479
+ // mark-secret action toggle reads "mark public" since clicking it moves
480
+ // the card OUT of the secret section.
481
+ const id = secret ? `secret-item-${index + 1}` : `item-${index + 1}`;
482
+ const markSecretLabel = secret ? 'mark public' : 'mark secret';
483
+ const dataSecretAttr = secret ? ' data-secret="true"' : '';
484
+ // #164 Phase 34b — small ⚿ glyph next to .scrap-name on secret
485
+ // cards. Provides visual continuity for the secret marker once a
486
+ // card is expanded (where it grows outside the grouped section's
487
+ // visual scope).
488
+ const secretGlyph: RawHtml = secret
489
+ ? unsafe(html`<span class="scrap-name-secret-mark" aria-label="secret" title="secret — never published">⚿</span>`)
490
+ : unsafe('');
159
491
  return unsafe(html`
160
- <section class="scrapbook-empty">
161
- <p>
162
- This scrapbook is empty. Write the first note, or drop a file
163
- anywhere on this page.
164
- </p>
165
- <div class="scrapbook-empty-actions">
166
- <button type="button" class="scrapbook-index-btn" data-action="new-note">+ new note</button>
167
- <button type="button" class="scrapbook-index-btn" data-action="upload">+ upload file</button>
492
+ <li class="scrap-card" data-kind="${item.kind}" data-state="closed" id="${id}"${unsafe(dataSecretAttr)}>
493
+ <div class="scrap-card-head">
494
+ <span class="scrap-seq">N° ${seq}</span>
495
+ ${secretGlyph}
496
+ <span class="scrap-name" data-action="open">${item.name}</span>
497
+ ${unsafe(time)}
168
498
  </div>
169
- </section>`);
499
+ <div class="scrap-card-meta">
500
+ <span class="scrap-kind ${kindClass}">${kindLabel}</span>
501
+ <span class="scrap-size">${formatSize(item.size)}</span>
502
+ ${kindMetaHtml}
503
+ </div>
504
+ ${preview}
505
+ <div class="scrap-card-foot">
506
+ <button class="scrap-tool scrap-tool--primary" type="button" data-action="open">open</button>
507
+ ${editBtn}
508
+ <button class="scrap-tool" type="button" data-action="rename">rename</button>
509
+ <button class="scrap-tool" type="button" data-action="mark-secret">${markSecretLabel}</button>
510
+ <span class="spacer"></span>
511
+ <button class="scrap-tool scrap-tool--delete" type="button" data-action="delete">delete</button>
512
+ </div>
513
+ </li>`);
170
514
  }
171
515
 
172
- function renderReadingPanel(items: readonly ScrapbookItem[]): RawHtml {
516
+ /**
517
+ * Inline new-note composer (Phase 34b — #166).
518
+ *
519
+ * Mirrors the pre-F1 inline composer (`44094ee^:scrapbook.ts:274-294`),
520
+ * adapted to the F1 `.scrap-*` design vocabulary. Hidden by default;
521
+ * the aside's `+ new note` button reveals it via the client wire-up.
522
+ *
523
+ * Per `.claude/rules/affordance-placement.md`: component-attached to
524
+ * the page (not a generic toolbar), placed where the resulting note
525
+ * will appear in sorted position. Direct manipulation: in-page form,
526
+ * filename + body + secret toggle visible inline, Cmd/Ctrl+S saves,
527
+ * Esc cancels. Replaces the F1 `window.prompt()` regression (#166).
528
+ */
529
+ function renderComposer(): RawHtml {
173
530
  return unsafe(html`
174
- <section class="scrapbook-reading">
175
- <form class="scrapbook-composer" data-scrapbook-composer hidden>
176
- <div class="scrapbook-composer-header">
177
- <span class="scrapbook-composer-seq" aria-hidden="true">✎</span>
178
- <span class="scrapbook-composer-kind">NEW</span>
179
- <input type="text" class="scrapbook-composer-filename" data-composer-filename
180
- placeholder="note-name.md" aria-label="new note filename" />
181
- <div class="scrapbook-editor-footer scrapbook-composer-actions">
182
- <label class="scrapbook-secret-toggle" title="save under scrapbook/secret/ — never published">
183
- <input type="checkbox" data-composer-secret />
184
- <span>secret</span>
185
- </label>
186
- <button type="button" class="scrapbook-tool" data-action="composer-cancel">cancel</button>
187
- <button type="submit" class="scrapbook-tool scrapbook-tool--primary" data-action="composer-save">save →</button>
188
- </div>
189
- </div>
190
- <div class="scrapbook-composer-body">
191
- <textarea data-composer-body
192
- placeholder="Write the note in markdown. Cmd/Ctrl+S saves."
193
- aria-label="new note body"></textarea>
194
- </div>
195
- </form>
196
- <ol class="scrapbook-items" data-scrapbook-items>
197
- ${items.map((item, i) => renderItemRow(item, i))}
198
- </ol>
199
- <div class="scrapbook-drop" data-scrapbook-drop role="button" tabindex="0"
200
- aria-label="upload a file to the scrapbook">
201
- <span class="scrapbook-drop-label">── drop a file here, or pick one ──</span>
202
- <input type="file" data-scrapbook-file-input
203
- accept="image/*,application/json,text/plain,text/markdown,.md,.json,.txt" />
204
- <label class="scrapbook-secret-toggle scrapbook-secret-toggle--upload"
205
- title="save the upload under scrapbook/secret/ — never published">
206
- <input type="checkbox" data-upload-secret />
207
- <span>upload as secret</span>
531
+ <form class="scrap-composer" data-scrap-composer hidden>
532
+ <header class="scrap-composer-head">
533
+ <span class="scrap-composer-glyph" aria-hidden="true">✎</span>
534
+ <span class="scrap-composer-kicker">NEW NOTE</span>
535
+ <input type="text" class="scrap-composer-filename" data-composer-filename
536
+ placeholder="note-name.md" aria-label="new note filename" />
537
+ <label class="scrap-composer-secret" title="save under scrapbook/secret/ — never published">
538
+ <input type="checkbox" data-composer-secret />
539
+ <span>secret</span>
208
540
  </label>
209
- </div>
210
- </section>`);
541
+ <button class="scrap-tool" type="button" data-action="composer-cancel">cancel</button>
542
+ <button class="scrap-tool scrap-tool--primary" type="submit" data-action="composer-save">save →</button>
543
+ </header>
544
+ <textarea class="scrap-composer-body" data-composer-body
545
+ placeholder="Write the note in markdown. Cmd/Ctrl+S saves, Esc cancels."
546
+ aria-label="new note body" rows="8"></textarea>
547
+ </form>`);
211
548
  }
212
549
 
213
- /**
214
- * Quiet second section listing items inside `scrapbook/secret/`. Read-
215
- * only in v1 — operators populate the directory by hand or via the
216
- * core API; the studio surface just shows what's there. The "private"
217
- * badge gives unmistakable visual differentiation from the public
218
- * items above.
219
- */
220
- function renderSecretSection(items: readonly ScrapbookItem[]): RawHtml {
550
+ function renderDropZone(): RawHtml {
551
+ return unsafe(html`
552
+ <div class="scrap-drop" role="button" tabindex="0" data-action="upload"
553
+ aria-label="Drop a file here, or press Enter to pick one">
554
+ ── drop a file here, or pick one ──
555
+ </div>`);
556
+ }
557
+
558
+ function renderSecretSection(
559
+ ctx: StudioContext,
560
+ site: string,
561
+ path: string,
562
+ secretItems: readonly ScrapbookItem[],
563
+ ): RawHtml {
564
+ if (secretItems.length === 0) return unsafe('');
565
+ const cards = secretItems.map((item, i) => renderCard(ctx, site, path, item, i, { secret: true }));
221
566
  return unsafe(html`
222
- <section class="scrapbook-secret" data-scrapbook-secret>
223
- <header class="scrapbook-secret-header">
224
- <span class="scrapbook-secret-mark" aria-hidden="true">⚿</span>
225
- <h2 class="scrapbook-secret-title">Secret</h2>
226
- <span class="scrapbook-secret-badge" aria-label="private — never published">
227
- private
228
- </span>
229
- <span class="scrapbook-secret-count">
230
- ${items.length} ${items.length === 1 ? 'item' : 'items'}
231
- </span>
567
+ <section class="scrap-secret" aria-label="secret items">
568
+ <header class="scrap-secret-head">
569
+ <span class="scrap-secret-mark" aria-hidden="true">⚿</span>
570
+ <h2 class="scrap-secret-title">Secret</h2>
571
+ <span class="scrap-secret-badge">private — never published</span>
232
572
  </header>
233
- <p class="scrapbook-secret-help">
234
- Items inside <code>scrapbook/secret/</code>. Excluded from the
235
- public site by the host's content-collection patterns.
236
- </p>
237
- <ol class="scrapbook-items scrapbook-items--secret">
238
- ${items.map((item, i) =>
239
- renderItemRow(item, i, { secret: true, withTools: true }),
240
- )}
573
+ <ol class="scrap-cards">
574
+ ${cards}
241
575
  </ol>
242
576
  </section>`);
243
577
  }
244
578
 
579
+ /**
580
+ * #168 Phase 34 ship-pass — when the scrapbook path matches a tracked
581
+ * calendar entry with a stamped UUID, return the entry-keyed review
582
+ * URL so the aside can render a "← back to review" link. Returns null
583
+ * when no entry matches (organizational subdirs, ad-hoc paths, or
584
+ * pre-doctor entries lacking an id) — the link is then omitted.
585
+ *
586
+ * Failures (calendar absent, parse error) fall through to null so a
587
+ * transient calendar issue never blocks the scrapbook render.
588
+ */
589
+ function lookupEntryReviewLink(
590
+ ctx: StudioContext,
591
+ site: string,
592
+ path: string,
593
+ ): string | null {
594
+ if (!(site in ctx.config.sites)) return null;
595
+ try {
596
+ const calendarPath = resolveCalendarPath(ctx.projectRoot, ctx.config, site);
597
+ if (!existsSync(calendarPath)) return null;
598
+ const cal = readCalendar(calendarPath);
599
+ const entry = findEntry(cal, path);
600
+ if (!entry || !entry.id) return null;
601
+ return `/dev/editorial-review/entry/${entry.id}`;
602
+ } catch {
603
+ return null;
604
+ }
605
+ }
606
+
245
607
  export function renderScrapbookPage(
246
608
  ctx: StudioContext,
247
609
  site: string,
@@ -253,46 +615,51 @@ export function renderScrapbookPage(
253
615
  if (!(site in ctx.config.sites)) {
254
616
  throw new Error(`unknown site: ${site}`);
255
617
  }
256
- const summary = listScrapbook(ctx.projectRoot, ctx.config, site, path);
257
- const items = summary.items;
258
- const secretItems = summary.secretItems;
259
-
260
- const publicBlock =
261
- items.length === 0
262
- ? renderEmpty().__raw
263
- : renderReadingPanel(items).__raw + renderIndexSidebar(items, site, path).__raw;
264
-
265
- const secretBlock = secretItems.length > 0 ? renderSecretSection(secretItems).__raw : '';
266
-
618
+ // listScrapbook returns { exists: false, items: [] } for missing dirs
619
+ // (packages/core/src/scrapbook.ts:337-339), so an empty scrapbook is not
620
+ // an error path. Real errors (slug validation, scrapbookDir resolution
621
+ // failures, FS permission issues) propagate to the studio's error handler.
622
+ const result = listScrapbook(ctx.projectRoot, ctx.config, site, path);
623
+ const items = result.items;
624
+ const secretItems = result.secretItems;
625
+ const totalSize = items.reduce((s, i) => s + i.size, 0);
626
+ const lastModified = items.reduce<string | null>((acc, i) => {
627
+ if (!i.mtime) return acc;
628
+ if (!acc || i.mtime > acc) return i.mtime;
629
+ return acc;
630
+ }, null);
631
+ const counts = countByKind(items);
632
+ const folderLabel = path.split('/').filter(Boolean).pop() ?? path;
633
+ const cards = items.map((item, i) => renderCard(ctx, site, path, item, i));
634
+ const cardsHtml = cards.map((c) => c.__raw).join('');
635
+ const reviewLink = lookupEntryReviewLink(ctx, site, path);
267
636
  const body = html`
268
637
  ${renderEditorialFolio('content', `scrapbook · ${site}/${path}`)}
269
- <main class="scrapbook-page" data-site="${site}" data-slug="${path}" data-scrapbook-root>
270
- <header class="er-pagehead er-pagehead--compact scrapbook-header">
271
- ${renderBreadcrumb(site, path)}
272
- <p class="er-pagehead__kicker scrapbook-kicker">
273
- <span class="scrapbook-kicker-mark" aria-hidden="true">§</span>
274
- Scrapbook
275
- </p>
276
- <h1 class="er-pagehead__title scrapbook-title">${path}</h1>
277
- <a class="scrapbook-back" href="/dev/editorial-studio">← back to the desk</a>
278
- </header>
279
- <div class="scrapbook-status" data-scrapbook-status hidden></div>
280
- ${unsafe(publicBlock)}
281
- ${unsafe(secretBlock)}
282
- <div class="scrapbook-drop-overlay" data-scrapbook-overlay aria-hidden="true">
283
- <span class="scrapbook-drop-overlay-text">drop to add to the scrapbook ◇</span>
284
- </div>
638
+ <main class="scrap-page" data-site="${site}" data-path="${path}">
639
+ ${renderAside(site, path, items, totalSize, lastModified, secretItems.length, reviewLink)}
640
+ <section class="scrap-main">
641
+ <header class="scrap-main-header">
642
+ ${renderBreadcrumb(site, path)}
643
+ ${renderSearch()}
644
+ </header>
645
+ ${renderFilterChips(counts)}
646
+ ${renderComposer()}
647
+ <ol class="scrap-cards" id="cards" data-scrap-cards>
648
+ ${unsafe(cardsHtml)}
649
+ </ol>
650
+ ${renderDropZone()}
651
+ ${renderSecretSection(ctx, site, path, secretItems)}
652
+ </section>
285
653
  </main>`;
286
-
287
654
  return layout({
288
- title: `scrapbook · ${path} — dev`,
655
+ title: `scrapbook · ${folderLabel} — dev`,
289
656
  cssHrefs: [
290
657
  '/static/css/editorial-review.css',
291
658
  '/static/css/editorial-nav.css',
292
659
  '/static/css/scrapbook.css',
293
660
  '/static/css/blog-figure.css',
294
661
  ],
295
- bodyAttrs: 'data-review-ui="studio"',
662
+ bodyAttrs: 'data-review-ui="scrapbook"',
296
663
  bodyHtml: body,
297
664
  scriptModules: ['scrapbook-client'],
298
665
  });