@anton-gustafsson/snapshot-core 0.0.7 → 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.
@@ -0,0 +1,79 @@
1
+ import type { EncodeOptions } from './encode';
2
+ import type { SnapshotService } from './snapshot-service';
3
+ import type { SnapshotKey, SnapshotStorage } from './snapshot-storage';
4
+ /**
5
+ * The half of a server-backed storage only the consumer can write: the HTTP
6
+ * calls themselves. Everything around them — the local cache, the
7
+ * stale-while-revalidate order, the "a 404 must not evict my capture" rule —
8
+ * is `CachedSnapshotStorage`'s job.
9
+ */
10
+ export interface RemoteSnapshotStorage {
11
+ /**
12
+ * Resolve `null` for "nothing stored yet" — **including a 404**, which is the
13
+ * normal answer for an entity nobody has captured. Reject only for real
14
+ * failures (network down, 500, 403), which are reported through `onError`
15
+ * and leave the local copy standing.
16
+ */
17
+ load(id: string, key: SnapshotKey): Promise<Blob | null>;
18
+ save(blob: Blob, id: string, key: SnapshotKey): Promise<void>;
19
+ remove?(id: string, key: SnapshotKey): Promise<void>;
20
+ }
21
+ export interface CachedSnapshotStorageOptions {
22
+ remote: RemoteSnapshotStorage;
23
+ /** Defaults to a fresh `IndexedDbSnapshotStorage`. */
24
+ local?: SnapshotStorage;
25
+ /** Re-encode applied to the *uploaded* copy only; the local cache keeps the full capture. */
26
+ uploadEncode?: EncodeOptions;
27
+ /** Skip the upload (and report a `SnapshotTooLargeError`) above this size. Mirrors a server-side cap. */
28
+ maxBytes?: number;
29
+ /**
30
+ * Called for every swallowed failure instead of `console.warn`, so a consumer
31
+ * can filter the routine ones (a 403 for a read-only user) from the real ones.
32
+ */
33
+ onError?(err: unknown, key: SnapshotKey, op: 'load' | 'save' | 'remove'): void;
34
+ }
35
+ /**
36
+ * A local cache in front of a remote store — the shape every server-backed
37
+ * consumer ends up needing.
38
+ *
39
+ * Load is stale-while-revalidate: the local hit paints immediately and the
40
+ * remote read happens in the background, `publish()`ing through the attached
41
+ * service if it turns up something new. Save writes locally first and fires the
42
+ * upload without awaiting it, so a slow PUT can never delay a navigation.
43
+ *
44
+ * Freshness is delegated to the HTTP cache (`ETag` + `Cache-Control: no-cache`)
45
+ * rather than any version bookkeeping here.
46
+ */
47
+ export declare class CachedSnapshotStorage implements SnapshotStorage {
48
+ private readonly remote;
49
+ private readonly local;
50
+ private readonly options;
51
+ private service?;
52
+ /**
53
+ * Byte size of the copy currently cached per key. A revalidation that comes
54
+ * back the same size is treated as unchanged, so a warm list doesn't rewrite
55
+ * IndexedDB and swap every <img> src on every visit.
56
+ */
57
+ private cachedSizes;
58
+ /** In-flight revalidations, so N rows of the same key don't fan out N reads. */
59
+ private revalidating;
60
+ /** Keys `remove()` has deleted, so a revalidation already in flight can't resurrect them when it resolves after. */
61
+ private removedKeys;
62
+ /** Only defined when the wrapped local storage has one — keeps `SnapshotService.prune()`'s capability check honest. */
63
+ readonly keys?: () => Promise<SnapshotKey[]>;
64
+ constructor(options: CachedSnapshotStorageOptions);
65
+ attach(service: SnapshotService): void;
66
+ /** Local write is awaited (the caller needs a displayable URL); the upload is not. */
67
+ save(blob: Blob, key: SnapshotKey): Promise<string>;
68
+ load(key: SnapshotKey): Promise<string | null>;
69
+ loadMany(keys: SnapshotKey[]): Promise<Map<string, string | null>>;
70
+ remove(key: SnapshotKey): Promise<void>;
71
+ /** Best-effort: reads the cached blob back through its own URL to learn its size, without any storage needing a new method. */
72
+ private primeCachedSize;
73
+ private upload;
74
+ /** Awaited path: resolve the remote copy, seed the cache, hand back the URL. */
75
+ private fetchRemote;
76
+ /** Background path: only touches anything when the remote copy actually differs. */
77
+ private revalidate;
78
+ private report;
79
+ }
@@ -0,0 +1,176 @@
1
+ import { encodeSnapshot } from './encode';
2
+ import { SnapshotTooLargeError } from './errors';
3
+ import { IndexedDbSnapshotStorage } from './snapshot-storage';
4
+ /**
5
+ * A local cache in front of a remote store — the shape every server-backed
6
+ * consumer ends up needing.
7
+ *
8
+ * Load is stale-while-revalidate: the local hit paints immediately and the
9
+ * remote read happens in the background, `publish()`ing through the attached
10
+ * service if it turns up something new. Save writes locally first and fires the
11
+ * upload without awaiting it, so a slow PUT can never delay a navigation.
12
+ *
13
+ * Freshness is delegated to the HTTP cache (`ETag` + `Cache-Control: no-cache`)
14
+ * rather than any version bookkeeping here.
15
+ */
16
+ export class CachedSnapshotStorage {
17
+ constructor(options) {
18
+ /**
19
+ * Byte size of the copy currently cached per key. A revalidation that comes
20
+ * back the same size is treated as unchanged, so a warm list doesn't rewrite
21
+ * IndexedDB and swap every <img> src on every visit.
22
+ */
23
+ this.cachedSizes = new Map();
24
+ /** In-flight revalidations, so N rows of the same key don't fan out N reads. */
25
+ this.revalidating = new Set();
26
+ /** Keys `remove()` has deleted, so a revalidation already in flight can't resurrect them when it resolves after. */
27
+ this.removedKeys = new Set();
28
+ this.options = options;
29
+ this.remote = options.remote;
30
+ this.local = options.local ?? new IndexedDbSnapshotStorage();
31
+ if (this.local.keys) {
32
+ const local = this.local;
33
+ this.keys = () => local.keys();
34
+ }
35
+ }
36
+ attach(service) {
37
+ this.service = service;
38
+ this.local.attach?.(service);
39
+ }
40
+ /** Local write is awaited (the caller needs a displayable URL); the upload is not. */
41
+ async save(blob, key) {
42
+ const url = await this.local.save(blob, key);
43
+ this.cachedSizes.set(key.key, blob.size);
44
+ this.removedKeys.delete(key.key);
45
+ void this.upload(blob, key);
46
+ return url;
47
+ }
48
+ async load(key) {
49
+ const cached = await this.local.load(key);
50
+ if (cached) {
51
+ // A fresh instance (e.g. after a page reload) has an empty cachedSizes
52
+ // even though the local copy is already on disk — without this, the
53
+ // first revalidate() below has nothing to compare against and always
54
+ // treats an unchanged remote copy as "changed".
55
+ if (!this.cachedSizes.has(key.key))
56
+ await this.primeCachedSize(key, cached);
57
+ void this.revalidate(key, true);
58
+ return cached;
59
+ }
60
+ // Cold miss: nothing to paint yet, so the remote read is worth awaiting —
61
+ // and its URL is returned directly rather than announced via publish().
62
+ return this.fetchRemote(key);
63
+ }
64
+ async loadMany(keys) {
65
+ const cached = this.local.loadMany
66
+ ? await this.local.loadMany(keys)
67
+ : new Map(await Promise.all(keys.map(async (key) => [key.key, await this.local.load(key)])));
68
+ const misses = keys.filter((key) => !cached.get(key.key));
69
+ const fetched = await Promise.all(misses.map((key) => this.fetchRemote(key)));
70
+ misses.forEach((key, i) => cached.set(key.key, fetched[i]));
71
+ // Everything that came from the cache still gets revalidated, exactly as a
72
+ // single load() would — primed first, for the same reason as load() above.
73
+ const hits = keys.filter((key) => !misses.includes(key));
74
+ await Promise.all(hits.map(async (key) => {
75
+ const url = cached.get(key.key);
76
+ if (url && !this.cachedSizes.has(key.key))
77
+ await this.primeCachedSize(key, url);
78
+ }));
79
+ for (const key of hits)
80
+ void this.revalidate(key, true);
81
+ return cached;
82
+ }
83
+ async remove(key) {
84
+ this.removedKeys.add(key.key);
85
+ this.cachedSizes.delete(key.key);
86
+ await this.local.remove(key);
87
+ if (!this.remote.remove)
88
+ return;
89
+ try {
90
+ await this.remote.remove(key.id, key);
91
+ }
92
+ catch (err) {
93
+ this.report(err, key, 'remove');
94
+ throw err;
95
+ }
96
+ }
97
+ /** Best-effort: reads the cached blob back through its own URL to learn its size, without any storage needing a new method. */
98
+ async primeCachedSize(key, url) {
99
+ if (typeof fetch !== 'function')
100
+ return;
101
+ try {
102
+ const blob = await fetch(url).then((r) => r.blob());
103
+ this.cachedSizes.set(key.key, blob.size);
104
+ }
105
+ catch {
106
+ // A miss here just costs one extra revalidate() write, same as before this fix.
107
+ }
108
+ }
109
+ async upload(blob, key) {
110
+ try {
111
+ const payload = this.options.uploadEncode ? await encodeSnapshot(blob, this.options.uploadEncode) : blob;
112
+ const { maxBytes } = this.options;
113
+ if (maxBytes !== undefined && payload.size > maxBytes) {
114
+ this.report(new SnapshotTooLargeError(payload.size, maxBytes), key, 'save');
115
+ return;
116
+ }
117
+ await this.remote.save(payload, key.id, key);
118
+ }
119
+ catch (err) {
120
+ this.report(err, key, 'save');
121
+ }
122
+ }
123
+ /** Awaited path: resolve the remote copy, seed the cache, hand back the URL. */
124
+ async fetchRemote(key) {
125
+ try {
126
+ const blob = await this.remote.load(key.id, key);
127
+ // null / empty is "nobody has captured this yet", not a failure.
128
+ if (!blob || blob.size === 0)
129
+ return null;
130
+ const url = await this.local.save(blob, key);
131
+ this.cachedSizes.set(key.key, blob.size);
132
+ this.removedKeys.delete(key.key);
133
+ return url;
134
+ }
135
+ catch (err) {
136
+ this.report(err, key, 'load');
137
+ return null;
138
+ }
139
+ }
140
+ /** Background path: only touches anything when the remote copy actually differs. */
141
+ async revalidate(key, publish) {
142
+ if (this.revalidating.has(key.key))
143
+ return;
144
+ this.revalidating.add(key.key);
145
+ try {
146
+ const blob = await this.remote.load(key.id, key);
147
+ // The load-bearing rule: a remote miss must NEVER evict a local capture.
148
+ // Offline, 404, and "not synced yet" all land here.
149
+ if (!blob || blob.size === 0)
150
+ return;
151
+ // remove() ran while this read was in flight — don't resurrect what the
152
+ // caller just deleted.
153
+ if (this.removedKeys.has(key.key))
154
+ return;
155
+ if (this.cachedSizes.get(key.key) === blob.size)
156
+ return;
157
+ const url = await this.local.save(blob, key);
158
+ this.cachedSizes.set(key.key, blob.size);
159
+ if (publish)
160
+ this.service?.publish(key.id, url, { variant: key.variant });
161
+ }
162
+ catch (err) {
163
+ // A rejected read leaves the local copy in place — offline still works.
164
+ this.report(err, key, 'load');
165
+ }
166
+ finally {
167
+ this.revalidating.delete(key.key);
168
+ }
169
+ }
170
+ report(err, key, op) {
171
+ if (this.options.onError)
172
+ this.options.onError(err, key, op);
173
+ else
174
+ console.warn(`CachedSnapshotStorage: ${op} failed for "${key.key}"`, err);
175
+ }
176
+ }
@@ -0,0 +1,19 @@
1
+ export type SnapshotImageType = `image/${'png' | 'webp' | 'jpeg'}`;
2
+ export interface EncodeOptions {
3
+ /** Output format. Defaults to `image/webp`, falling back to `image/png` where WebP encoding isn't available. */
4
+ type?: SnapshotImageType;
5
+ /** Lossy quality, 0-1. Ignored for `image/png`. Default 0.8. */
6
+ quality?: number;
7
+ /** Longest edge in px — downscales to fit, never upscales. */
8
+ maxEdge?: number;
9
+ }
10
+ /**
11
+ * Re-encodes (and optionally downscales) a snapshot blob off the layout path,
12
+ * via `createImageBitmap` + `OffscreenCanvas` — so a full-resolution PNG from
13
+ * html2canvas becomes a small WebP suitable for a thumbnail or an upload.
14
+ *
15
+ * Deliberately never throws: a browser missing either API, a decode failure, or
16
+ * an encoder that doesn't know the requested type all resolve with the *input*
17
+ * blob. A thumbnail that's bigger than intended beats no thumbnail at all.
18
+ */
19
+ export declare function encodeSnapshot(blob: Blob, opts?: EncodeOptions): Promise<Blob>;
package/dist/encode.js ADDED
@@ -0,0 +1,56 @@
1
+ const DEFAULT_TYPE = 'image/webp';
2
+ const DEFAULT_QUALITY = 0.8;
3
+ function targetSize(width, height, maxEdge) {
4
+ if (!maxEdge || maxEdge <= 0)
5
+ return { width, height };
6
+ const longest = Math.max(width, height);
7
+ if (longest <= maxEdge)
8
+ return { width, height };
9
+ const ratio = maxEdge / longest;
10
+ return { width: Math.max(1, Math.round(width * ratio)), height: Math.max(1, Math.round(height * ratio)) };
11
+ }
12
+ /**
13
+ * Re-encodes (and optionally downscales) a snapshot blob off the layout path,
14
+ * via `createImageBitmap` + `OffscreenCanvas` — so a full-resolution PNG from
15
+ * html2canvas becomes a small WebP suitable for a thumbnail or an upload.
16
+ *
17
+ * Deliberately never throws: a browser missing either API, a decode failure, or
18
+ * an encoder that doesn't know the requested type all resolve with the *input*
19
+ * blob. A thumbnail that's bigger than intended beats no thumbnail at all.
20
+ */
21
+ export async function encodeSnapshot(blob, opts = {}) {
22
+ const type = opts.type ?? DEFAULT_TYPE;
23
+ const quality = opts.quality ?? DEFAULT_QUALITY;
24
+ if (typeof createImageBitmap !== 'function' || typeof OffscreenCanvas !== 'function')
25
+ return blob;
26
+ let bitmap;
27
+ try {
28
+ bitmap = await createImageBitmap(blob);
29
+ const { width, height } = targetSize(bitmap.width, bitmap.height, opts.maxEdge);
30
+ // Nothing to do: same format already, no downscale needed, and either quality
31
+ // doesn't apply (png) or the caller didn't ask for a specific one — we can't
32
+ // tell what quality the existing blob was actually encoded at, so a caller
33
+ // who *did* specify one always gets a fresh encode at that quality.
34
+ const sameDimsAndType = width === bitmap.width && height === bitmap.height && blob.type === type;
35
+ if (sameDimsAndType && (type === 'image/png' || opts.quality === undefined))
36
+ return blob;
37
+ const canvas = new OffscreenCanvas(width, height);
38
+ const ctx = canvas.getContext('2d');
39
+ if (!ctx)
40
+ return blob;
41
+ ctx.drawImage(bitmap, 0, 0, width, height);
42
+ let encoded = await canvas.convertToBlob({ type, quality });
43
+ // Chrome/Safari silently fall back to image/png for a type they can't
44
+ // encode, so trust the *result's* type rather than a feature probe.
45
+ if (encoded.type !== type && type !== 'image/png') {
46
+ encoded = await canvas.convertToBlob({ type: 'image/png' });
47
+ }
48
+ return encoded;
49
+ }
50
+ catch {
51
+ return blob;
52
+ }
53
+ finally {
54
+ bitmap?.close();
55
+ }
56
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Typed errors, so a caller can branch on *why* a capture failed instead of
3
+ * string-matching a prose message. Every rejection thrown by the library
4
+ * extends `SnapshotError`.
5
+ */
6
+ export declare class SnapshotError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /** The element passed to `capture()` was removed from the document before the render started. */
10
+ export declare class SnapshotDetachedElementError extends SnapshotError {
11
+ readonly id: string;
12
+ constructor(id: string);
13
+ }
14
+ /** html2canvas rejected while rendering the element (e.g. "Unable to find element in cloned iframe"). */
15
+ export declare class SnapshotRenderError extends SnapshotError {
16
+ readonly id: string;
17
+ readonly cause: unknown;
18
+ constructor(id: string, cause: unknown);
19
+ }
20
+ /** `canvas.toBlob()` resolved null — tainted (cross-origin) content, or a zero-size element. */
21
+ export declare class SnapshotTaintedCanvasError extends SnapshotError {
22
+ readonly id: string;
23
+ constructor(id: string);
24
+ }
25
+ /** A blob exceeded a configured `maxBytes` cap (see `CachedSnapshotStorage`). */
26
+ export declare class SnapshotTooLargeError extends SnapshotError {
27
+ readonly size: number;
28
+ readonly maxBytes: number;
29
+ constructor(size: number, maxBytes: number);
30
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Typed errors, so a caller can branch on *why* a capture failed instead of
3
+ * string-matching a prose message. Every rejection thrown by the library
4
+ * extends `SnapshotError`.
5
+ */
6
+ export class SnapshotError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = new.target.name;
10
+ }
11
+ }
12
+ /** The element passed to `capture()` was removed from the document before the render started. */
13
+ export class SnapshotDetachedElementError extends SnapshotError {
14
+ constructor(id) {
15
+ super(`SnapshotService: the element for "${id}" is not connected to the document — ` +
16
+ 'it was removed (e.g. the view was destroyed) before the capture could run.');
17
+ this.id = id;
18
+ }
19
+ }
20
+ /** html2canvas rejected while rendering the element (e.g. "Unable to find element in cloned iframe"). */
21
+ export class SnapshotRenderError extends SnapshotError {
22
+ constructor(id, cause) {
23
+ super(`SnapshotService: html2canvas failed while capturing "${id}": ` +
24
+ (cause instanceof Error ? cause.message : String(cause)));
25
+ this.id = id;
26
+ this.cause = cause;
27
+ }
28
+ }
29
+ /** `canvas.toBlob()` resolved null — tainted (cross-origin) content, or a zero-size element. */
30
+ export class SnapshotTaintedCanvasError extends SnapshotError {
31
+ constructor(id) {
32
+ super(`SnapshotService: canvas.toBlob() returned null while capturing "${id}" — the element likely ` +
33
+ 'contains tainted (cross-origin, no CORS headers) content, or has zero size.');
34
+ this.id = id;
35
+ }
36
+ }
37
+ /** A blob exceeded a configured `maxBytes` cap (see `CachedSnapshotStorage`). */
38
+ export class SnapshotTooLargeError extends SnapshotError {
39
+ constructor(size, maxBytes) {
40
+ super(`Snapshot is ${size} bytes, above the configured maxBytes of ${maxBytes} — not uploaded.`);
41
+ this.size = size;
42
+ this.maxBytes = maxBytes;
43
+ }
44
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
1
  export * from './snapshot-nav-list';
2
2
  export * from './snapshot-service';
3
3
  export * from './snapshot-storage';
4
+ export * from './cached-snapshot-storage';
5
+ export * from './encode';
6
+ export * from './errors';
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
1
  export * from './snapshot-nav-list';
2
2
  export * from './snapshot-service';
3
3
  export * from './snapshot-storage';
4
+ export * from './cached-snapshot-storage';
5
+ export * from './encode';
6
+ export * from './errors';
@@ -1,14 +1,21 @@
1
1
  import { LitElement } from 'lit';
2
2
  import type { SnapshotService } from './snapshot-service';
3
- export interface NavItem {
3
+ export interface NavItem<T = unknown> {
4
+ /** The snapshot id — a plain domain id. Anything else the consumer needs belongs in `data`. */
4
5
  id: string;
5
6
  label: string;
6
7
  /** A plain-text glyph (e.g. an emoji), or markup — a string starting with `<` renders as raw HTML/SVG instead of text, so a consumer can pass its own icon (e.g. `<svg>...</svg>`). Only the placeholder frame shown before a card's first capture. */
7
8
  icon?: string;
8
- route?: string;
9
9
  description?: string;
10
+ /** Arbitrary consumer payload — echoed back verbatim on `nav-select` / `nav-edit`, so no lookup-by-id is needed in the handler. */
11
+ data?: T;
12
+ /** Per-item override of the component-level `editable` — for per-row rights. */
13
+ editable?: boolean;
14
+ /** @deprecated Put the route in `data` and read it off the emitted item. Kept for one release. */
15
+ route?: string;
10
16
  }
11
- export type SnapshotNavListVariant = 'list' | 'icon-only' | 'card';
17
+ /** `'icon-only'` is the old name for `'tile'`; it still works and normalises to `'tile'`. */
18
+ export type SnapshotNavListVariant = 'list' | 'tile' | 'card' | 'icon-only';
12
19
  /** `overlay` floats the edit button over the thumbnail (top-right, reveals on hover); `meta` pins it to the right edge of the title's line (description below), always visible. */
13
20
  export type SnapshotNavListEditButtonPosition = 'overlay' | 'meta';
14
21
  /**
@@ -20,8 +27,17 @@ export type SnapshotNavListEditButtonPosition = 'overlay' | 'meta';
20
27
  export declare class SnapshotNavList extends LitElement {
21
28
  static styles: import("lit").CSSResult;
22
29
  items: NavItem[];
30
+ /** `card` by default — a framed preview with title/description underneath. `tile` is the compact contact-sheet grid, `list` a sidebar row. */
23
31
  variant: SnapshotNavListVariant;
24
- /** icon-only tile overlay: tint behind the title so it stays legible over any image. Transparent by default — opt into a scrim explicitly. */
32
+ /**
33
+ * Second dimension on every id — typically the active theme, so a light and a
34
+ * dark capture of the same view are stored (and read) separately. Passed
35
+ * straight through to `SnapshotService.get()` as `variant`.
36
+ */
37
+ variantKey?: string;
38
+ /** Lets the host itself scroll (see `--snapshot-nav-list-max-height`) instead of growing unbounded. */
39
+ scrollable: boolean;
40
+ /** tile overlay: tint behind the title so it stays legible over any image. Transparent by default — opt into a scrim explicitly. */
25
41
  overlayTint: 'dark' | 'light' | 'none';
26
42
  /** caption background tint strength, 0-1 */
27
43
  textOverlayOpacity: number;
@@ -29,11 +45,11 @@ export declare class SnapshotNavList extends LitElement {
29
45
  imageOverlayOpacity: number;
30
46
  /** backdrop blur behind the title, in px */
31
47
  overlayBlur: number;
32
- /** icon-only only: 'bottom' is the caption strip (default), 'center' centers a larger title. */
48
+ /** tile only: 'bottom' is the caption strip (default), 'center' centers a larger title. */
33
49
  labelPosition: 'bottom' | 'center';
34
50
  /** Defaults to the shared singleton — set your own instance (e.g. a namespaced or custom-storage SnapshotService) per <snapshot-nav-list> if needed. */
35
51
  snapshotService: SnapshotService;
36
- /** 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). */
52
+ /** 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`. */
37
53
  editable: boolean;
38
54
  /** 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. */
39
55
  editButtonPosition: SnapshotNavListEditButtonPosition;
@@ -41,26 +57,47 @@ export declare class SnapshotNavList extends LitElement {
41
57
  editIcon: string;
42
58
  private thumbs;
43
59
  private loadingIds;
44
- /** 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. */
60
+ /**
61
+ * In-flight dedup guard, separate from `loadingIds` (which is only for
62
+ * spinner display) so a repeat `loadThumb` call for an id already being
63
+ * fetched is a no-op. Keyed by `${variant}\0${id}` — not just `id` — so a
64
+ * `variantKey` switch mid-flight doesn't have the new variant's fetch
65
+ * silently dropped because the *previous* variant's id is still marked
66
+ * in-flight.
67
+ */
45
68
  private fetchingIds;
46
69
  private unsubscribe?;
70
+ private fetchKey;
47
71
  private subscribeToService;
48
72
  connectedCallback(): void;
49
73
  disconnectedCallback(): void;
50
74
  willUpdate(changed: Map<string, unknown>): void;
51
75
  updated(changed: Map<string, unknown>): void;
52
- private loadThumb;
76
+ /**
77
+ * One batched read for the whole list — `getMany()` collapses to a single
78
+ * query/round-trip on a storage that implements `loadMany`, instead of one
79
+ * per row. `fetchingIds` still guards per id, so an `items` reassignment
80
+ * mid-flight doesn't re-request what's already coming.
81
+ */
82
+ private loadThumbs;
53
83
  /** image scrim: blur + its own (usually 0) tint strength — independent of the caption's. */
54
84
  private get imageOverlayStyle();
55
85
  /** caption background: tint (to pop the text) + the same blur. */
56
86
  private get metaStyle();
57
87
  private select;
58
88
  private edit;
89
+ private isEditable;
59
90
  private renderEditButton;
60
91
  render(): import("lit-html").TemplateResult<1>;
61
92
  }
93
+ /** Detail type of both `nav-select` and `nav-edit` — the clicked item itself. */
94
+ export type SnapshotNavEvent<T = unknown> = CustomEvent<NavItem<T>>;
62
95
  declare global {
63
96
  interface HTMLElementTagNameMap {
64
97
  'snapshot-nav-list': SnapshotNavList;
65
98
  }
99
+ interface HTMLElementEventMap {
100
+ 'nav-select': SnapshotNavEvent;
101
+ 'nav-edit': SnapshotNavEvent;
102
+ }
66
103
  }