fb-slides 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ this — which makes it worth being able to see what arrived.
7
7
  Entries are written as the work lands, under **Unreleased**; the release commit
8
8
  that stamps the version renames that heading to the version and its date.
9
9
 
10
+ ## 0.10.0 — 2026-09-08
11
+
12
+ - **A file in the assets panel opens on click.** The name on a row carries the
13
+ full path in its tooltip, and clicking it shows the file: an image comes up
14
+ right there, over the deck — full size, the path under it, a click anywhere
15
+ or Escape puts it away — and everything else opens in a new tab, where the
16
+ browser plays the clip, renders the PDF, and offers to save the rest.
17
+
18
+ ## 0.9.1 — 2026-09-08
19
+
20
+ - **The assets panel shows the folder, not just what you dropped.** The list
21
+ under the folder picker is now that folder as it is on disk — the screenshot
22
+ from a moment ago and the one from last month, side by side — so an asset
23
+ that was already there can be copied, written into the slide, or deleted
24
+ without going to the Finder for it. Picking another folder lists that one,
25
+ what this session put there is tinted, and a file under a kilobyte is counted
26
+ in bytes rather than rounding to `0 KB`.
27
+
10
28
  ## 0.9.0 — 2026-09-08
11
29
 
12
30
  - **The editor answers `Shift+E`, not `T`.** The drawer's shortcut moved off
package/README.md CHANGED
@@ -273,10 +273,13 @@ files are saved there; drop on **+ new folder…** and it asks for a name and
273
273
  makes it. The button next to the `+` opens the same panel for files you would
274
274
  rather pick than drag.
275
275
 
276
- Each file that lands is listed with the path to write in the slide, a button
277
- that copies it, and one that writes it for you at the cursor: `![](…)` for an
278
- image, a `<video>` with the attributes that make it behave for a clip, an
279
- `<audio>` for sound, a link for everything else. Names are folded to something
276
+ The list under the folder picker is that folder, as it is on disk: what was just
277
+ dropped and what has been there since last month. Each row carries the path to
278
+ write in the slide, a button that copies it, and one that writes it for you at
279
+ the cursor: `![](…)` for an image, a `<video>` with the attributes that make it
280
+ behave for a clip, an `<audio>` for sound, a link for everything else. Picking
281
+ another folder lists that one; what this session put there is tinted, so it
282
+ stands out among files that were already around. Names are folded to something
280
283
  a URL can carry — `Schermata città (1).PNG` becomes `Schermata-citta-1.png` —
281
284
  and nothing is ever overwritten: a second `clip.mp4` lands as `clip-2.mp4`.
282
285
 
package/lib/assets.mjs CHANGED
@@ -6,8 +6,8 @@
6
6
  // and putting the picture it points at next to it are the same job, so the
7
7
  // drawer that does one does the other.
8
8
  //
9
- // GET /api/assets → { dir, dirs } — where files land, and the subfolders
10
- // that are already there to aim at
9
+ // GET /api/assets?dir=x → { dir, dirs, at, files } — where files land, the
10
+ // subfolders to aim at, and what is in the one asked for
11
11
  // POST /api/assets → the file's own bytes, its name and folder riding in
12
12
  // headers (a filename is not latin-1, hence the encoding);
13
13
  // answers { path } — what the slide should say
@@ -103,6 +103,23 @@ const freeName = async (dir, name) => {
103
103
  return null;
104
104
  };
105
105
 
106
+ // What is in one folder, as rows the drawer can list: files only, no dotfiles,
107
+ // each carrying the path a slide would write.
108
+ const filesIn = async (path, dir, relative) => {
109
+ const entries = await readdir(path, { withFileTypes: true }).catch(() => []);
110
+ const found = [];
111
+ for (const entry of entries) {
112
+ if (!entry.isFile() || entry.name.startsWith('.')) continue;
113
+ const info = await stat(join(path, entry.name)).catch(() => null);
114
+ found.push({
115
+ name: entry.name,
116
+ size: info?.size ?? 0,
117
+ path: [dir, relative, entry.name].filter(Boolean).join('/'),
118
+ });
119
+ }
120
+ return found.sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true }));
121
+ };
122
+
106
123
  const subfolders = async (root) => {
107
124
  const walk = async (dir, prefix, left) => {
108
125
  if (!left) return [];
@@ -157,7 +174,16 @@ const receive = async (req, target) => {
157
174
  // where that is on disk.
158
175
  export const createAssetApi = ({ dir, path: root }) => async (req, res) => {
159
176
  if (req.method === 'GET') {
160
- return json(res, 200, { dir, dirs: await subfolders(root).catch(() => []) });
177
+ // `dir` is the folder the drawer is showing — the root when it says nothing,
178
+ // and nothing at all while a new folder is being named.
179
+ const asked = new URL(req.url, 'http://localhost').searchParams.get('dir') ?? '';
180
+ const at = folderIn(root, asked);
181
+ return json(res, 200, {
182
+ dir,
183
+ dirs: await subfolders(root).catch(() => []),
184
+ at: at?.relative ?? '',
185
+ files: at ? await filesIn(at.path, dir, at.relative) : [],
186
+ });
161
187
  }
162
188
 
163
189
  if (req.method !== 'POST' && req.method !== 'DELETE') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fb-slides",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Markdown-driven reveal.js decks: live demo embeds, annotation, mermaid, and a zero-config dev server",
6
6
  "keywords": [
package/runtime/assets.js CHANGED
@@ -7,11 +7,11 @@
7
7
  // is created if it is new — and the drawer lists what landed, each row a path
8
8
  // with two buttons: copy it, or write the markup for it into the slide.
9
9
  //
10
- // Nothing here is a file browser. The list is what *this* drop produced, which
11
- // is what you need in the ten seconds after dragging a screenshot in; the paths
12
- // are relative, so they work in dev and in the built deck alike. A row can also
13
- // delete its file — the wrong screenshot, dropped a moment ago — and that one
14
- // asks for the word to be typed: it is the only thing here a reload cannot undo.
10
+ // The list under the folder picker is that folder, as it is on disk: what was
11
+ // just dropped and what has been there since last month, each row a path to
12
+ // copy, to write into the slide, or to delete. The paths are relative, so they
13
+ // work in dev and in the built deck alike. Deleting asks for the word to be
14
+ // typed — it is the only thing here a reload cannot undo.
15
15
  //
16
16
  // Only the dev server answers /api/assets, so on a static host the sheet never
17
17
  // appears — the drawer it belongs to is not there either.
@@ -76,7 +76,13 @@ const TRASH_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
76
76
 
77
77
  const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
78
78
 
79
- const bytes = (size) => (size < 1024 * 1024 ? `${Math.round(size / 1024)} KB` : `${(size / 1048576).toFixed(1)} MB`);
79
+ // A 511-byte SVG rounding to `0 KB` is a row that looks like a broken file.
80
+ const bytes = (size) =>
81
+ size < 1024
82
+ ? `${size} B`
83
+ : size < 1024 * 1024
84
+ ? `${Math.round(size / 1024)} KB`
85
+ : `${(size / 1048576).toFixed(1)} MB`;
80
86
 
81
87
  // One file, streamed. XHR rather than fetch for the one thing fetch cannot do:
82
88
  // tell you how far a 60 MB screen recording has got.
@@ -161,6 +167,32 @@ export const createAssets = ({ paste, active }) => {
161
167
  const wordInput = confirmBox.querySelector('.deck-assets-word');
162
168
  const confirmButton = confirmBox.querySelector('.deck-assets-confirm-delete');
163
169
 
170
+ // ---- the image preview --------------------------------------------------
171
+ // A click on an image's name opens the image itself, over everything — the
172
+ // drawer is narrow and a thumbnail would say less than the file name does.
173
+ // A click anywhere on it, or Escape, puts it away. Every other kind of file
174
+ // goes to a new tab instead: the browser knows a clip and a PDF better.
175
+
176
+ const preview = document.createElement('div');
177
+ preview.className = 'deck-assets-preview';
178
+ preview.hidden = true;
179
+ preview.innerHTML = `<figure><img alt="" /><figcaption><code></code></figcaption></figure>`;
180
+ const previewImage = preview.querySelector('img');
181
+
182
+ const openPreview = (path) => {
183
+ previewImage.src = path;
184
+ preview.querySelector('figcaption code').textContent = path;
185
+ preview.hidden = false;
186
+ };
187
+
188
+ const closePreview = () => {
189
+ preview.hidden = true;
190
+ // A 20 MB screenshot should stop arriving the moment nobody is looking.
191
+ previewImage.removeAttribute('src');
192
+ };
193
+
194
+ preview.addEventListener('click', closePreview);
195
+
164
196
  // ---- the full-page sheet ----------------------------------------------
165
197
 
166
198
  const overlay = document.createElement('div');
@@ -181,7 +213,10 @@ export const createAssets = ({ paste, active }) => {
181
213
  let dirs = []; // its subfolders, as the server last listed them
182
214
  let chosen = ''; // where the next drop goes, remembered between drops
183
215
  let pending = null; // files waiting for a folder that has yet to be named
184
- let asking = null; // { path, row } — the delete question standing over the list
216
+ let asking = null; // the path the delete question is standing over
217
+ let files = []; // the chosen folder, as the server last listed it
218
+ let failures = []; // uploads that did not land, kept under the listing
219
+ const fresh = new Set(); // what this session put there — worth pointing at
185
220
 
186
221
  // The root, what the server last listed, and — however that listing raced
187
222
  // with the upload that made it — the folder this drop actually went to.
@@ -213,21 +248,28 @@ export const createAssets = ({ paste, active }) => {
213
248
  targets.insertAdjacentHTML('beforeend', `<li data-dir="${NEW}" class="is-new">+ new folder…</li>`);
214
249
  };
215
250
 
216
- // The folder list as it is right now: a drop is often the second one, and the
217
- // folder the first one made should be there to aim at.
251
+ // The folder as it is right now — its subfolders to aim at, and its files to
252
+ // list. Called on open, on picking another folder, and after every write.
218
253
  const refresh = async () => {
254
+ const where = pending ? '' : chosen;
219
255
  try {
220
- const response = await fetch('/api/assets', { headers: API_HEADERS, cache: 'no-cache' });
256
+ const response = await fetch(`/api/assets?dir=${encodeURIComponent(where)}`, {
257
+ headers: API_HEADERS,
258
+ cache: 'no-cache',
259
+ });
221
260
  if (response.ok) {
222
261
  const answer = await response.json();
223
262
  root = answer.dir ?? root;
224
263
  dirs = Array.isArray(answer.dirs) ? answer.dirs : [];
264
+ files = Array.isArray(answer.files) ? answer.files : [];
225
265
  }
226
266
  } catch {
227
267
  /* no listing: assets/ itself is always there to drop into */
268
+ files = [];
228
269
  }
229
270
  drawSelect();
230
271
  drawTargets();
272
+ drawFiles();
231
273
  };
232
274
 
233
275
  // ---- open and close ---------------------------------------------------
@@ -250,6 +292,7 @@ export const createAssets = ({ paste, active }) => {
250
292
  nameRow.hidden = true;
251
293
  waiting.hidden = true;
252
294
  closeConfirm();
295
+ closePreview();
253
296
  };
254
297
 
255
298
  // ---- the rows ---------------------------------------------------------
@@ -269,6 +312,70 @@ export const createAssets = ({ paste, active }) => {
269
312
  }
270
313
  };
271
314
 
315
+ // The folder, one row per file: the path, and the three things worth doing
316
+ // with it. Rebuilt whole on every refresh — a listing is cheap, and the
317
+ // alternative is keeping rows in step with a folder two other things write to.
318
+ const drawFiles = () => {
319
+ list.innerHTML = '';
320
+
321
+ if (!files.length && !failures.length) {
322
+ const empty = document.createElement('li');
323
+ empty.className = 'deck-assets-none';
324
+ empty.textContent = pending ? 'The folder will be made when you name it.' : 'Nothing in this folder yet.';
325
+ list.append(empty);
326
+ }
327
+
328
+ for (const file of files) {
329
+ const row = document.createElement('li');
330
+ if (fresh.has(file.path)) row.classList.add('is-fresh');
331
+ row.innerHTML =
332
+ `<code></code>` +
333
+ `<span class="deck-assets-size"></span>` +
334
+ `<span class="deck-assets-acts">` +
335
+ `<button type="button" class="deck-assets-copy" title="Copy the path">${COPY_ICON}</button>` +
336
+ `<button type="button" class="deck-assets-insert" title="Write it into the slide">${INSERT_ICON}</button>` +
337
+ `<button type="button" class="deck-assets-trash" title="Delete the file">${TRASH_ICON}</button>` +
338
+ `</span>`;
339
+ const name = row.querySelector('code');
340
+ name.textContent = file.path;
341
+ // The drawer is narrow and a path can be long: the row truncates, and
342
+ // hovering the name says the whole thing. Clicking it shows the file: an
343
+ // image right here, over the deck; anything else in a new tab, where the
344
+ // browser plays the clip, renders the PDF, and offers to save the rest.
345
+ name.title = file.path;
346
+ name.classList.add('is-viewable');
347
+ if (IMAGE.test(file.path)) {
348
+ name.classList.add('is-image');
349
+ name.addEventListener('click', () => openPreview(file.path));
350
+ } else {
351
+ name.addEventListener('click', () => window.open(file.path, '_blank', 'noopener'));
352
+ }
353
+ row.querySelector('.deck-assets-size').textContent = bytes(file.size);
354
+ row.querySelector('.deck-assets-copy').addEventListener('click', (event) =>
355
+ copy(file.path, event.currentTarget),
356
+ );
357
+ row.querySelector('.deck-assets-insert').addEventListener('click', () => {
358
+ paste(blockFor(file.path, file.name));
359
+ close();
360
+ });
361
+ row.querySelector('.deck-assets-trash').addEventListener('click', () => askDelete(file.path));
362
+ list.append(row);
363
+ }
364
+
365
+ // What did not land stays under the listing rather than vanishing with the
366
+ // refresh that follows the upload.
367
+ for (const failure of failures) {
368
+ const row = document.createElement('li');
369
+ row.className = 'is-failed';
370
+ row.innerHTML = `<span class="deck-assets-file"></span><span class="deck-assets-pct"></span>`;
371
+ row.querySelector('.deck-assets-file').textContent = failure.name;
372
+ row.querySelector('.deck-assets-pct').textContent = failure.message;
373
+ list.append(row);
374
+ }
375
+ };
376
+
377
+ // While an upload is in flight it has a row of its own, under the listing,
378
+ // counting up. The refresh that follows replaces it with the real thing.
272
379
  const addRow = (file) => {
273
380
  const row = document.createElement('li');
274
381
  row.innerHTML = `<span class="deck-assets-file"></span><span class="deck-assets-pct"></span>`;
@@ -277,64 +384,37 @@ export const createAssets = ({ paste, active }) => {
277
384
  pct.textContent = bytes(file.size);
278
385
  list.append(row);
279
386
  row.scrollIntoView({ block: 'nearest' });
280
-
281
387
  return {
282
388
  progress: (share) => {
283
389
  pct.textContent = `${Math.round(share * 100)}%`;
284
390
  },
285
- failed: (message) => {
286
- row.classList.add('is-failed');
287
- pct.textContent = message;
288
- },
289
- done: ({ path, name }) => {
290
- row.classList.add('is-done');
291
- row.innerHTML =
292
- `<code></code>` +
293
- `<span class="deck-assets-acts">` +
294
- `<button type="button" class="deck-assets-copy" title="Copy the path">${COPY_ICON}</button>` +
295
- `<button type="button" class="deck-assets-insert" title="Write it into the slide">${INSERT_ICON}</button>` +
296
- `<button type="button" class="deck-assets-trash" title="Delete the file">${TRASH_ICON}</button>` +
297
- `</span>`;
298
- row.querySelector('code').textContent = path;
299
- row.querySelector('.deck-assets-copy').addEventListener('click', (event) =>
300
- copy(path, event.currentTarget),
301
- );
302
- row.querySelector('.deck-assets-insert').addEventListener('click', () => {
303
- paste(blockFor(path, name));
304
- close();
305
- });
306
- row.querySelector('.deck-assets-trash').addEventListener('click', () => askDelete(path, row));
307
- },
308
391
  };
309
392
  };
310
393
 
311
394
  // One at a time: a folder of screenshots dropped at once should not open ten
312
395
  // sockets, and the rows then fill in the order they were dropped.
313
- const send = async (files, dir) => {
314
- if (!files.length) return;
396
+ const send = async (dropped, dir) => {
397
+ if (!dropped.length) return;
315
398
  chosen = dir;
399
+ failures = [];
316
400
  open();
317
- for (const file of files) {
401
+ for (const file of dropped) {
318
402
  const row = addRow(file);
319
403
  try {
320
404
  const answer = await upload(file, dir, row.progress);
321
- row.done(answer);
322
405
  // The folder as the server spells it — `talk 2026` was typed, and what
323
406
  // exists on disk is `talk-2026`. The list should say the latter.
324
407
  chosen = answer.dir;
325
- if (answer.dir && !dirs.includes(answer.dir)) {
326
- dirs = [...dirs, answer.dir].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
327
- }
328
- drawSelect();
329
- drawTargets();
408
+ fresh.add(answer.path);
330
409
  } catch (error) {
331
- row.failed(error.message);
410
+ failures.push({ name: file.name, message: error.message });
332
411
  }
333
412
  }
413
+ await refresh();
334
414
  };
335
415
 
336
- const askDelete = (path, row) => {
337
- asking = { path, row };
416
+ const askDelete = (path) => {
417
+ asking = path;
338
418
  confirmBox.querySelector('code').textContent = path;
339
419
  wordInput.value = '';
340
420
  confirmButton.disabled = true;
@@ -344,7 +424,7 @@ export const createAssets = ({ paste, active }) => {
344
424
 
345
425
  const doDelete = async () => {
346
426
  if (!asking || confirmButton.disabled) return;
347
- const { path, row } = asking;
427
+ const path = asking;
348
428
  confirmButton.disabled = true;
349
429
  try {
350
430
  const response = await fetch(`/api/assets?path=${encodeURIComponent(path)}`, {
@@ -352,10 +432,8 @@ export const createAssets = ({ paste, active }) => {
352
432
  headers: API_HEADERS,
353
433
  });
354
434
  if (!response.ok) throw new Error((await response.json()).error ?? response.status);
355
- row.remove();
435
+ fresh.delete(path);
356
436
  closeConfirm();
357
- // The folder may have been the file's only reason to exist; the list says
358
- // what is there now either way.
359
437
  refresh();
360
438
  } catch (error) {
361
439
  confirmBox.querySelector('code').textContent = error.message;
@@ -364,14 +442,14 @@ export const createAssets = ({ paste, active }) => {
364
442
  };
365
443
 
366
444
  // A drop that named no folder yet: hold the files, ask, and send on the answer.
367
- const askName = (files) => {
368
- pending = files;
445
+ const askName = (dropped) => {
446
+ pending = dropped;
369
447
  open();
370
448
  drawSelect();
371
449
  nameRow.hidden = false;
372
450
  goButton.hidden = false;
373
451
  waiting.hidden = false;
374
- waiting.textContent = `${files.length} file${files.length === 1 ? '' : 's'} waiting for a folder name.`;
452
+ waiting.textContent = `${dropped.length} file${dropped.length === 1 ? '' : 's'} waiting for a folder name.`;
375
453
  nameInput.focus();
376
454
  nameInput.select();
377
455
  };
@@ -379,23 +457,23 @@ export const createAssets = ({ paste, active }) => {
379
457
  const confirmName = () => {
380
458
  const dir = nameInput.value.trim().replace(/^\/+|\/+$/g, '');
381
459
  if (!dir) return nameInput.focus();
382
- const files = pending;
460
+ const held = pending;
383
461
  pending = null;
384
462
  nameInput.value = '';
385
463
  nameRow.hidden = true;
386
464
  goButton.hidden = true;
387
465
  waiting.hidden = true;
388
- if (files) send(files, dir);
466
+ if (held) send(held, dir);
389
467
  else {
390
468
  chosen = dir;
391
- drawSelect();
469
+ refresh();
392
470
  }
393
471
  };
394
472
 
395
- const drop = (files, dir) => {
396
- if (!files.length) return;
397
- if (dir === NEW) askName(files);
398
- else send(files, dir);
473
+ const drop = (dropped, dir) => {
474
+ if (!dropped.length) return;
475
+ if (dir === NEW) askName(dropped);
476
+ else send(dropped, dir);
399
477
  };
400
478
 
401
479
  // ---- wiring: the panel -------------------------------------------------
@@ -410,6 +488,8 @@ export const createAssets = ({ paste, active }) => {
410
488
  }
411
489
  nameRow.hidden = true;
412
490
  chosen = select.value;
491
+ failures = [];
492
+ refresh();
413
493
  });
414
494
 
415
495
  wordInput.addEventListener('input', () => {
@@ -435,9 +515,9 @@ export const createAssets = ({ paste, active }) => {
435
515
  fileInput.click();
436
516
  });
437
517
  fileInput.addEventListener('change', () => {
438
- const files = [...fileInput.files];
518
+ const picked = [...fileInput.files];
439
519
  fileInput.value = '';
440
- drop(files, select.value === NEW ? nameInput.value.trim() : select.value);
520
+ drop(picked, select.value === NEW ? nameInput.value.trim() : select.value);
441
521
  });
442
522
 
443
523
  // ---- wiring: the sheet -------------------------------------------------
@@ -492,7 +572,7 @@ export const createAssets = ({ paste, active }) => {
492
572
  drop([...event.dataTransfer.files], target?.dataset.dir ?? chosen);
493
573
  });
494
574
 
495
- document.body.append(overlay);
575
+ document.body.append(overlay, preview);
496
576
 
497
577
  return {
498
578
  element,
@@ -503,6 +583,7 @@ export const createAssets = ({ paste, active }) => {
503
583
  // One layer per press, innermost first — the drawer asks this before it
504
584
  // decides whether Escape was meant for it.
505
585
  escape: () => {
586
+ if (!preview.hidden) return closePreview(), true;
506
587
  if (!confirmBox.hidden) return closeConfirm(), true;
507
588
  if (!element.hidden) return close(), true;
508
589
  return false;
@@ -1683,6 +1683,12 @@ body.edit-open #deck-edit { transform: none; }
1683
1683
  text-overflow: ellipsis;
1684
1684
  white-space: nowrap;
1685
1685
  }
1686
+ /* A file's name shows the file — an image over the deck, the rest in a new
1687
+ tab; the cursor and the underline are the whole affordance, the row stays a
1688
+ row. */
1689
+ .deck-assets-list code.is-viewable { cursor: pointer; }
1690
+ .deck-assets-list code.is-image { cursor: zoom-in; }
1691
+ .deck-assets-list code.is-viewable:hover { color: var(--accent); text-decoration: underline; }
1686
1692
  .deck-assets-file {
1687
1693
  min-width: 0;
1688
1694
  font: 400 12px/1.5 var(--sans);
@@ -1691,11 +1697,24 @@ body.edit-open #deck-edit { transform: none; }
1691
1697
  text-overflow: ellipsis;
1692
1698
  white-space: nowrap;
1693
1699
  }
1694
- .deck-assets-pct {
1700
+ .deck-assets-pct,
1701
+ .deck-assets-size {
1695
1702
  flex: 0 0 auto;
1696
1703
  font: 400 11px/1.5 var(--mono);
1697
1704
  color: var(--muted);
1698
1705
  }
1706
+ /* The row grows to the path and the size is pinned right of it, so a long name
1707
+ truncates rather than pushing the buttons off the edge. */
1708
+ .deck-assets-list li code { flex: 1 1 auto; }
1709
+ /* Put there in this session — the row you are most likely reaching for. */
1710
+ .deck-assets-list li.is-fresh { background: color-mix(in srgb, var(--accent) 14%, transparent); }
1711
+ .deck-assets-none {
1712
+ justify-content: center;
1713
+ padding: 14px 10px;
1714
+ font: 400 12px/1.5 var(--sans);
1715
+ color: var(--muted);
1716
+ background: none;
1717
+ }
1699
1718
  .deck-assets-list li.is-failed { background: color-mix(in srgb, #e5484d 16%, transparent); }
1700
1719
  .deck-assets-list li.is-failed .deck-assets-pct { color: #ff8589; }
1701
1720
 
@@ -1856,6 +1875,40 @@ body.edit-open #deck-edit { transform: none; }
1856
1875
  transform: scale(1.04);
1857
1876
  }
1858
1877
 
1878
+ /* The image preview — over everything the way the drop sheet is: the image is
1879
+ the whole point while it is up, and a click anywhere puts it away. */
1880
+ .deck-assets-preview {
1881
+ position: fixed;
1882
+ inset: 0;
1883
+ z-index: 72;
1884
+ display: grid;
1885
+ place-items: center;
1886
+ background: color-mix(in srgb, var(--bg) 74%, transparent);
1887
+ backdrop-filter: blur(8px);
1888
+ cursor: zoom-out;
1889
+ }
1890
+ .deck-assets-preview[hidden] { display: none; }
1891
+ .deck-assets-preview figure {
1892
+ margin: 0;
1893
+ display: flex;
1894
+ flex-direction: column;
1895
+ align-items: center;
1896
+ gap: 12px;
1897
+ }
1898
+ .deck-assets-preview img {
1899
+ max-width: min(1100px, 84vw);
1900
+ max-height: 78vh;
1901
+ border: 1px solid var(--line);
1902
+ border-radius: 10px;
1903
+ /* A card behind it, so a transparent PNG reads as an image and not a hole. */
1904
+ background: var(--card);
1905
+ box-shadow: 0 18px 60px color-mix(in srgb, #000 45%, transparent);
1906
+ }
1907
+ .deck-assets-preview figcaption code {
1908
+ font: 500 12.5px/1.4 var(--mono);
1909
+ color: var(--accent-2);
1910
+ }
1911
+
1859
1912
  /* The open drawer's button wears the accent — it draws in nothing, like the
1860
1913
  pointer's; the id outweighs the generic armed-tool rule. */
1861
1914
  #tool-edit[aria-pressed='true'] {