@anton-gustafsson/snapshot-core 0.0.6 → 0.4.1

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.
@@ -12,6 +12,7 @@ import { snapshotService as defaultSnapshotService } from './snapshot-service';
12
12
  function isMarkupIcon(icon) {
13
13
  return icon.trimStart().startsWith('<');
14
14
  }
15
+ const DEFAULT_EDIT_ICON = '✎';
15
16
  /**
16
17
  * Visual identity: a contact sheet. Every tile is a "frame" — numbered like a strip
17
18
  * of negatives — because that's literally what a snapshot thumbnail is. All colors
@@ -22,8 +23,11 @@ export class SnapshotNavList extends LitElement {
22
23
  constructor() {
23
24
  super(...arguments);
24
25
  this.items = [];
25
- this.variant = 'icon-only';
26
- /** icon-only tile overlay: tint behind the title so it stays legible over any image. Transparent by default — opt into a scrim explicitly. */
26
+ /** `card` by default — a framed preview with title/description underneath. `tile` is the compact contact-sheet grid, `list` a sidebar row. */
27
+ this.variant = 'card';
28
+ /** Lets the host itself scroll (see `--snapshot-nav-list-max-height`) instead of growing unbounded. */
29
+ this.scrollable = false;
30
+ /** tile overlay: tint behind the title so it stays legible over any image. Transparent by default — opt into a scrim explicitly. */
27
31
  this.overlayTint = 'none';
28
32
  /** caption background tint strength, 0-1 */
29
33
  this.textOverlayOpacity = 0.35;
@@ -31,15 +35,26 @@ export class SnapshotNavList extends LitElement {
31
35
  this.imageOverlayOpacity = 0;
32
36
  /** backdrop blur behind the title, in px */
33
37
  this.overlayBlur = 0;
34
- /** icon-only only: 'bottom' is the caption strip (default), 'center' centers a larger title. */
38
+ /** tile only: 'bottom' is the caption strip (default), 'center' centers a larger title. */
35
39
  this.labelPosition = 'bottom';
36
40
  /** Defaults to the shared singleton — set your own instance (e.g. a namespaced or custom-storage SnapshotService) per <snapshot-nav-list> if needed. */
37
41
  this.snapshotService = defaultSnapshotService;
38
- /** Shows a top-right edit button per card. Off by default — clicking it fires `nav-edit` instead of `nav-select`; the host decides what "edit" means (e.g. open its own dialog component). */
42
+ /** Shows an edit button per card. Off by default — clicking it fires `nav-edit` instead of `nav-select`; the host decides what "edit" means (e.g. open its own dialog component). Overridable per row via `NavItem.editable`. */
39
43
  this.editable = false;
44
+ /** Where the edit button sits: `overlay` (default) floats it over the thumbnail; `meta` pins it to the right edge of the title row, with the description below. Ignored by the icon-only variant, whose caption is itself an overlay. */
45
+ this.editButtonPosition = 'overlay';
46
+ /** Edit button glyph. Same convention as `NavItem.icon`: a plain-text glyph (e.g. an emoji), or markup — a string starting with `<` renders as raw HTML/SVG, so a consumer can pass its own icon (e.g. `<svg>...</svg>`). */
47
+ this.editIcon = DEFAULT_EDIT_ICON;
40
48
  this.thumbs = new Map();
41
49
  this.loadingIds = new Set();
42
- /** In-flight dedup guard, separate from `loadingIds` (which is only for spinner display) so a repeat `loadThumb` call for an id already being fetched is a no-op. */
50
+ /**
51
+ * In-flight dedup guard, separate from `loadingIds` (which is only for
52
+ * spinner display) so a repeat `loadThumb` call for an id already being
53
+ * fetched is a no-op. Keyed by `${variant}\0${id}` — not just `id` — so a
54
+ * `variantKey` switch mid-flight doesn't have the new variant's fetch
55
+ * silently dropped because the *previous* variant's id is still marked
56
+ * in-flight.
57
+ */
43
58
  this.fetchingIds = new Set();
44
59
  }
45
60
  static { this.styles = css `
@@ -47,6 +62,16 @@ export class SnapshotNavList extends LitElement {
47
62
  display: block;
48
63
  font-family: var(--snapshot-nav-list-font, inherit);
49
64
  --frame-accent: var(--snapshot-nav-list-accent, #ff5a1f);
65
+ max-height: var(--snapshot-nav-list-max-height, none);
66
+ }
67
+ /* scrollable: the host owns the scroll, so a long list in a flex sidebar
68
+ doesn't push its siblings off-screen and the consumer doesn't have to
69
+ reach in with its own overflow/min-height CSS. */
70
+ :host([scrollable]) {
71
+ flex: 1 1 auto;
72
+ min-height: 0;
73
+ overflow-y: auto;
74
+ overscroll-behavior: contain;
50
75
  }
51
76
  ul {
52
77
  list-style: none;
@@ -182,10 +207,10 @@ export class SnapshotNavList extends LitElement {
182
207
  -webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
183
208
  pointer-events: none;
184
209
  }
185
- /* the overlay exists so icon-only's overlaid title stays legible — list
210
+ /* the overlay exists so a tile's overlaid title stays legible — list
186
211
  variant shows the label beside the thumb, not on top of it, so the
187
212
  tint has nothing to do there. */
188
- :host(:not([variant='icon-only'])) .image-overlay {
213
+ :host(:not([variant='tile'])) .image-overlay {
189
214
  display: none;
190
215
  }
191
216
 
@@ -243,6 +268,50 @@ export class SnapshotNavList extends LitElement {
243
268
  outline: 2px solid var(--frame-accent);
244
269
  outline-offset: 1px;
245
270
  }
271
+ .edit-button svg {
272
+ width: 1em;
273
+ height: 1em;
274
+ display: block;
275
+ fill: currentColor;
276
+ }
277
+
278
+ /* Transparent by default (display: contents) so the existing per-variant
279
+ .label/.meta rules — including icon-only's absolute caption strip —
280
+ keep applying unchanged; it only becomes a real row when the edit
281
+ button moves in beside the title. */
282
+ .label-row {
283
+ display: contents;
284
+ }
285
+ /* edit-button-position="meta": button on the title's line, description
286
+ still on its own line underneath. Not offered for icon-only, whose
287
+ .meta is an absolutely positioned overlay strip — the overlay button is
288
+ already the right place there. */
289
+ :host([edit-button-position='meta']:not([variant='icon-only'])) .label-row {
290
+ display: flex;
291
+ align-items: center;
292
+ gap: 0.5rem;
293
+ min-width: 0;
294
+ }
295
+ /* the title takes the whole row so the button lands on the card's right
296
+ edge, still on the title's own line (the description sits below it);
297
+ min-width: 0 keeps a long title ellipsising instead of pushing out. */
298
+ :host([edit-button-position='meta']:not([variant='icon-only'])) .label {
299
+ flex: 1;
300
+ min-width: 0;
301
+ }
302
+ :host([edit-button-position='meta']:not([variant='icon-only'])) .edit-button {
303
+ position: static;
304
+ flex-shrink: 0;
305
+ /* in-flow, over the host's own background: currentColor-derived instead
306
+ of the overlay's dark scrim, and always visible (it isn't covering
307
+ anything, so hiding it until hover would just make it hard to find) */
308
+ background: color-mix(in srgb, currentColor 10%, transparent);
309
+ color: inherit;
310
+ opacity: 1;
311
+ }
312
+ :host([edit-button-position='meta']:not([variant='icon-only'])) .edit-button:hover {
313
+ background: color-mix(in srgb, currentColor 20%, transparent);
314
+ }
246
315
 
247
316
  /* list: a compact thumb reads better in a narrow sidebar than the grid's 160x100 */
248
317
  :host([variant='list']) .thumb-wrap {
@@ -250,25 +319,25 @@ export class SnapshotNavList extends LitElement {
250
319
  height: 68px;
251
320
  }
252
321
 
253
- /* icon-only: contact-sheet grid, caption strip pinned to the bottom of each frame */
254
- :host([variant='icon-only']) ul {
322
+ /* tile: contact-sheet grid, caption strip pinned to the bottom of each frame */
323
+ :host([variant='tile']) ul {
255
324
  flex-direction: row;
256
325
  flex-wrap: wrap;
257
326
  gap: 0.6rem;
258
327
  }
259
- :host([variant='icon-only']) li {
328
+ :host([variant='tile']) li {
260
329
  position: relative;
261
330
  width: var(--snapshot-nav-list-tile-width, 160px);
262
331
  height: var(--snapshot-nav-list-tile-height, 100px);
263
332
  padding: 0;
264
333
  overflow: hidden;
265
334
  }
266
- :host([variant='icon-only']) .thumb-wrap {
335
+ :host([variant='tile']) .thumb-wrap {
267
336
  width: 100%;
268
337
  height: 100%;
269
338
  border-radius: var(--snapshot-nav-list-radius, 10px);
270
339
  }
271
- :host([variant='icon-only']) .meta {
340
+ :host([variant='tile']) .meta {
272
341
  position: absolute;
273
342
  inset: auto 0 0 0;
274
343
  margin: var(--snapshot-nav-list-overlay-margin, 0);
@@ -281,28 +350,28 @@ export class SnapshotNavList extends LitElement {
281
350
  backdrop-filter: blur(var(--overlay-blur, 0px));
282
351
  -webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
283
352
  }
284
- :host([variant='icon-only']) .label {
353
+ :host([variant='tile']) .label {
285
354
  white-space: normal;
286
355
  }
287
- :host([variant='icon-only']) .description {
356
+ :host([variant='tile']) .description {
288
357
  color: color-mix(in srgb, var(--overlay-text, #fff) 75%, transparent);
289
358
  }
290
359
 
291
360
  /* label-position="center": title big and centered */
292
- :host([variant='icon-only'][label-position='center']) .meta {
361
+ :host([variant='tile'][label-position='center']) .meta {
293
362
  inset: 0;
294
363
  align-items: center;
295
364
  justify-content: center;
296
365
  padding: 0.6rem;
297
366
  }
298
- :host([variant='icon-only'][label-position='center']) .label {
367
+ :host([variant='tile'][label-position='center']) .label {
299
368
  font-size: 1.15rem;
300
369
  font-weight: 600;
301
370
  text-align: center;
302
371
  }
303
372
 
304
373
  /* card: a contained (never cropped) preview above real body text below it —
305
- text never sits on top of the image, so unlike icon-only it needs no
374
+ text never sits on top of the image, so unlike a tile it needs no
306
375
  overlay tint to stay legible. Modeled on a typical "preview card"
307
376
  pattern: framed shot, title + description underneath, shadow on hover. */
308
377
  :host([variant='card']) ul {
@@ -367,15 +436,25 @@ export class SnapshotNavList extends LitElement {
367
436
  font-size: 0.8125rem;
368
437
  }
369
438
  `; }
439
+ fetchKey(variant, id) {
440
+ return `${variant ?? ''}\0${id}`;
441
+ }
370
442
  subscribeToService() {
371
443
  this.unsubscribe?.();
372
- this.unsubscribe = this.snapshotService.subscribe((id, url) => {
444
+ this.unsubscribe = this.snapshotService.subscribe((id, url, variant) => {
373
445
  // Ignore captures for ids this list isn't showing — the service is
374
446
  // often a shared singleton, so without this guard `thumbs` would grow
375
447
  // forever with urls for every snapshot captured anywhere on the page,
376
448
  // not just this list's own items.
377
449
  if (!this.items.some((item) => item.id === id))
378
450
  return;
451
+ // Same reason, one dimension over: a dark-theme capture must not
452
+ // overwrite the light-theme tile this list is currently showing. Folds
453
+ // '' and undefined together on both sides — SnapshotService.keyOf()
454
+ // does the same, so a caller that sets variantKey to '' instead of
455
+ // leaving it undefined must still match a plain (no-variant) capture.
456
+ if ((variant || undefined) !== (this.variantKey || undefined))
457
+ return;
379
458
  if (url === null) {
380
459
  this.thumbs.delete(id);
381
460
  }
@@ -400,14 +479,23 @@ export class SnapshotNavList extends LitElement {
400
479
  // update instead of triggering Lit's "update scheduled from updated()"
401
480
  // warning that came from doing this same flip inside updated().
402
481
  willUpdate(changed) {
403
- if (changed.has('items')) {
482
+ // 'icon-only' is the pre-0.3 name for 'tile'. Normalising here (rather
483
+ // than in a setter) keeps the reflected attribute — and therefore every
484
+ // CSS selector — on the one canonical value.
485
+ if (this.variant === 'icon-only')
486
+ this.variant = 'tile';
487
+ // A variant-key switch (e.g. light -> dark) invalidates every thumbnail:
488
+ // they're separate snapshots under separate keys.
489
+ if (changed.has('variantKey'))
490
+ this.thumbs.clear();
491
+ if (changed.has('items') || changed.has('variantKey')) {
404
492
  const ids = new Set(this.items.map((item) => item.id));
405
493
  for (const id of this.thumbs.keys()) {
406
494
  if (!ids.has(id))
407
495
  this.thumbs.delete(id);
408
496
  }
409
497
  for (const item of this.items) {
410
- if (!this.thumbs.has(item.id) && !this.fetchingIds.has(item.id)) {
498
+ if (!this.thumbs.has(item.id) && !this.fetchingIds.has(this.fetchKey(this.variantKey, item.id))) {
411
499
  this.loadingIds.add(item.id);
412
500
  }
413
501
  }
@@ -417,25 +505,42 @@ export class SnapshotNavList extends LitElement {
417
505
  if (changed.has('snapshotService')) {
418
506
  this.subscribeToService();
419
507
  }
420
- if (changed.has('items')) {
421
- this.items.forEach((item) => this.loadThumb(item.id));
508
+ if (changed.has('items') || changed.has('variantKey')) {
509
+ void this.loadThumbs();
422
510
  }
423
511
  }
424
- async loadThumb(id) {
425
- if (this.thumbs.has(id) || this.fetchingIds.has(id))
512
+ /**
513
+ * One batched read for the whole list — `getMany()` collapses to a single
514
+ * query/round-trip on a storage that implements `loadMany`, instead of one
515
+ * per row. `fetchingIds` still guards per id, so an `items` reassignment
516
+ * mid-flight doesn't re-request what's already coming.
517
+ */
518
+ async loadThumbs() {
519
+ const variant = this.variantKey;
520
+ const wanted = this.items.filter((item) => !this.thumbs.has(item.id) && !this.fetchingIds.has(this.fetchKey(variant, item.id)));
521
+ if (wanted.length === 0)
426
522
  return;
427
- this.fetchingIds.add(id);
523
+ const ids = wanted.map((item) => item.id);
524
+ ids.forEach((id) => this.fetchingIds.add(this.fetchKey(variant, id)));
428
525
  try {
429
- const url = await this.snapshotService.get(id);
430
- if (url)
431
- this.thumbs.set(id, url);
526
+ const urls = await this.snapshotService.getMany(ids, { variant });
527
+ // Dropped if the variant changed while the read was in flight — those
528
+ // urls belong to the previous theme.
529
+ if (variant !== this.variantKey)
530
+ return;
531
+ for (const [id, url] of urls) {
532
+ if (url)
533
+ this.thumbs.set(id, url);
534
+ }
432
535
  }
433
536
  catch (err) {
434
- console.error(`snapshot-nav-list: failed to load thumbnail for "${id}"`, err);
537
+ console.error('snapshot-nav-list: failed to load thumbnails', err);
435
538
  }
436
539
  finally {
437
- this.fetchingIds.delete(id);
438
- this.loadingIds.delete(id);
540
+ ids.forEach((id) => {
541
+ this.fetchingIds.delete(this.fetchKey(variant, id));
542
+ this.loadingIds.delete(id);
543
+ });
439
544
  this.requestUpdate();
440
545
  }
441
546
  }
@@ -460,24 +565,36 @@ export class SnapshotNavList extends LitElement {
460
565
  const text = this.overlayTint === 'light' ? '#111' : '#fff';
461
566
  return { '--overlay-bg': bg, '--overlay-text': text, '--overlay-blur': blur };
462
567
  }
568
+ // Both events carry the whole item — including `data` — so a handler never
569
+ // has to look the item back up by id.
463
570
  select(item) {
464
- this.dispatchEvent(new CustomEvent('nav-select', {
465
- detail: { id: item.id, route: item.route },
466
- bubbles: true,
467
- composed: true,
468
- }));
571
+ this.dispatchEvent(new CustomEvent('nav-select', { detail: item, bubbles: true, composed: true }));
469
572
  }
470
573
  edit(e, item) {
471
574
  e.stopPropagation();
472
- this.dispatchEvent(new CustomEvent('nav-edit', {
473
- detail: { id: item.id, route: item.route },
474
- bubbles: true,
475
- composed: true,
476
- }));
575
+ this.dispatchEvent(new CustomEvent('nav-edit', { detail: item, bubbles: true, composed: true }));
576
+ }
577
+ isEditable(item) {
578
+ return item.editable ?? this.editable;
579
+ }
580
+ renderEditButton(item) {
581
+ const icon = this.editIcon || DEFAULT_EDIT_ICON;
582
+ return html `<button
583
+ type="button"
584
+ class="edit-button"
585
+ part="edit-button"
586
+ aria-label="Edit ${item.label}"
587
+ @click=${(e) => this.edit(e, item)}
588
+ >
589
+ ${isMarkupIcon(icon) ? unsafeHTML(icon) : icon}
590
+ </button>`;
477
591
  }
478
592
  render() {
479
593
  const imageOverlayStyle = this.imageOverlayStyle;
480
594
  const metaStyle = this.metaStyle;
595
+ // icon-only's caption is itself an overlay strip on the image, so there's
596
+ // no in-flow text row to put the button in — fall back to the overlay.
597
+ const editInMeta = this.editButtonPosition === 'meta' && this.variant !== 'icon-only';
481
598
  return html `
482
599
  <ul role="listbox">
483
600
  ${this.items.map((item) => html `
@@ -501,20 +618,13 @@ export class SnapshotNavList extends LitElement {
501
618
  >
502
619
  </div>`}
503
620
  <div class="image-overlay" part="overlay" style=${styleMap(imageOverlayStyle)}></div>
504
- ${this.editable
505
- ? html `<button
506
- type="button"
507
- class="edit-button"
508
- part="edit-button"
509
- aria-label="Edit ${item.label}"
510
- @click=${(e) => this.edit(e, item)}
511
- >
512
-
513
- </button>`
514
- : ''}
621
+ ${this.isEditable(item) && !editInMeta ? this.renderEditButton(item) : ''}
515
622
  </div>
516
623
  <div class="meta" part="meta" style=${styleMap(metaStyle)}>
517
- <span class="label" part="label">${item.label}</span>
624
+ <div class="label-row" part="label-row">
625
+ <span class="label" part="label">${item.label}</span>
626
+ ${this.isEditable(item) && editInMeta ? this.renderEditButton(item) : ''}
627
+ </div>
518
628
  ${item.description ? html `<span class="description" part="description">${item.description}</span>` : ''}
519
629
  </div>
520
630
  </li>
@@ -529,6 +639,12 @@ __decorate([
529
639
  __decorate([
530
640
  property({ reflect: true })
531
641
  ], SnapshotNavList.prototype, "variant", void 0);
642
+ __decorate([
643
+ property({ attribute: 'variant-key' })
644
+ ], SnapshotNavList.prototype, "variantKey", void 0);
645
+ __decorate([
646
+ property({ type: Boolean, reflect: true })
647
+ ], SnapshotNavList.prototype, "scrollable", void 0);
532
648
  __decorate([
533
649
  property({ attribute: 'overlay-tint' })
534
650
  ], SnapshotNavList.prototype, "overlayTint", void 0);
@@ -550,12 +666,20 @@ __decorate([
550
666
  __decorate([
551
667
  property({ type: Boolean })
552
668
  ], SnapshotNavList.prototype, "editable", void 0);
669
+ __decorate([
670
+ property({ reflect: true, attribute: 'edit-button-position' })
671
+ ], SnapshotNavList.prototype, "editButtonPosition", void 0);
672
+ __decorate([
673
+ property({ attribute: 'edit-icon' })
674
+ ], SnapshotNavList.prototype, "editIcon", void 0);
553
675
  __decorate([
554
676
  state()
555
677
  ], SnapshotNavList.prototype, "thumbs", void 0);
556
678
  __decorate([
557
679
  state()
558
680
  ], SnapshotNavList.prototype, "loadingIds", void 0);
559
- if (!customElements.get('snapshot-nav-list')) {
681
+ // Guarded so importing this package in a DOM-less process (SSR, Node, a test
682
+ // runner without jsdom) is a no-op instead of a crash.
683
+ if (typeof customElements !== 'undefined' && !customElements.get('snapshot-nav-list')) {
560
684
  customElements.define('snapshot-nav-list', SnapshotNavList);
561
685
  }
@@ -1,4 +1,5 @@
1
- import type { SnapshotStorage } from './snapshot-storage';
1
+ import type { EncodeOptions } from './encode';
2
+ import type { SnapshotKey, SnapshotStorage } from './snapshot-storage';
2
3
  export interface SnapshotServiceConfig {
3
4
  /** Provide your own backend-backed implementation to persist across devices. */
4
5
  storage?: SnapshotStorage;
@@ -11,21 +12,85 @@ export interface SnapshotServiceConfig {
11
12
  * their keys and live-update notifications can't collide.
12
13
  */
13
14
  keyPrefix?: string;
15
+ /** Re-encode applied to every capture before it's stored. Defaults to none (the raw html2canvas PNG). */
16
+ encode?: EncodeOptions;
17
+ /**
18
+ * Escape hatch for an existing store whose keys don't follow
19
+ * `keyPrefix + id [+ '@' + variant]` — return the key to use. Note that
20
+ * `parseKey()` (and therefore `prune()`) can't reverse a custom shape.
21
+ */
22
+ keyFor?: (key: SnapshotKey) => string;
23
+ }
24
+ /** `url` is null when the id was removed (or invalidated) rather than (re)captured. */
25
+ type Listener = (id: string, url: string | null, variant?: string) => void;
26
+ export interface VariantOptions {
27
+ /**
28
+ * A second dimension on the id — a theme, a density, a locale. Stored under
29
+ * its own key, so `get(id, { variant: 'dark' })` never returns the light one.
30
+ */
31
+ variant?: string;
32
+ }
33
+ export interface CaptureOptions extends VariantOptions {
34
+ /** Per-call override of the instance `scale`. */
35
+ scale?: number;
36
+ /** Per-call override of the instance `encode`. */
37
+ encode?: EncodeOptions;
14
38
  }
15
- /** `url` is null when the id was removed rather than (re)captured. */
16
- type Listener = (id: string, url: string | null) => void;
17
39
  export declare class SnapshotService {
18
40
  private storage;
19
41
  private scale;
20
42
  private keyPrefix;
21
- private channel;
43
+ private encode?;
44
+ private keyFor?;
45
+ /** Absent under Node/SSR (or any environment without BroadcastChannel) — same-tab notification still works. */
46
+ private channel?;
22
47
  private listeners;
48
+ /** In-flight captures, keyed by storage key, so two callers racing on the same view do one render. */
49
+ private capturing;
23
50
  constructor(config?: SnapshotServiceConfig);
24
- /** Capture works on any element — a snapshot-nav-list item is one convention, not a requirement. */
25
- capture(el: HTMLElement, id: string): Promise<string>;
26
- get(id: string): Promise<string | null>;
51
+ /**
52
+ * The fully-qualified storage key for an id (+ variant) under this instance.
53
+ * `id`/`variant` are `encodeURIComponent`-escaped before joining, so a `@`
54
+ * (or any other character) inside either can never be mistaken for the
55
+ * separator itself — without escaping, `id: 'a@b'` and `id: 'a', variant: 'b'`
56
+ * would otherwise land on the exact same key.
57
+ */
58
+ keyOf(id: string, opts?: VariantOptions): SnapshotKey;
59
+ /**
60
+ * Inverse of `keyOf()` for the default key shape — splits a stored key back
61
+ * into `{ id, variant }`. Returns `null` for a key belonging to a different
62
+ * `keyPrefix`, or for any key when a custom `keyFor` is configured (a custom
63
+ * shape isn't reversible).
64
+ */
65
+ parseKey(key: string): SnapshotKey | null;
66
+ /**
67
+ * Capture works on any element — a snapshot-nav-list item is one convention,
68
+ * not a requirement. Concurrent calls for the same id/variant share one
69
+ * render instead of racing two.
70
+ */
71
+ capture(el: HTMLElement, id: string, opts?: CaptureOptions): Promise<string>;
72
+ private runCapture;
73
+ get(id: string, opts?: VariantOptions): Promise<string | null>;
74
+ /**
75
+ * Batch read, keyed by bare id. Uses the storage's `loadMany()` when it has
76
+ * one (a single query/round-trip for a whole list) and falls back to parallel
77
+ * `load()`s when it doesn't.
78
+ */
79
+ getMany(ids: string[], opts?: VariantOptions): Promise<Map<string, string | null>>;
27
80
  /** Deletes a stored snapshot and notifies subscribers (this tab and others) that `id` is gone. */
28
- remove(id: string): Promise<void>;
81
+ remove(id: string, opts?: VariantOptions): Promise<void>;
82
+ /**
83
+ * Deletes every stored snapshot (all variants) whose id isn't in `keepIds`,
84
+ * so thumbnails don't outlive the entities they belong to. Requires a storage
85
+ * that implements `keys()`; returns the number of snapshots removed.
86
+ */
87
+ prune(keepIds: Iterable<string>): Promise<number>;
88
+ /**
89
+ * Warms the storage for a list of ids ahead of render (one `loadMany()` where
90
+ * the storage supports it) and publishes whatever it finds, so any mounted
91
+ * `<snapshot-nav-list>` paints from the first frame instead of spinning.
92
+ */
93
+ prefetch(ids: string[], opts?: VariantOptions): Promise<void>;
29
94
  /**
30
95
  * Announces a URL for `id` to subscribers (this tab and others) without
31
96
  * touching storage. For a storage layer that resolves a fresher value
@@ -33,7 +98,7 @@ export declare class SnapshotService {
33
98
  * local cache in front of a slower authoritative database — call this once
34
99
  * the slow read settles so any mounted <snapshot-nav-list> updates live.
35
100
  */
36
- publish(id: string, url: string): void;
101
+ publish(id: string, url: string, opts?: VariantOptions): void;
37
102
  /**
38
103
  * Tells subscribers to drop their locally cached thumbnail for `id` and
39
104
  * treat it as not-yet-loaded — without deleting anything from storage.
@@ -41,12 +106,22 @@ export declare class SnapshotService {
41
106
  * it again. Useful when the underlying data changed out from under the
42
107
  * cache (or, for a demo, to replay a loading state on demand).
43
108
  */
44
- invalidate(id: string): void;
45
- subscribe(cb: Listener): () => boolean;
109
+ invalidate(id: string, opts?: VariantOptions): void;
110
+ subscribe(cb: Listener): () => void;
46
111
  /** Releases the cross-tab BroadcastChannel. Call when this instance (a non-default, namespaced one) is no longer needed. */
47
112
  close(): void;
48
113
  private notify;
49
114
  }
50
- /** Default singleton (IndexedDB-backed). Replace with your own SnapshotService({ storage }) if you need a real backend. */
115
+ /**
116
+ * The shared IndexedDB-backed instance, created on first call — so importing
117
+ * this package never opens a BroadcastChannel or an IndexedDB connection an
118
+ * app that brings its own `SnapshotService` would never use.
119
+ */
120
+ export declare function getDefaultSnapshotService(): SnapshotService;
121
+ /**
122
+ * @deprecated Use `getDefaultSnapshotService()`. A lazy stand-in for the
123
+ * default instance: it forwards every access to the real service, constructing
124
+ * it on first touch rather than at import time.
125
+ */
51
126
  export declare const snapshotService: SnapshotService;
52
127
  export {};