@deskwork/studio 0.12.0 → 0.13.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 (67) 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/lib/version.d.ts +10 -0
  11. package/dist/lib/version.d.ts.map +1 -0
  12. package/dist/lib/version.js +29 -0
  13. package/dist/lib/version.js.map +1 -0
  14. package/dist/pages/chrome.d.ts +24 -13
  15. package/dist/pages/chrome.d.ts.map +1 -1
  16. package/dist/pages/chrome.js +25 -24
  17. package/dist/pages/chrome.js.map +1 -1
  18. package/dist/pages/chrome.ts +38 -27
  19. package/dist/pages/content-detail.js +1 -1
  20. package/dist/pages/content-detail.js.map +1 -1
  21. package/dist/pages/content-detail.ts +1 -1
  22. package/dist/pages/content.d.ts.map +1 -1
  23. package/dist/pages/content.js +48 -0
  24. package/dist/pages/content.js.map +1 -1
  25. package/dist/pages/content.ts +51 -0
  26. package/dist/pages/dashboard/header.d.ts.map +1 -1
  27. package/dist/pages/dashboard/header.js +3 -0
  28. package/dist/pages/dashboard/header.js.map +1 -1
  29. package/dist/pages/dashboard/header.ts +3 -0
  30. package/dist/pages/dashboard/section.d.ts +6 -3
  31. package/dist/pages/dashboard/section.d.ts.map +1 -1
  32. package/dist/pages/dashboard/section.js +25 -12
  33. package/dist/pages/dashboard/section.js.map +1 -1
  34. package/dist/pages/dashboard/section.ts +26 -13
  35. package/dist/pages/entry-review.js +2 -2
  36. package/dist/pages/entry-review.js.map +1 -1
  37. package/dist/pages/entry-review.ts +2 -2
  38. package/dist/pages/html.d.ts +2 -0
  39. package/dist/pages/html.d.ts.map +1 -1
  40. package/dist/pages/html.js +2 -0
  41. package/dist/pages/html.js.map +1 -1
  42. package/dist/pages/html.ts +4 -0
  43. package/dist/pages/layout.d.ts.map +1 -1
  44. package/dist/pages/layout.js +6 -0
  45. package/dist/pages/layout.js.map +1 -1
  46. package/dist/pages/layout.ts +7 -0
  47. package/dist/pages/review-scrapbook-drawer.d.ts +7 -0
  48. package/dist/pages/review-scrapbook-drawer.d.ts.map +1 -1
  49. package/dist/pages/review-scrapbook-drawer.js +45 -6
  50. package/dist/pages/review-scrapbook-drawer.js.map +1 -1
  51. package/dist/pages/review-scrapbook-drawer.ts +50 -6
  52. package/dist/pages/review.d.ts.map +1 -1
  53. package/dist/pages/review.js +168 -41
  54. package/dist/pages/review.js.map +1 -1
  55. package/dist/pages/review.ts +192 -41
  56. package/dist/pages/scrapbook.d.ts +7 -14
  57. package/dist/pages/scrapbook.d.ts.map +1 -1
  58. package/dist/pages/scrapbook.js +352 -193
  59. package/dist/pages/scrapbook.js.map +1 -1
  60. package/dist/pages/scrapbook.ts +390 -222
  61. package/dist/pages/shortform.js +1 -1
  62. package/dist/pages/shortform.js.map +1 -1
  63. package/dist/pages/shortform.ts +1 -1
  64. package/dist/server.d.ts.map +1 -1
  65. package/dist/server.js +26 -13
  66. package/dist/server.js.map +1 -1
  67. package/package.json +4 -4
@@ -1,243 +1,408 @@
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 { readFileSync } from 'node:fs';
20
14
  import {
21
15
  formatRelativeTime,
22
16
  formatSize,
23
17
  listScrapbook,
18
+ scrapbookFilePath,
24
19
  type ScrapbookItem,
20
+ type ScrapbookItemKind,
25
21
  } from '@deskwork/core/scrapbook';
26
22
  import type { StudioContext } from '../routes/api.ts';
27
23
  import { html, unsafe, type RawHtml } from './html.ts';
28
24
  import { layout } from './layout.ts';
29
25
  import { renderEditorialFolio } from './chrome.ts';
30
26
 
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;
27
+ const KIND_LABEL: Record<ScrapbookItemKind, string> = {
28
+ md: 'MD',
29
+ img: 'IMG',
30
+ json: 'JSON',
31
+ js: 'JS',
32
+ txt: 'TXT',
33
+ other: '·',
34
+ };
35
+
36
+ function escapeHtml(s: string): string {
37
+ return s
38
+ .replace(/&/g, '&amp;')
39
+ .replace(/</g, '&lt;')
40
+ .replace(/>/g, '&gt;')
41
+ .replace(/"/g, '&quot;')
42
+ .replace(/'/g, '&#39;');
43
+ }
44
+
45
+ /**
46
+ * Strip a YAML frontmatter block from the top of an md file. Only strips
47
+ * the leading `---\n...\n---\n` block; body-level `---` separators (Setext
48
+ * H2 underline, thematic break) are preserved because the function only
49
+ * looks at the first 4 chars for the opener.
50
+ */
51
+ function stripFrontmatter(text: string): string {
52
+ if (!text.startsWith('---\n')) return text;
53
+ const closeIdx = text.indexOf('\n---\n', 4);
54
+ if (closeIdx < 0) return text;
55
+ return text.slice(closeIdx + 5).replace(/^\n+/, '');
56
+ }
57
+
58
+ /**
59
+ * Build the closed-state preview excerpt for md/json/txt. Returns null
60
+ * when there's nothing useful to render — empty file, frontmatter-only
61
+ * file, or binary masquerading as text — so the caller can omit the
62
+ * preview block entirely (matches "other" kind treatment, avoids the
63
+ * 6rem min-height void).
64
+ *
65
+ * For json: pretty-print via JSON.parse + JSON.stringify(_, null, 2) so
66
+ * minified single-line files still render multi-line. Falls back to raw
67
+ * content on parse error (bad JSON is still readable as text).
68
+ *
69
+ * Binary detection: NUL byte presence after UTF-8 decode. Real text
70
+ * almost never has NUL; binary files have it within the first KB.
71
+ */
72
+ function previewExcerpt(buf: Buffer, kind: 'md' | 'json' | 'txt'): string | null {
73
+ let text = buf.subarray(0, Math.min(buf.byteLength, 2400)).toString('utf-8');
74
+ if (text.indexOf('\0') >= 0) return null;
75
+ if (kind === 'md') text = stripFrontmatter(text);
76
+ if (kind === 'json') {
77
+ try {
78
+ const fullText = buf.toString('utf-8');
79
+ text = JSON.stringify(JSON.parse(fullText), null, 2);
80
+ } catch {
81
+ // Invalid JSON — fall through to the raw-text excerpt below.
82
+ }
83
+ }
84
+ const excerpt = text.split('\n').slice(0, 8).join('\n').slice(0, 600);
85
+ if (excerpt.trim() === '') return null;
86
+ return excerpt;
87
+ }
88
+
89
+ /**
90
+ * Count lines in a text file: number of `\n` bytes plus 1 if the last
91
+ * byte isn't `\n` (so a 3-line file whether or not it has a trailing
92
+ * newline reports 3).
93
+ */
94
+ function countLines(buf: Buffer): number {
95
+ let count = 0;
96
+ for (const b of buf) if (b === 0x0a) count++;
97
+ if (buf.length > 0 && buf[buf.length - 1] !== 0x0a) count++;
98
+ return count;
99
+ }
100
+
101
+ /**
102
+ * Count top-level keys in a JSON object. Returns null if the file is not
103
+ * valid JSON or its root is not a plain object (arrays, primitives →
104
+ * null; caller renders no extra meta).
105
+ */
106
+ function countJsonKeys(buf: Buffer): number | null {
107
+ try {
108
+ const obj: unknown = JSON.parse(buf.toString('utf-8'));
109
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
110
+ return Object.keys(obj).length;
111
+ }
112
+ return null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ interface ImageDimensions { readonly width: number; readonly height: number; }
119
+
120
+ /**
121
+ * Read PNG dimensions from the IHDR chunk. Returns null for non-PNG or
122
+ * truncated files. JPEG/WebP/GIF support deferred — most deskwork
123
+ * scrapbook images are screenshots / icons (PNG) and the meta is purely
124
+ * informational, so the empty-string fallback is acceptable for other
125
+ * formats.
126
+ */
127
+ function readImageDimensions(buf: Buffer): ImageDimensions | null {
128
+ if (buf.length < 24) return null;
129
+ if (buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) return null;
130
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
43
131
  }
44
132
 
45
- function renderItemRow(
133
+ /**
134
+ * Compute the per-kind extra meta string shown after the kind chip + size:
135
+ * md / txt → "{N} lines"
136
+ * json → "{N} keys" (root must be a plain object; otherwise empty)
137
+ * img → "{W} × {H}" (PNG only; other formats → empty)
138
+ * other → empty
139
+ *
140
+ * ENOENT (race-window with delete) returns empty so the card still
141
+ * renders; other errors propagate to the page renderer.
142
+ */
143
+ function computeKindMeta(
144
+ ctx: StudioContext,
145
+ site: string,
146
+ path: string,
46
147
  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>`);
148
+ ): string {
149
+ if (item.kind !== 'md' && item.kind !== 'txt' && item.kind !== 'json' && item.kind !== 'img') {
150
+ return '';
151
+ }
152
+ let buf: Buffer;
153
+ try {
154
+ const fullPath = scrapbookFilePath(ctx.projectRoot, ctx.config, site, path, item.name);
155
+ buf = readFileSync(fullPath);
156
+ } catch (e) {
157
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT') return '';
158
+ throw e;
159
+ }
160
+ if (item.kind === 'md' || item.kind === 'txt') return `${countLines(buf)} lines`;
161
+ if (item.kind === 'json') {
162
+ const keys = countJsonKeys(buf);
163
+ return keys !== null ? `${keys} keys` : '';
164
+ }
165
+ // img
166
+ const dims = readImageDimensions(buf);
167
+ return dims ? `${dims.width} × ${dims.height}` : '';
88
168
  }
89
169
 
90
170
  /**
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.
171
+ * Server-side preview for the closed-state card. Img bg-frame URL;
172
+ * md italic Newsreader excerpt with frontmatter stripped; json mono
173
+ * pre with parse-then-stringify pretty-print; txt → mono pre raw excerpt.
174
+ * Other / empty / binary-as-text → no preview block.
94
175
  */
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
- );
176
+ function renderPreview(
177
+ ctx: StudioContext,
178
+ site: string,
179
+ path: string,
180
+ item: ScrapbookItem,
181
+ opts: { secret?: boolean } = {},
182
+ ): RawHtml {
183
+ const { secret = false } = opts;
184
+ if (item.kind === 'img') {
185
+ const params = new URLSearchParams({ site, path, name: item.name });
186
+ if (secret) params.set('secret', '1');
187
+ const url = `/api/dev/scrapbook-file?${params.toString()}`;
188
+ return unsafe(html`
189
+ <div class="scrap-preview scrap-preview--img" aria-hidden="true">
190
+ <div class="scrap-preview--img-frame" style="background-image: url(&quot;${url}&quot;);"></div>
191
+ </div>`);
192
+ }
193
+ if (item.kind !== 'md' && item.kind !== 'txt' && item.kind !== 'json') {
194
+ return unsafe('');
195
+ }
196
+ try {
197
+ const fullPath = scrapbookFilePath(
198
+ ctx.projectRoot,
199
+ ctx.config,
200
+ site,
201
+ path,
202
+ item.name,
203
+ secret ? { secret: true } : {},
204
+ );
205
+ const buf = readFileSync(fullPath);
206
+ const excerpt = previewExcerpt(buf, item.kind);
207
+ if (excerpt === null) return unsafe('');
208
+ const safe = escapeHtml(excerpt);
209
+ if (item.kind === 'json' || item.kind === 'txt') {
210
+ return unsafe(html`
211
+ <pre class="scrap-preview scrap-preview--mono" aria-hidden="true">${unsafe(safe)}</pre>`);
212
+ }
213
+ return unsafe(html`
214
+ <div class="scrap-preview scrap-preview-md" aria-hidden="true"><p>${unsafe(safe)}</p></div>`);
215
+ } catch (e) {
216
+ // ENOENT = file disappeared between listScrapbook and this read (race
217
+ // window with delete); rendering an empty preview is the right call.
218
+ // Anything else (EACCES, EISDIR, encoding bugs) propagates so the
219
+ // operator sees a real error instead of a silently-broken page.
220
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
221
+ return unsafe('');
107
222
  }
223
+ throw e;
108
224
  }
109
- const sep = '<span class="scrapbook-breadcrumb-sep" aria-hidden="true">›</span>';
110
- const joined = links.join(`\n${sep}\n`);
225
+ }
226
+
227
+ interface KindCounts {
228
+ all: number;
229
+ md: number;
230
+ img: number;
231
+ json: number;
232
+ js: number;
233
+ txt: number;
234
+ other: number;
235
+ }
236
+
237
+ function countByKind(items: readonly ScrapbookItem[]): KindCounts {
238
+ const counts: KindCounts = {
239
+ all: items.length,
240
+ md: 0,
241
+ img: 0,
242
+ json: 0,
243
+ js: 0,
244
+ txt: 0,
245
+ other: 0,
246
+ };
247
+ for (const i of items) counts[i.kind]++;
248
+ return counts;
249
+ }
250
+
251
+ function renderFilterChips(counts: KindCounts): RawHtml {
252
+ const chip = (kind: keyof KindCounts, label: string, isAll = false): RawHtml =>
253
+ unsafe(html`
254
+ <button class="scrap-filter" type="button" data-filter="${kind}"
255
+ aria-pressed="${isAll ? 'true' : 'false'}">${label} · ${counts[kind]}</button>`);
256
+ return unsafe(html`
257
+ <div class="scrap-filters" role="toolbar" aria-label="filter by kind">
258
+ ${chip('all', 'all', true)}
259
+ ${chip('md', 'md')}
260
+ ${chip('img', 'img')}
261
+ ${chip('json', 'json')}
262
+ ${chip('txt', 'txt')}
263
+ ${chip('other', 'other')}
264
+ </div>`);
265
+ }
266
+
267
+ function renderSearch(): RawHtml {
111
268
  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)}
269
+ <div class="scrap-search">
270
+ <input type="search" placeholder="filter by name or content" aria-label="filter scrapbook" data-scrap-search />
271
+ <span class="scrap-search-kbd">/</span>
272
+ </div>`);
273
+ }
274
+
275
+ function renderBreadcrumb(site: string, path: string): RawHtml {
276
+ const segments = path.split('/').filter(Boolean);
277
+ const last = segments[segments.length - 1] ?? path;
278
+ return unsafe(html`
279
+ <nav class="scrap-breadcrumb" aria-label="hierarchy">
280
+ <a href="/dev/content/${site}">${site}</a><span class="sep">›</span>
281
+ <b>${last}</b>
116
282
  </nav>`);
117
283
  }
118
284
 
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;
285
+ function renderAside(
286
+ site: string,
287
+ path: string,
288
+ items: readonly ScrapbookItem[],
289
+ totalSize: number,
290
+ lastModified: string | null,
291
+ secretCount: number,
292
+ ): RawHtml {
293
+ const lastModifiedLabel = lastModified ? formatRelativeTime(lastModified) : '—';
294
+ const publicCount = items.length;
295
+ const sizeLabel = formatSize(totalSize);
296
+ const folderLabel = path.split('/').filter(Boolean).pop() ?? path;
297
+ const fullPath = `${site}/${path}/scrapbook/`;
125
298
  return unsafe(html`
126
- <aside class="scrapbook-index">
127
- <p class="scrapbook-index-kicker">
128
- <span aria-hidden="true">§</span> The folder
299
+ <aside class="scrap-aside">
300
+ <p class="scrap-aside-kicker"><em>§</em> The folder</p>
301
+ <h1 class="scrap-aside-title">${folderLabel}</h1>
302
+ <p class="scrap-aside-meta">${site}</p>
303
+ <hr />
304
+ <p class="scrap-aside-totals">
305
+ <strong>${publicCount}</strong> public ·
306
+ <strong>${secretCount}</strong> secret ·
307
+ <em>${sizeLabel}</em>
129
308
  </p>
130
- <p class="scrapbook-index-meta">${path}</p>
131
- <p class="scrapbook-index-meta">${site}</p>
309
+ <p class="scrap-aside-meta">last modified ${lastModifiedLabel}</p>
132
310
  <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
- )}
311
+ <ol class="scrap-aside-list" data-scrap-aside-list>
312
+ ${items.map((item, i) => {
313
+ const seq = String(i + 1).padStart(2, '0');
314
+ return unsafe(html`<li><span class="num">${seq}</span><a href="#item-${i + 1}" data-scrap-aside-link>${item.name}</a></li>`);
315
+ })}
140
316
  </ol>
141
317
  <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>
318
+ <div class="scrap-aside-actions">
319
+ <button class="scrap-aside-btn scrap-aside-btn--primary" type="button" data-action="new-note">+ new note</button>
320
+ <button class="scrap-aside-btn" type="button" data-action="upload">+ upload file</button>
152
321
  </div>
153
322
  <hr />
154
- <p class="scrapbook-index-path">${site}/${path}/scrapbook/</p>
323
+ <p class="scrap-aside-path">${fullPath}</p>
155
324
  </aside>`);
156
325
  }
157
326
 
158
- function renderEmpty(): RawHtml {
327
+ function renderCard(
328
+ ctx: StudioContext,
329
+ site: string,
330
+ path: string,
331
+ item: ScrapbookItem,
332
+ index: number,
333
+ opts: { secret?: boolean } = {},
334
+ ): RawHtml {
335
+ const { secret = false } = opts;
336
+ const seq = String(index + 1).padStart(2, '0');
337
+ const kindLabel = KIND_LABEL[item.kind];
338
+ const kindClass = item.kind === 'other' ? '' : `scrap-kind--${item.kind}`;
339
+ const time = item.mtime
340
+ ? html`<time class="scrap-time" datetime="${item.mtime}">${formatRelativeTime(item.mtime)}</time>`
341
+ : '';
342
+ const preview = renderPreview(ctx, site, path, item, { secret });
343
+ const kindMeta = computeKindMeta(ctx, site, path, item);
344
+ const kindMetaHtml: RawHtml = kindMeta
345
+ ? unsafe(html`<span>·</span><span>${kindMeta}</span>`)
346
+ : unsafe('');
347
+ const editBtn = item.kind === 'img'
348
+ ? unsafe('')
349
+ : unsafe(html`<button class="scrap-tool" type="button" data-action="edit">edit</button>`);
350
+ // Secret cards get id="secret-item-N" to disambiguate from public ids in
351
+ // restoreFromHash + aside cross-link lookups (F4 contract); the
352
+ // mark-secret action toggle reads "mark public" since clicking it moves
353
+ // the card OUT of the secret section.
354
+ const id = secret ? `secret-item-${index + 1}` : `item-${index + 1}`;
355
+ const markSecretLabel = secret ? 'mark public' : 'mark secret';
356
+ const dataSecretAttr = secret ? ' data-secret="true"' : '';
159
357
  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>
358
+ <li class="scrap-card" data-kind="${item.kind}" data-state="closed" id="${id}"${unsafe(dataSecretAttr)}>
359
+ <div class="scrap-card-head">
360
+ <span class="scrap-seq">N° ${seq}</span>
361
+ <span class="scrap-name" data-action="open">${item.name}</span>
362
+ ${unsafe(time)}
168
363
  </div>
169
- </section>`);
364
+ <div class="scrap-card-meta">
365
+ <span class="scrap-kind ${kindClass}">${kindLabel}</span>
366
+ <span class="scrap-size">${formatSize(item.size)}</span>
367
+ ${kindMetaHtml}
368
+ </div>
369
+ ${preview}
370
+ <div class="scrap-card-foot">
371
+ <button class="scrap-tool scrap-tool--primary" type="button" data-action="open">open</button>
372
+ ${editBtn}
373
+ <button class="scrap-tool" type="button" data-action="rename">rename</button>
374
+ <button class="scrap-tool" type="button" data-action="mark-secret">${markSecretLabel}</button>
375
+ <span class="spacer"></span>
376
+ <button class="scrap-tool scrap-tool--delete" type="button" data-action="delete">delete</button>
377
+ </div>
378
+ </li>`);
170
379
  }
171
380
 
172
- function renderReadingPanel(items: readonly ScrapbookItem[]): RawHtml {
381
+ function renderDropZone(): RawHtml {
173
382
  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>
208
- </label>
209
- </div>
210
- </section>`);
383
+ <div class="scrap-drop" role="button" tabindex="0" data-action="upload"
384
+ aria-label="Drop a file here, or press Enter to pick one">
385
+ ── drop a file here, or pick one ──
386
+ </div>`);
211
387
  }
212
388
 
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 {
389
+ function renderSecretSection(
390
+ ctx: StudioContext,
391
+ site: string,
392
+ path: string,
393
+ secretItems: readonly ScrapbookItem[],
394
+ ): RawHtml {
395
+ if (secretItems.length === 0) return unsafe('');
396
+ const cards = secretItems.map((item, i) => renderCard(ctx, site, path, item, i, { secret: true }));
221
397
  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>
398
+ <section class="scrap-secret" aria-label="secret items">
399
+ <header class="scrap-secret-head">
400
+ <span class="scrap-secret-mark" aria-hidden="true">⚿</span>
401
+ <h2 class="scrap-secret-title">Secret</h2>
402
+ <span class="scrap-secret-badge">private — never published</span>
232
403
  </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
- )}
404
+ <ol class="scrap-cards">
405
+ ${cards}
241
406
  </ol>
242
407
  </section>`);
243
408
  }
@@ -253,46 +418,49 @@ export function renderScrapbookPage(
253
418
  if (!(site in ctx.config.sites)) {
254
419
  throw new Error(`unknown site: ${site}`);
255
420
  }
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
-
421
+ // listScrapbook returns { exists: false, items: [] } for missing dirs
422
+ // (packages/core/src/scrapbook.ts:337-339), so an empty scrapbook is not
423
+ // an error path. Real errors (slug validation, scrapbookDir resolution
424
+ // failures, FS permission issues) propagate to the studio's error handler.
425
+ const result = listScrapbook(ctx.projectRoot, ctx.config, site, path);
426
+ const items = result.items;
427
+ const secretItems = result.secretItems;
428
+ const totalSize = items.reduce((s, i) => s + i.size, 0);
429
+ const lastModified = items.reduce<string | null>((acc, i) => {
430
+ if (!i.mtime) return acc;
431
+ if (!acc || i.mtime > acc) return i.mtime;
432
+ return acc;
433
+ }, null);
434
+ const counts = countByKind(items);
435
+ const folderLabel = path.split('/').filter(Boolean).pop() ?? path;
436
+ const cards = items.map((item, i) => renderCard(ctx, site, path, item, i));
437
+ const cardsHtml = cards.map((c) => c.__raw).join('');
267
438
  const body = html`
268
439
  ${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>
440
+ <main class="scrap-page" data-site="${site}" data-path="${path}">
441
+ ${renderAside(site, path, items, totalSize, lastModified, secretItems.length)}
442
+ <section class="scrap-main">
443
+ <header class="scrap-main-header">
444
+ ${renderBreadcrumb(site, path)}
445
+ ${renderSearch()}
446
+ </header>
447
+ ${renderFilterChips(counts)}
448
+ <ol class="scrap-cards" id="cards" data-scrap-cards>
449
+ ${unsafe(cardsHtml)}
450
+ </ol>
451
+ ${renderDropZone()}
452
+ ${renderSecretSection(ctx, site, path, secretItems)}
453
+ </section>
285
454
  </main>`;
286
-
287
455
  return layout({
288
- title: `scrapbook · ${path} — dev`,
456
+ title: `scrapbook · ${folderLabel} — dev`,
289
457
  cssHrefs: [
290
458
  '/static/css/editorial-review.css',
291
459
  '/static/css/editorial-nav.css',
292
460
  '/static/css/scrapbook.css',
293
461
  '/static/css/blog-figure.css',
294
462
  ],
295
- bodyAttrs: 'data-review-ui="studio"',
463
+ bodyAttrs: 'data-review-ui="scrapbook"',
296
464
  bodyHtml: body,
297
465
  scriptModules: ['scrapbook-client'],
298
466
  });