@anton-gustafsson/snapshot-core 0.4.2 → 0.4.3

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/dist/index.d.ts CHANGED
@@ -4,5 +4,3 @@ export * from './snapshot-storage';
4
4
  export * from './cached-snapshot-storage';
5
5
  export * from './encode';
6
6
  export * from './errors';
7
- export * from './neutralize-oklch';
8
- export * from './wait-for-canvases-to-paint';
package/dist/index.js CHANGED
@@ -4,5 +4,3 @@ export * from './snapshot-storage';
4
4
  export * from './cached-snapshot-storage';
5
5
  export * from './encode';
6
6
  export * from './errors';
7
- export * from './neutralize-oklch';
8
- export * from './wait-for-canvases-to-paint';
@@ -1,4 +1,9 @@
1
1
  /**
2
+ * Internal to `SnapshotService.capture()` — wired up via
3
+ * `CaptureOptions.neutralizeColors`, not meant to be called directly. Kept
4
+ * in its own module (rather than inlined) so it stays independently unit
5
+ * testable and `colorjs.io` stays an on-demand import.
6
+ *
2
7
  * html2canvas can't parse the CSS `oklch()`/`oklab()` color functions that
3
8
  * `getComputedStyle` resolves a growing share of real-world CSS to —
4
9
  * Tailwind v4's default palette among others — independent of how the color
@@ -9,13 +14,12 @@
9
14
  * in that space), so anything animating a color at capture time needs the
10
15
  * same treatment.
11
16
  *
12
- * Call this on the *live* document, before `capture()` not just on the
13
- * element being captured. html2canvas clones the whole document (for
14
- * correct ancestor stacking/background), not only the target element, so a
15
- * descendant can still inherit or otherwise resolve through an ancestor this
16
- * call never touched if `root` is scoped too narrowly; `document.documentElement`
17
- * is the safe default. Restore once the capture settles this rewrites
18
- * real inline styles on the live page, visibly if left in place.
17
+ * Runs on the whole document (`capture()` always passes
18
+ * `document.documentElement`), not just the captured element — html2canvas
19
+ * clones the whole document (for correct ancestor stacking/background), so
20
+ * a descendant could otherwise still inherit an un-neutralized color from
21
+ * outside the captured element. Restored once the capture settles — this
22
+ * rewrites real inline styles on the live page, visibly if left in place.
19
23
  *
20
24
  * Walks the subtree, rewrites every computed property whose value contains
21
25
  * `oklch(...)`/`oklab(...)` to an inline `hsl()` equivalent (custom
@@ -1,5 +1,10 @@
1
1
  const OKLCH_PATTERN = /okl(?:ch|ab)\([^)]*\)/gi;
2
2
  /**
3
+ * Internal to `SnapshotService.capture()` — wired up via
4
+ * `CaptureOptions.neutralizeColors`, not meant to be called directly. Kept
5
+ * in its own module (rather than inlined) so it stays independently unit
6
+ * testable and `colorjs.io` stays an on-demand import.
7
+ *
3
8
  * html2canvas can't parse the CSS `oklch()`/`oklab()` color functions that
4
9
  * `getComputedStyle` resolves a growing share of real-world CSS to —
5
10
  * Tailwind v4's default palette among others — independent of how the color
@@ -10,13 +15,12 @@ const OKLCH_PATTERN = /okl(?:ch|ab)\([^)]*\)/gi;
10
15
  * in that space), so anything animating a color at capture time needs the
11
16
  * same treatment.
12
17
  *
13
- * Call this on the *live* document, before `capture()` not just on the
14
- * element being captured. html2canvas clones the whole document (for
15
- * correct ancestor stacking/background), not only the target element, so a
16
- * descendant can still inherit or otherwise resolve through an ancestor this
17
- * call never touched if `root` is scoped too narrowly; `document.documentElement`
18
- * is the safe default. Restore once the capture settles this rewrites
19
- * real inline styles on the live page, visibly if left in place.
18
+ * Runs on the whole document (`capture()` always passes
19
+ * `document.documentElement`), not just the captured element — html2canvas
20
+ * clones the whole document (for correct ancestor stacking/background), so
21
+ * a descendant could otherwise still inherit an un-neutralized color from
22
+ * outside the captured element. Restored once the capture settles — this
23
+ * rewrites real inline styles on the live page, visibly if left in place.
20
24
  *
21
25
  * Walks the subtree, rewrites every computed property whose value contains
22
26
  * `oklch(...)`/`oklab(...)` to an inline `hsl()` equivalent (custom
@@ -14,21 +14,16 @@ export interface NavItem<T = unknown> {
14
14
  /** @deprecated Put the route in `data` and read it off the emitted item. Kept for one release. */
15
15
  route?: string;
16
16
  }
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';
19
17
  /** `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. */
20
18
  export type SnapshotNavListEditButtonPosition = 'overlay' | 'meta';
21
19
  /**
22
- * Visual identity: a contact sheet. Every tile is a "frame" numbered like a strip
23
- * of negatives because that's literally what a snapshot thumbnail is. All colors
24
- * come from CSS custom properties (themeable) with sensible fallbacks derived from
25
- * `currentColor`, so an unstyled host still looks intentional.
20
+ * A grid of preview cards: a contained (never-cropped) screenshot above real
21
+ * title/description text below it the text never sits on top of the
22
+ * image, so it needs no overlay tint to stay legible.
26
23
  */
27
24
  export declare class SnapshotNavList extends LitElement {
28
25
  static styles: import("lit").CSSResult;
29
26
  items: NavItem[];
30
- /** `card` by default — a framed preview with title/description underneath. `tile` is the compact contact-sheet grid, `list` a sidebar row. */
31
- variant: SnapshotNavListVariant;
32
27
  /**
33
28
  * Second dimension on every id — typically the active theme, so a light and a
34
29
  * dark capture of the same view are stored (and read) separately. Passed
@@ -37,21 +32,11 @@ export declare class SnapshotNavList extends LitElement {
37
32
  variantKey?: string;
38
33
  /** Lets the host itself scroll (see `--snapshot-nav-list-max-height`) instead of growing unbounded. */
39
34
  scrollable: boolean;
40
- /** tile overlay: tint behind the title so it stays legible over any image. Transparent by default — opt into a scrim explicitly. */
41
- overlayTint: 'dark' | 'light' | 'none';
42
- /** caption background tint strength, 0-1 */
43
- textOverlayOpacity: number;
44
- /** image scrim tint strength, 0-1 — 0 keeps the image clear (blur only) */
45
- imageOverlayOpacity: number;
46
- /** backdrop blur behind the title, in px */
47
- overlayBlur: number;
48
- /** tile only: 'bottom' is the caption strip (default), 'center' centers a larger title. */
49
- labelPosition: 'bottom' | 'center';
50
35
  /** Defaults to the shared singleton — set your own instance (e.g. a namespaced or custom-storage SnapshotService) per <snapshot-nav-list> if needed. */
51
36
  snapshotService: SnapshotService;
52
37
  /** 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`. */
53
38
  editable: boolean;
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
+ /** 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. */
55
40
  editButtonPosition: SnapshotNavListEditButtonPosition;
56
41
  /** 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>`). */
57
42
  editIcon: string;
@@ -80,10 +65,6 @@ export declare class SnapshotNavList extends LitElement {
80
65
  * mid-flight doesn't re-request what's already coming.
81
66
  */
82
67
  private loadThumbs;
83
- /** image scrim: blur + its own (usually 0) tint strength — independent of the caption's. */
84
- private get imageOverlayStyle();
85
- /** caption background: tint (to pop the text) + the same blur. */
86
- private get metaStyle();
87
68
  private select;
88
69
  private edit;
89
70
  private isEditable;
@@ -6,7 +6,6 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
6
6
  };
7
7
  import { LitElement, html, css } from 'lit';
8
8
  import { property, state } from 'lit/decorators.js';
9
- import { styleMap } from 'lit/directives/style-map.js';
10
9
  import { unsafeHTML } from 'lit/directives/unsafe-html.js';
11
10
  import { snapshotService as defaultSnapshotService } from './snapshot-service';
12
11
  function isMarkupIcon(icon) {
@@ -14,34 +13,21 @@ function isMarkupIcon(icon) {
14
13
  }
15
14
  const DEFAULT_EDIT_ICON = '✎';
16
15
  /**
17
- * Visual identity: a contact sheet. Every tile is a "frame" numbered like a strip
18
- * of negatives because that's literally what a snapshot thumbnail is. All colors
19
- * come from CSS custom properties (themeable) with sensible fallbacks derived from
20
- * `currentColor`, so an unstyled host still looks intentional.
16
+ * A grid of preview cards: a contained (never-cropped) screenshot above real
17
+ * title/description text below it the text never sits on top of the
18
+ * image, so it needs no overlay tint to stay legible.
21
19
  */
22
20
  export class SnapshotNavList extends LitElement {
23
21
  constructor() {
24
22
  super(...arguments);
25
23
  this.items = [];
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
24
  /** Lets the host itself scroll (see `--snapshot-nav-list-max-height`) instead of growing unbounded. */
29
25
  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. */
31
- this.overlayTint = 'none';
32
- /** caption background tint strength, 0-1 */
33
- this.textOverlayOpacity = 0.35;
34
- /** image scrim tint strength, 0-1 — 0 keeps the image clear (blur only) */
35
- this.imageOverlayOpacity = 0;
36
- /** backdrop blur behind the title, in px */
37
- this.overlayBlur = 0;
38
- /** tile only: 'bottom' is the caption strip (default), 'center' centers a larger title. */
39
- this.labelPosition = 'bottom';
40
26
  /** Defaults to the shared singleton — set your own instance (e.g. a namespaced or custom-storage SnapshotService) per <snapshot-nav-list> if needed. */
41
27
  this.snapshotService = defaultSnapshotService;
42
28
  /** 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`. */
43
29
  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. */
30
+ /** 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. */
45
31
  this.editButtonPosition = 'overlay';
46
32
  /** 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
33
  this.editIcon = DEFAULT_EDIT_ICON;
@@ -77,66 +63,48 @@ export class SnapshotNavList extends LitElement {
77
63
  list-style: none;
78
64
  margin: 0;
79
65
  padding: 0;
80
- display: flex;
81
- flex-direction: column;
82
- gap: var(--snapshot-nav-list-gap, 0.3rem);
66
+ display: grid;
67
+ grid-template-columns: repeat(auto-fill, minmax(var(--snapshot-nav-list-card-min-width, 220px), 1fr));
68
+ gap: var(--snapshot-nav-list-card-gap, 1.25rem);
83
69
  }
84
70
  li {
85
71
  display: flex;
86
- align-items: center;
87
- gap: 0.7rem;
72
+ flex-direction: column;
73
+ align-items: stretch;
74
+ gap: 0;
88
75
  cursor: pointer;
89
- padding: 0.45rem 0.55rem;
76
+ padding: var(--snapshot-nav-list-card-padding, 0.5rem);
90
77
  border-radius: var(--snapshot-nav-list-radius, 10px);
78
+ background: var(--snapshot-nav-list-card-bg, transparent);
79
+ border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
80
+ box-shadow: none;
81
+ transition:
82
+ box-shadow var(--snapshot-nav-list-card-transition-dur, 0.15s) ease,
83
+ border-color var(--snapshot-nav-list-card-transition-dur, 0.15s) ease;
91
84
  }
92
85
  li:hover,
93
86
  li:focus-visible {
94
- background: color-mix(in srgb, currentColor 7%, transparent);
87
+ background: var(--snapshot-nav-list-card-bg, transparent);
88
+ box-shadow: var(--snapshot-nav-list-card-shadow, 0 2px 8px color-mix(in srgb, currentColor 18%, transparent));
89
+ border-color: color-mix(in srgb, currentColor 22%, transparent);
95
90
  outline: none;
96
91
  }
97
- li:focus-visible .thumb-wrap {
98
- outline: 2px solid var(--frame-accent);
99
- outline-offset: 2px;
92
+ li:focus-visible {
93
+ outline: none;
94
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--frame-accent) 55%, transparent);
100
95
  }
101
96
 
102
97
  .thumb-wrap {
103
98
  position: relative;
104
- width: 160px;
105
- height: 100px;
99
+ width: 100%;
100
+ height: auto;
101
+ aspect-ratio: 2 / 1;
106
102
  border-radius: var(--snapshot-nav-list-radius-sm, 7px);
107
- flex-shrink: 0;
103
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 12%, transparent);
104
+ background: color-mix(in srgb, currentColor 4%, transparent);
105
+ display: grid;
106
+ place-items: center;
108
107
  overflow: hidden;
109
- box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 16%, transparent);
110
- }
111
- /* signature: registration-mark corners, like a photo mount */
112
- .thumb-wrap::before,
113
- .thumb-wrap::after {
114
- content: '';
115
- position: absolute;
116
- width: 9px;
117
- height: 9px;
118
- pointer-events: none;
119
- opacity: 0;
120
- transition: opacity 0.15s ease;
121
- z-index: 2;
122
- }
123
- .thumb-wrap::before {
124
- top: 4px;
125
- left: 4px;
126
- border-top: 2px solid var(--frame-accent);
127
- border-left: 2px solid var(--frame-accent);
128
- }
129
- .thumb-wrap::after {
130
- bottom: 4px;
131
- right: 4px;
132
- border-bottom: 2px solid var(--frame-accent);
133
- border-right: 2px solid var(--frame-accent);
134
- }
135
- li:hover .thumb-wrap::before,
136
- li:hover .thumb-wrap::after,
137
- li:focus-visible .thumb-wrap::before,
138
- li:focus-visible .thumb-wrap::after {
139
- opacity: 1;
140
108
  }
141
109
 
142
110
  .thumb {
@@ -145,7 +113,8 @@ export class SnapshotNavList extends LitElement {
145
113
  display: block;
146
114
  }
147
115
  img.thumb {
148
- object-fit: cover;
116
+ /* contain, not cover — the whole preview stays readable, nothing cropped */
117
+ object-fit: contain;
149
118
  object-position: center;
150
119
  background: transparent;
151
120
  }
@@ -198,41 +167,27 @@ export class SnapshotNavList extends LitElement {
198
167
  }
199
168
  }
200
169
 
201
- /* independent from .meta's tint — image-overlay-opacity defaults to 0 so the image stays clear (blur only) */
202
- .image-overlay {
203
- position: absolute;
204
- inset: 0;
205
- background: var(--overlay-bg, transparent);
206
- backdrop-filter: blur(var(--overlay-blur, 0px));
207
- -webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
208
- pointer-events: none;
209
- }
210
- /* the overlay exists so a tile's overlaid title stays legible — list
211
- variant shows the label beside the thumb, not on top of it, so the
212
- tint has nothing to do there. */
213
- :host(:not([variant='tile'])) .image-overlay {
214
- display: none;
215
- }
216
-
217
170
  .meta {
218
171
  display: flex;
219
172
  flex-direction: column;
220
- gap: 0.15rem;
173
+ gap: 0.25rem;
221
174
  min-width: 0;
175
+ padding: 0.75rem 0.5rem 0.5rem;
222
176
  }
223
177
  .label {
224
178
  overflow: hidden;
225
179
  text-overflow: ellipsis;
226
- white-space: nowrap;
180
+ white-space: normal;
227
181
  color: inherit;
228
- font-weight: 500;
182
+ font-weight: 600;
183
+ font-size: 1rem;
229
184
  }
230
185
  .description {
231
186
  overflow: hidden;
232
187
  text-overflow: ellipsis;
233
- white-space: nowrap;
188
+ white-space: normal;
234
189
  color: color-mix(in srgb, currentColor 60%, transparent);
235
- font-size: 0.75rem;
190
+ font-size: 0.8125rem;
236
191
  }
237
192
 
238
193
  .edit-button {
@@ -275,18 +230,15 @@ export class SnapshotNavList extends LitElement {
275
230
  fill: currentColor;
276
231
  }
277
232
 
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. */
233
+ /* Transparent by default (display: contents) so .label/.meta keep applying
234
+ unchanged; it only becomes a real row when the edit button moves in
235
+ beside the title. */
282
236
  .label-row {
283
237
  display: contents;
284
238
  }
285
239
  /* 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 {
240
+ still on its own line underneath. */
241
+ :host([edit-button-position='meta']) .label-row {
290
242
  display: flex;
291
243
  align-items: center;
292
244
  gap: 0.5rem;
@@ -295,11 +247,11 @@ export class SnapshotNavList extends LitElement {
295
247
  /* the title takes the whole row so the button lands on the card's right
296
248
  edge, still on the title's own line (the description sits below it);
297
249
  min-width: 0 keeps a long title ellipsising instead of pushing out. */
298
- :host([edit-button-position='meta']:not([variant='icon-only'])) .label {
250
+ :host([edit-button-position='meta']) .label {
299
251
  flex: 1;
300
252
  min-width: 0;
301
253
  }
302
- :host([edit-button-position='meta']:not([variant='icon-only'])) .edit-button {
254
+ :host([edit-button-position='meta']) .edit-button {
303
255
  position: static;
304
256
  flex-shrink: 0;
305
257
  /* in-flow, over the host's own background: currentColor-derived instead
@@ -309,132 +261,9 @@ export class SnapshotNavList extends LitElement {
309
261
  color: inherit;
310
262
  opacity: 1;
311
263
  }
312
- :host([edit-button-position='meta']:not([variant='icon-only'])) .edit-button:hover {
264
+ :host([edit-button-position='meta']) .edit-button:hover {
313
265
  background: color-mix(in srgb, currentColor 20%, transparent);
314
266
  }
315
-
316
- /* list: a compact thumb reads better in a narrow sidebar than the grid's 160x100 */
317
- :host([variant='list']) .thumb-wrap {
318
- width: 108px;
319
- height: 68px;
320
- }
321
-
322
- /* tile: contact-sheet grid, caption strip pinned to the bottom of each frame */
323
- :host([variant='tile']) ul {
324
- flex-direction: row;
325
- flex-wrap: wrap;
326
- gap: 0.6rem;
327
- }
328
- :host([variant='tile']) li {
329
- position: relative;
330
- width: var(--snapshot-nav-list-tile-width, 160px);
331
- height: var(--snapshot-nav-list-tile-height, 100px);
332
- padding: 0;
333
- overflow: hidden;
334
- }
335
- :host([variant='tile']) .thumb-wrap {
336
- width: 100%;
337
- height: 100%;
338
- border-radius: var(--snapshot-nav-list-radius, 10px);
339
- }
340
- :host([variant='tile']) .meta {
341
- position: absolute;
342
- inset: auto 0 0 0;
343
- margin: var(--snapshot-nav-list-overlay-margin, 0);
344
- border-radius: var(--snapshot-nav-list-overlay-radius, 0);
345
- align-items: flex-start;
346
- gap: 0.15rem;
347
- padding: 0.4rem 0.5rem;
348
- color: var(--overlay-text, #fff);
349
- background: var(--overlay-bg, transparent);
350
- backdrop-filter: blur(var(--overlay-blur, 0px));
351
- -webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
352
- }
353
- :host([variant='tile']) .label {
354
- white-space: normal;
355
- }
356
- :host([variant='tile']) .description {
357
- color: color-mix(in srgb, var(--overlay-text, #fff) 75%, transparent);
358
- }
359
-
360
- /* label-position="center": title big and centered */
361
- :host([variant='tile'][label-position='center']) .meta {
362
- inset: 0;
363
- align-items: center;
364
- justify-content: center;
365
- padding: 0.6rem;
366
- }
367
- :host([variant='tile'][label-position='center']) .label {
368
- font-size: 1.15rem;
369
- font-weight: 600;
370
- text-align: center;
371
- }
372
-
373
- /* card: a contained (never cropped) preview above real body text below it —
374
- text never sits on top of the image, so unlike a tile it needs no
375
- overlay tint to stay legible. Modeled on a typical "preview card"
376
- pattern: framed shot, title + description underneath, shadow on hover. */
377
- :host([variant='card']) ul {
378
- display: grid;
379
- grid-template-columns: repeat(auto-fill, minmax(var(--snapshot-nav-list-card-min-width, 220px), 1fr));
380
- gap: var(--snapshot-nav-list-card-gap, 1.25rem);
381
- }
382
- :host([variant='card']) li {
383
- flex-direction: column;
384
- align-items: stretch;
385
- gap: 0;
386
- padding: var(--snapshot-nav-list-card-padding, 0.5rem);
387
- background: var(--snapshot-nav-list-card-bg, transparent);
388
- border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
389
- box-shadow: none;
390
- transition:
391
- box-shadow var(--snapshot-nav-list-card-transition-dur, 0.15s) ease,
392
- border-color var(--snapshot-nav-list-card-transition-dur, 0.15s) ease;
393
- }
394
- :host([variant='card']) li:hover,
395
- :host([variant='card']) li:focus-visible {
396
- background: var(--snapshot-nav-list-card-bg, transparent);
397
- box-shadow: var(--snapshot-nav-list-card-shadow, 0 2px 8px color-mix(in srgb, currentColor 18%, transparent));
398
- border-color: color-mix(in srgb, currentColor 22%, transparent);
399
- }
400
- :host([variant='card']) li:focus-visible {
401
- outline: none;
402
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--frame-accent) 55%, transparent);
403
- }
404
- :host([variant='card']) li:focus-visible .thumb-wrap {
405
- outline: none;
406
- }
407
- :host([variant='card']) .thumb-wrap {
408
- width: 100%;
409
- height: auto;
410
- aspect-ratio: 2 / 1;
411
- border-radius: var(--snapshot-nav-list-radius-sm, 7px);
412
- box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 12%, transparent);
413
- background: color-mix(in srgb, currentColor 4%, transparent);
414
- display: grid;
415
- place-items: center;
416
- }
417
- :host([variant='card']) .thumb-wrap::before,
418
- :host([variant='card']) .thumb-wrap::after {
419
- display: none;
420
- }
421
- :host([variant='card']) img.thumb {
422
- /* contain, not cover — the whole dashboard stays readable, nothing cropped */
423
- object-fit: contain;
424
- }
425
- :host([variant='card']) .meta {
426
- padding: 0.75rem 0.5rem 0.5rem;
427
- gap: 0.25rem;
428
- }
429
- :host([variant='card']) .label {
430
- white-space: normal;
431
- font-size: 1rem;
432
- font-weight: 600;
433
- }
434
- :host([variant='card']) .description {
435
- white-space: normal;
436
- font-size: 0.8125rem;
437
- }
438
267
  `; }
439
268
  fetchKey(variant, id) {
440
269
  return `${variant ?? ''}\0${id}`;
@@ -479,11 +308,6 @@ export class SnapshotNavList extends LitElement {
479
308
  // update instead of triggering Lit's "update scheduled from updated()"
480
309
  // warning that came from doing this same flip inside updated().
481
310
  willUpdate(changed) {
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
311
  // A variant-key switch (e.g. light -> dark) invalidates every thumbnail:
488
312
  // they're separate snapshots under separate keys.
489
313
  if (changed.has('variantKey'))
@@ -544,27 +368,6 @@ export class SnapshotNavList extends LitElement {
544
368
  this.requestUpdate();
545
369
  }
546
370
  }
547
- /** image scrim: blur + its own (usually 0) tint strength — independent of the caption's. */
548
- get imageOverlayStyle() {
549
- const blur = `${this.overlayBlur}px`;
550
- if (this.overlayTint === 'none' || this.imageOverlayOpacity === 0) {
551
- return { '--overlay-blur': blur };
552
- }
553
- const tintColor = this.overlayTint === 'light' ? '#fff' : '#000';
554
- const bg = `color-mix(in srgb, ${tintColor} ${Math.round(this.imageOverlayOpacity * 100)}%, transparent)`;
555
- return { '--overlay-bg': bg, '--overlay-blur': blur };
556
- }
557
- /** caption background: tint (to pop the text) + the same blur. */
558
- get metaStyle() {
559
- const blur = `${this.overlayBlur}px`;
560
- if (this.overlayTint === 'none') {
561
- return { '--overlay-bg': 'transparent', '--overlay-text': 'inherit', '--overlay-blur': blur };
562
- }
563
- const tintColor = this.overlayTint === 'light' ? '#fff' : '#000';
564
- const bg = `color-mix(in srgb, ${tintColor} ${Math.round(this.textOverlayOpacity * 100)}%, transparent)`;
565
- const text = this.overlayTint === 'light' ? '#111' : '#fff';
566
- return { '--overlay-bg': bg, '--overlay-text': text, '--overlay-blur': blur };
567
- }
568
371
  // Both events carry the whole item — including `data` — so a handler never
569
372
  // has to look the item back up by id.
570
373
  select(item) {
@@ -590,11 +393,7 @@ export class SnapshotNavList extends LitElement {
590
393
  </button>`;
591
394
  }
592
395
  render() {
593
- const imageOverlayStyle = this.imageOverlayStyle;
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';
396
+ const editInMeta = this.editButtonPosition === 'meta';
598
397
  return html `
599
398
  <ul role="listbox">
600
399
  ${this.items.map((item) => html `
@@ -617,10 +416,9 @@ export class SnapshotNavList extends LitElement {
617
416
  >${item.icon ? (isMarkupIcon(item.icon) ? unsafeHTML(item.icon) : item.icon) : ''}</span
618
417
  >
619
418
  </div>`}
620
- <div class="image-overlay" part="overlay" style=${styleMap(imageOverlayStyle)}></div>
621
419
  ${this.isEditable(item) && !editInMeta ? this.renderEditButton(item) : ''}
622
420
  </div>
623
- <div class="meta" part="meta" style=${styleMap(metaStyle)}>
421
+ <div class="meta" part="meta">
624
422
  <div class="label-row" part="label-row">
625
423
  <span class="label" part="label">${item.label}</span>
626
424
  ${this.isEditable(item) && editInMeta ? this.renderEditButton(item) : ''}
@@ -636,30 +434,12 @@ export class SnapshotNavList extends LitElement {
636
434
  __decorate([
637
435
  property({ type: Array })
638
436
  ], SnapshotNavList.prototype, "items", void 0);
639
- __decorate([
640
- property({ reflect: true })
641
- ], SnapshotNavList.prototype, "variant", void 0);
642
437
  __decorate([
643
438
  property({ attribute: 'variant-key' })
644
439
  ], SnapshotNavList.prototype, "variantKey", void 0);
645
440
  __decorate([
646
441
  property({ type: Boolean, reflect: true })
647
442
  ], SnapshotNavList.prototype, "scrollable", void 0);
648
- __decorate([
649
- property({ attribute: 'overlay-tint' })
650
- ], SnapshotNavList.prototype, "overlayTint", void 0);
651
- __decorate([
652
- property({ type: Number, attribute: 'text-overlay-opacity' })
653
- ], SnapshotNavList.prototype, "textOverlayOpacity", void 0);
654
- __decorate([
655
- property({ type: Number, attribute: 'image-overlay-opacity' })
656
- ], SnapshotNavList.prototype, "imageOverlayOpacity", void 0);
657
- __decorate([
658
- property({ type: Number, attribute: 'overlay-blur' })
659
- ], SnapshotNavList.prototype, "overlayBlur", void 0);
660
- __decorate([
661
- property({ reflect: true, attribute: 'label-position' })
662
- ], SnapshotNavList.prototype, "labelPosition", void 0);
663
443
  __decorate([
664
444
  property({ attribute: false })
665
445
  ], SnapshotNavList.prototype, "snapshotService", void 0);
@@ -40,14 +40,72 @@ export interface CaptureOptions extends VariantOptions {
40
40
  * (and the clone of `el`) it's about to render, before it renders it. The
41
41
  * escape hatch for anything that needs to touch the clone specifically. A
42
42
  * returned promise is awaited.
43
- *
44
- * For colors specifically, prefer `neutralizeOklchColors` called on the
45
- * *live* document before `capture()` (see its own docs) — html2canvas
46
- * clones the whole document, not just `el`, so a fix scoped to `element`
47
- * here can still miss a color a descendant inherits from outside it.
48
43
  */
49
44
  onclone?: (document: Document, element: HTMLElement) => void | Promise<void>;
45
+ /**
46
+ * Target output size in CSS px. Required to use `fit`; ignored otherwise.
47
+ */
48
+ width?: number;
49
+ height?: number;
50
+ /**
51
+ * How to fit `el`'s content into `width`×`height`:
52
+ * - `'contain'` — scale to fit entirely inside the box, letterboxed (filled
53
+ * with `background`) if the aspect ratio doesn't match.
54
+ * - `'cover'` — scale to fill the box, cropping the overflow, centered.
55
+ * Upscales content smaller than the target — this is a thumbnail, not a
56
+ * lossless copy.
57
+ *
58
+ * Implemented by cloning `el` off-screen into a `width`×`height` frame and
59
+ * capturing that instead, so it works the same for any caller — no
60
+ * framework-specific lifetime handling. `contentCrop` defaults to `false`
61
+ * whenever `fit` is set, since the frame is already the exact requested
62
+ * size.
63
+ */
64
+ fit?: 'contain' | 'cover';
65
+ /** Fill color behind letterboxing or undersized content. Defaults to html2canvas's own default (white). */
66
+ background?: string;
67
+ /**
68
+ * Every capture crops to the bounding box of `el`'s visible children (see
69
+ * `CONTENT_PADDING`), so a container much bigger than its content doesn't
70
+ * capture as mostly empty space. Pass `false` to capture `el` at its own
71
+ * full size instead — needed for exact, pre-sized output (`fit` does this
72
+ * automatically).
73
+ */
74
+ contentCrop?: boolean;
75
+ /**
76
+ * Rewrites resolved `oklch()`/`oklab()` colors (anywhere in the document,
77
+ * not just `el`) to `hsl()` for the duration of the capture, then restores
78
+ * them. html2canvas can't parse either function, and `getComputedStyle`
79
+ * resolves a growing share of ordinary CSS to one of them regardless of
80
+ * how the color was authored — Tailwind v4's default palette included.
81
+ * Off by default: it's a full-document style walk plus an on-demand import
82
+ * of `colorjs.io`, so only pay for it on a page that actually hits this.
83
+ */
84
+ neutralizeColors?: boolean;
50
85
  }
86
+ /**
87
+ * The padding (in CSS px) applied around the bounding box of `el`'s visible
88
+ * children when the default content-crop runs — see `CaptureOptions.contentCrop`.
89
+ */
90
+ export declare const CONTENT_PADDING = 16;
91
+ /**
92
+ * Scale + centered-crop-offset for fitting a `natural` box into a `target`
93
+ * box. `'cover'` uses `Math.max` (fills the target, overflow gets cropped,
94
+ * offsets can go negative to center that overflow); `'contain'` uses
95
+ * `Math.min` (fits entirely inside, offsets are never negative — the
96
+ * shortfall is left for the caller to fill as letterboxing).
97
+ */
98
+ export declare function computeFit(natural: {
99
+ width: number;
100
+ height: number;
101
+ }, target: {
102
+ width: number;
103
+ height: number;
104
+ }, fit: 'contain' | 'cover'): {
105
+ scale: number;
106
+ offsetX: number;
107
+ offsetY: number;
108
+ };
51
109
  export declare class SnapshotService {
52
110
  private storage;
53
111
  private scale;
@@ -1,7 +1,12 @@
1
1
  import { encodeSnapshot } from './encode';
2
2
  import { SnapshotDetachedElementError, SnapshotRenderError, SnapshotTaintedCanvasError } from './errors';
3
+ import { neutralizeOklchColors } from './neutralize-oklch';
3
4
  import { IndexedDbSnapshotStorage } from './snapshot-storage';
4
- const CONTENT_PADDING = 16;
5
+ /**
6
+ * The padding (in CSS px) applied around the bounding box of `el`'s visible
7
+ * children when the default content-crop runs — see `CaptureOptions.contentCrop`.
8
+ */
9
+ export const CONTENT_PADDING = 16;
5
10
  const VARIANT_SEPARATOR = '@';
6
11
  // Tracks keyPrefixes already claimed by a live SnapshotService instance, so
7
12
  // two instances that both forget to set one (or pick the same one) get a
@@ -45,6 +50,57 @@ function getContentBounds(el) {
45
50
  return full;
46
51
  return { x, y, width, height };
47
52
  }
53
+ /**
54
+ * Scale + centered-crop-offset for fitting a `natural` box into a `target`
55
+ * box. `'cover'` uses `Math.max` (fills the target, overflow gets cropped,
56
+ * offsets can go negative to center that overflow); `'contain'` uses
57
+ * `Math.min` (fits entirely inside, offsets are never negative — the
58
+ * shortfall is left for the caller to fill as letterboxing).
59
+ */
60
+ export function computeFit(natural, target, fit) {
61
+ const scale = fit === 'cover'
62
+ ? Math.max(target.width / natural.width, target.height / natural.height)
63
+ : Math.min(target.width / natural.width, target.height / natural.height);
64
+ const scaledWidth = natural.width * scale;
65
+ const scaledHeight = natural.height * scale;
66
+ return {
67
+ scale,
68
+ offsetX: (scaledWidth - target.width) / 2,
69
+ offsetY: (scaledHeight - target.height) / 2,
70
+ };
71
+ }
72
+ /**
73
+ * Clones `el` into an off-screen `width`×`height` frame, scaled per `fit`
74
+ * and centered. Returns the frame (to capture instead of `el`) and a cleanup
75
+ * that removes it from the document — always call it, capture or not.
76
+ */
77
+ function buildFitFrame(el, width, height, fit, background) {
78
+ const naturalWidth = el.scrollWidth;
79
+ const naturalHeight = el.scrollHeight;
80
+ const frame = document.createElement('div');
81
+ frame.style.position = 'fixed';
82
+ frame.style.top = '0';
83
+ frame.style.left = '-99999px';
84
+ frame.style.width = `${width}px`;
85
+ frame.style.height = `${height}px`;
86
+ frame.style.overflow = 'hidden';
87
+ if (background)
88
+ frame.style.background = background;
89
+ if (naturalWidth > 0 && naturalHeight > 0) {
90
+ const { scale, offsetX, offsetY } = computeFit({ width: naturalWidth, height: naturalHeight }, { width, height }, fit);
91
+ const clone = el.cloneNode(true);
92
+ clone.style.position = 'absolute';
93
+ clone.style.top = `${-offsetY}px`;
94
+ clone.style.left = `${-offsetX}px`;
95
+ clone.style.transformOrigin = 'top left';
96
+ clone.style.transform = `scale(${scale})`;
97
+ clone.style.width = `${naturalWidth}px`;
98
+ clone.style.height = `${naturalHeight}px`;
99
+ frame.appendChild(clone);
100
+ }
101
+ document.body.appendChild(frame);
102
+ return { frame, cleanup: () => frame.remove() };
103
+ }
48
104
  export class SnapshotService {
49
105
  constructor(config = {}) {
50
106
  this.listeners = new Set();
@@ -130,14 +186,30 @@ export class SnapshotService {
130
186
  // storing a blank thumbnail over a good one.
131
187
  if (!el.isConnected)
132
188
  throw new SnapshotDetachedElementError(key.id);
133
- const crop = getContentBounds(el);
189
+ const useFit = opts.width !== undefined && opts.height !== undefined && opts.fit !== undefined;
190
+ const fitFrame = useFit
191
+ ? buildFitFrame(el, opts.width, opts.height, opts.fit, opts.background)
192
+ : undefined;
193
+ const target = fitFrame?.frame ?? el;
194
+ // `fit` already produced an exactly-sized frame — the default content-crop
195
+ // would only fight that (see CaptureOptions.contentCrop), so it's off by
196
+ // default here unless the caller explicitly asks for it back.
197
+ const contentCrop = opts.contentCrop ?? !useFit;
198
+ const crop = contentCrop
199
+ ? getContentBounds(target)
200
+ : useFit
201
+ // Known exactly — no need to round-trip through layout (`clientWidth`)
202
+ // for the one size `buildFitFrame` was already asked to produce.
203
+ ? { x: 0, y: 0, width: opts.width, height: opts.height }
204
+ : { x: 0, y: 0, width: target.clientWidth, height: target.clientHeight };
205
+ const restoreColors = opts.neutralizeColors ? await neutralizeOklchColors(document.documentElement) : undefined;
134
206
  // Imported on demand so `import '@anton-gustafsson/snapshot-core'` doesn't
135
207
  // pull a DOM-only dependency into a Node/SSR/Jest process that only wants
136
208
  // the types or a storage.
137
209
  const { default: html2canvas } = await import('html2canvas');
138
210
  let canvas;
139
211
  try {
140
- canvas = await html2canvas(el, {
212
+ canvas = await html2canvas(target, {
141
213
  scale: opts.scale ?? this.scale,
142
214
  logging: false,
143
215
  useCORS: true,
@@ -152,6 +224,10 @@ export class SnapshotService {
152
224
  // errors.ts documents every rejection from this library as a SnapshotError.
153
225
  throw new SnapshotRenderError(key.id, err);
154
226
  }
227
+ finally {
228
+ restoreColors?.();
229
+ fitFrame?.cleanup();
230
+ }
155
231
  const raw = await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new SnapshotTaintedCanvasError(key.id))), 'image/png'));
156
232
  const encode = opts.encode ?? this.encode;
157
233
  const blob = encode ? await encodeSnapshot(raw, encode) : raw;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anton-gustafsson/snapshot-core",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "A pluggable snapshot service that turns any DOM element into a stored, shareable image, plus an optional <snapshot-nav-list> web component to display them.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,20 +0,0 @@
1
- /**
2
- * A `<canvas>`-based chart (Chart.js, and most others) typically paints
3
- * through its own `ResizeObserver` + `requestAnimationFrame` cycle, entirely
4
- * decoupled from the framework's own change detection — so the tick-plus-
5
- * one-frame `injectSnapshotCapture()` waits for can still race a chart's own
6
- * pending redraw. html2canvas only ever copies whatever is currently in a
7
- * canvas's pixel buffer, so losing that race produces a capture with a fully
8
- * blank chart: no error, nothing logged, just an empty rectangle where the
9
- * chart should be.
10
- *
11
- * Polls every `<canvas>` under `root` (one requestAnimationFrame per
12
- * attempt) until each either has non-transparent pixel data or `maxFrames`
13
- * is exhausted, whichever comes first. Best-effort: a canvas that's still
14
- * blank after `maxFrames` is left as-is rather than blocking the capture
15
- * indefinitely — a bad thumbnail isn't worth stalling navigation over. Call
16
- * this before `capture()`, on the live element (unlike `neutralizeOklchColors`,
17
- * which needs the clone — this needs the canvas that's actually still
18
- * painting).
19
- */
20
- export declare function waitForCanvasesToPaint(root: HTMLElement, maxFrames?: number): Promise<void>;
@@ -1,53 +0,0 @@
1
- /**
2
- * A `<canvas>`-based chart (Chart.js, and most others) typically paints
3
- * through its own `ResizeObserver` + `requestAnimationFrame` cycle, entirely
4
- * decoupled from the framework's own change detection — so the tick-plus-
5
- * one-frame `injectSnapshotCapture()` waits for can still race a chart's own
6
- * pending redraw. html2canvas only ever copies whatever is currently in a
7
- * canvas's pixel buffer, so losing that race produces a capture with a fully
8
- * blank chart: no error, nothing logged, just an empty rectangle where the
9
- * chart should be.
10
- *
11
- * Polls every `<canvas>` under `root` (one requestAnimationFrame per
12
- * attempt) until each either has non-transparent pixel data or `maxFrames`
13
- * is exhausted, whichever comes first. Best-effort: a canvas that's still
14
- * blank after `maxFrames` is left as-is rather than blocking the capture
15
- * indefinitely — a bad thumbnail isn't worth stalling navigation over. Call
16
- * this before `capture()`, on the live element (unlike `neutralizeOklchColors`,
17
- * which needs the clone — this needs the canvas that's actually still
18
- * painting).
19
- */
20
- export async function waitForCanvasesToPaint(root, maxFrames = 6) {
21
- const canvases = Array.from(root.querySelectorAll('canvas'));
22
- if (canvases.length === 0)
23
- return;
24
- for (let frame = 0; frame < maxFrames; frame++) {
25
- if (canvases.every((canvas) => !isBlank(canvas)))
26
- return;
27
- await new Promise((resolve) => requestAnimationFrame(() => resolve()));
28
- }
29
- }
30
- function isBlank(canvas) {
31
- if (canvas.width === 0 || canvas.height === 0)
32
- return false;
33
- try {
34
- // Only 2D canvases can be cheaply inspected this way — a WebGL canvas
35
- // with `preserveDrawingBuffer: false` reads back as empty regardless of
36
- // what's on screen, so treat anything non-2D as "can't tell, assume it's
37
- // fine" rather than waiting forever.
38
- const ctx = canvas.getContext('2d');
39
- if (!ctx)
40
- return false;
41
- const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
42
- for (let i = 0; i < data.length; i++) {
43
- if (data[i] !== 0)
44
- return false;
45
- }
46
- return true;
47
- }
48
- catch {
49
- // A tainted canvas throws on getImageData — not something this check
50
- // can resolve either way, so don't block the capture over it.
51
- return false;
52
- }
53
- }