@leavepulse/ui 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,7 +37,7 @@ const _hoisted_13 = ["data-index", "aria-label", "onClick"];
37
37
  const _hoisted_14 = ["src", "alt"];
38
38
  const WHEEL_STEP = 60;
39
39
  const BTN = "flex size-9 items-center justify-center rounded-control text-white/80 outline-none transition-colors duration-[var(--duration-fast)] hover:bg-white/15 hover:text-white focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40";
40
- const ARROW = "absolute top-1/2 z-10 flex size-11 -translate-y-1/2 items-center justify-center rounded-pill bg-black/45 text-white/90 outline-none backdrop-blur-sm transition-[background-color,scale] duration-[var(--duration-fast)] hover:bg-black/65 hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-0";
40
+ const ARROW = "absolute top-1/2 z-10 flex size-11 -translate-y-1/2 items-center justify-center rounded-pill bg-black/45 text-white/75 ring-1 ring-white/0 outline-none backdrop-blur-sm transition-[background-color,scale,color,box-shadow] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:bg-white/20 hover:text-white hover:ring-white/30 hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-0 motion-reduce:transition-none motion-reduce:hover:scale-100 motion-reduce:active:scale-100";
41
41
  const _sfc_main = /* @__PURE__ */ defineComponent({
42
42
  __name: "LpLightbox",
43
43
  props: {
@@ -1,17 +1,21 @@
1
1
  import { defineComponent, ref, computed, openBlock, createElementBlock, renderSlot, mergeProps, unref, Fragment, renderList, normalizeClass, createElementVNode, toDisplayString, createVNode, createCommentVNode, createBlock } from "vue";
2
2
  import { useMergedAttrs } from "../composables/useMergedClass.js";
3
3
  import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
4
- import { _ as _sfc_main$2 } from "./LpLightbox-Cixfxezw.js";
4
+ import { _ as _sfc_main$2 } from "./LpLightbox-oLqyjOln.js";
5
5
  const _hoisted_1 = { key: 0 };
6
6
  const _hoisted_2 = ["aria-label"];
7
7
  const _hoisted_3 = ["aria-label", "onClick"];
8
8
  const _hoisted_4 = ["src", "alt"];
9
9
  const _hoisted_5 = {
10
10
  key: 0,
11
- class: "pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-medium text-white"
11
+ class: "pointer-events-none absolute inset-0 flex items-center justify-center"
12
12
  };
13
13
  const _hoisted_6 = {
14
14
  key: 1,
15
+ class: "pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-medium text-white"
16
+ };
17
+ const _hoisted_7 = {
18
+ key: 2,
15
19
  class: "pointer-events-none absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-[background-color,opacity] duration-[var(--duration-fast)] group-hover:bg-black/25 group-hover:opacity-100 motion-reduce:transition-none",
16
20
  "aria-hidden": "true"
17
21
  };
@@ -28,7 +32,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
28
32
  rotatable: { type: Boolean, default: true },
29
33
  ariaLabel: { default: "Media" }
30
34
  },
31
- emits: ["select", "update:open"],
35
+ emits: ["select", "reveal", "update:open"],
32
36
  setup(__props, { expose: __expose, emit: __emit }) {
33
37
  const props = __props;
34
38
  const emit = __emit;
@@ -111,16 +115,37 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
111
115
  if (!item) return;
112
116
  emit("select", item, i);
113
117
  if (!props.openable) return;
114
- index.value = i;
118
+ const at = openIndexOf(i);
119
+ if (at === -1) return;
120
+ index.value = at;
115
121
  open.value = true;
116
122
  emit("update:open", true);
117
123
  }
118
124
  function openOverflow() {
119
125
  activate(visible.value.length);
120
126
  }
127
+ const revealed = ref(/* @__PURE__ */ new Set());
128
+ function isHidden(item) {
129
+ return Boolean(item.spoiler) && !revealed.value.has(item.src);
130
+ }
131
+ function reveal(item) {
132
+ revealed.value = new Set(revealed.value).add(item.src);
133
+ emit("reveal", item);
134
+ }
135
+ const openItems = computed(() => props.items.filter((item) => !isHidden(item)));
136
+ function openIndexOf(i) {
137
+ const item = props.items[i];
138
+ return item ? openItems.value.indexOf(item) : -1;
139
+ }
121
140
  __expose({
122
141
  /** Open the lightbox at an index — for a "view all" button outside the grid. */
123
- openAt: (i) => activate(i)
142
+ openAt: (i) => activate(i),
143
+ /** Uncover every spoiler in the set. */
144
+ revealAll: () => {
145
+ const next = new Set(revealed.value);
146
+ for (const item of props.items) if (item.spoiler) next.add(item.src);
147
+ revealed.value = next;
148
+ }
124
149
  });
125
150
  return (_ctx, _cache) => {
126
151
  return !__props.items.length ? (openBlock(), createElementBlock("div", _hoisted_1, [
@@ -141,8 +166,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
141
166
  __props.openable ? "cursor-pointer" : "cursor-default",
142
167
  "focus-visible:ring-2 focus-visible:ring-ring"
143
168
  ]),
144
- "aria-label": hiddenCount.value && i === visible.value.length - 1 ? `Show ${hiddenCount.value} more` : item.title || `Image ${i + 1} of ${__props.items.length}`,
145
- onClick: ($event) => hiddenCount.value && i === visible.value.length - 1 ? openOverflow() : activate(i)
169
+ "aria-label": isHidden(item) ? "Spoiler — show image" : hiddenCount.value && i === visible.value.length - 1 ? `Show ${hiddenCount.value} more` : item.title || `Image ${i + 1} of ${__props.items.length}`,
170
+ onClick: ($event) => isHidden(item) ? reveal(item) : hiddenCount.value && i === visible.value.length - 1 ? openOverflow() : activate(i)
146
171
  }, [
147
172
  renderSlot(_ctx.$slots, "item", {
148
173
  item,
@@ -150,16 +175,18 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
150
175
  }, () => [
151
176
  createElementVNode("img", {
152
177
  src: item.thumb || item.src,
153
- alt: item.title || "",
178
+ alt: isHidden(item) ? "" : item.title || "",
154
179
  loading: "lazy",
155
180
  decoding: "async",
156
- class: normalizeClass([
157
- "size-full object-cover transition-transform duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100",
158
- hiddenCount.value && i === visible.value.length - 1 ? "scale-[1.06] blur-[2px] brightness-[0.45] group-hover:scale-[1.06]" : ""
159
- ])
181
+ class: normalizeClass(["size-full object-cover transition-transform duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100", [
182
+ isHidden(item) ? "scale-[1.12] blur-[18px] brightness-[0.6] saturate-[1.1] group-hover:scale-[1.12]" : "",
183
+ !isHidden(item) && hiddenCount.value && i === visible.value.length - 1 ? "scale-[1.06] blur-[2px] brightness-[0.45] group-hover:scale-[1.06]" : ""
184
+ ]])
160
185
  }, null, 10, _hoisted_4)
161
186
  ]),
162
- hiddenCount.value && i === visible.value.length - 1 ? (openBlock(), createElementBlock("span", _hoisted_5, " +" + toDisplayString(hiddenCount.value), 1)) : __props.openable ? (openBlock(), createElementBlock("span", _hoisted_6, [
187
+ isHidden(item) ? (openBlock(), createElementBlock("span", _hoisted_5, [..._cache[2] || (_cache[2] = [
188
+ createElementVNode("span", { class: "rounded-pill bg-black/55 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white" }, " Spoiler ", -1)
189
+ ])])) : hiddenCount.value && i === visible.value.length - 1 ? (openBlock(), createElementBlock("span", _hoisted_6, " +" + toDisplayString(hiddenCount.value), 1)) : __props.openable ? (openBlock(), createElementBlock("span", _hoisted_7, [
163
190
  createVNode(_sfc_main$1, {
164
191
  name: "lucide:maximize-2",
165
192
  size: 18,
@@ -174,7 +201,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
174
201
  "onUpdate:open": _cache[0] || (_cache[0] = ($event) => open.value = $event),
175
202
  index: index.value,
176
203
  "onUpdate:index": _cache[1] || (_cache[1] = ($event) => index.value = $event),
177
- items: __props.items,
204
+ items: openItems.value,
178
205
  downloadable: __props.downloadable,
179
206
  rotatable: __props.rotatable
180
207
  }, null, 8, ["open", "index", "items", "downloadable", "rotatable"])) : createCommentVNode("", true)
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpLightbox-Cixfxezw.js";
1
+ import { _ as _sfc_main } from "../chunks/LpLightbox-oLqyjOln.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -45,12 +45,16 @@ type __VLS_Slots = {
45
45
  declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {
46
46
  /** Open the lightbox at an index — for a "view all" button outside the grid. */
47
47
  openAt: (i: number) => void;
48
+ /** Uncover every spoiler in the set. */
49
+ revealAll: () => void;
48
50
  }, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
49
51
  select: (item: LightboxItem, index: number) => any;
50
52
  "update:open": (value: boolean) => any;
53
+ reveal: (item: LightboxItem) => any;
51
54
  }, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
52
55
  onSelect?: ((item: LightboxItem, index: number) => any) | undefined;
53
56
  "onUpdate:open"?: ((value: boolean) => any) | undefined;
57
+ onReveal?: ((item: LightboxItem) => any) | undefined;
54
58
  }>, {
55
59
  columns: number | {
56
60
  base?: number;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpMediaGallery-t1z3bQcY.js";
1
+ import { _ as _sfc_main } from "../chunks/LpMediaGallery-Cid7PGYx.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -9,6 +9,16 @@ export interface LightboxItem {
9
9
  description?: string;
10
10
  /** Filename used when downloading; derived from `src` when absent. */
11
11
  filename?: string;
12
+ /**
13
+ * Hide this image behind a cover until someone asks for it — a spoiler, a
14
+ * screenshot of something not everyone in the channel has reached yet, a
15
+ * picture that should not appear in a scroll past.
16
+ *
17
+ * A property of the ITEM rather than of the grid showing it: which images are
18
+ * spoilers is a fact about the images, and the usual set is a mixed one, with
19
+ * a marked picture sitting beside plain ones.
20
+ */
21
+ spoiler?: boolean;
12
22
  }
13
23
  /** Last path segment of a URL, or a fallback — used to name a download. */
14
24
  export declare function fileNameOf(item: LightboxItem, fallback?: string): string;
package/dist/index.js CHANGED
@@ -35,11 +35,11 @@ import { _ as _27 } from "./chunks/LpIcon-CCnX5_2j.js";
35
35
  import { default as default5 } from "./components/LpImageEditor.vue.js";
36
36
  import { _ as _28 } from "./chunks/LpInfraNode-Ds_8SZgN.js";
37
37
  import { _ as _29 } from "./chunks/LpInput-9EXWUM9B.js";
38
- import { _ as _30 } from "./chunks/LpLightbox-Cixfxezw.js";
38
+ import { _ as _30 } from "./chunks/LpLightbox-oLqyjOln.js";
39
39
  import { fileNameOf } from "./components/lightbox.js";
40
40
  import { _ as _31 } from "./chunks/LpLink-DCND-zyu.js";
41
41
  import { _ as _32 } from "./chunks/LpLogViewer-y53ZqSVq.js";
42
- import { _ as _33 } from "./chunks/LpMediaGallery-t1z3bQcY.js";
42
+ import { _ as _33 } from "./chunks/LpMediaGallery-Cid7PGYx.js";
43
43
  import { _ as _34 } from "./chunks/LpModal-D_o-56To.js";
44
44
  import { _ as _35 } from "./chunks/LpNotificationBell-lHePf89W.js";
45
45
  import { _ as _36 } from "./chunks/LpNumberField-CWuX08iX.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -315,8 +315,21 @@ const imageStyle = computed(() => ({
315
315
 
316
316
  const BTN =
317
317
  "flex size-9 items-center justify-center rounded-control text-white/80 outline-none transition-colors duration-[var(--duration-fast)] hover:bg-white/15 hover:text-white focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
318
+ /*
319
+ * The arrows sit on a photograph, which is the hard surface to answer on: the
320
+ * resting chrome is a dark translucent disc, and a hover that only deepens that
321
+ * dark is a change from "nearly black" to "slightly more nearly black" over an
322
+ * image that may itself be dark. Measured, the old step moved the fill from
323
+ * 0.45 to 0.65 alpha and the disc by 5% of 44px — two pixels. It was doing
324
+ * something, and it read as doing nothing.
325
+ *
326
+ * So the hover changes the QUALITY of the surface, not just its depth: the fill
327
+ * goes lighter rather than darker, a hairline ring appears, the icon reaches
328
+ * full white and the disc grows by a tenth. Pressing it dips back below resting
329
+ * size, which is the part that makes a button feel connected to the click.
330
+ */
318
331
  const ARROW =
319
- "absolute top-1/2 z-10 flex size-11 -translate-y-1/2 items-center justify-center rounded-pill bg-black/45 text-white/90 outline-none backdrop-blur-sm transition-[background-color,scale] duration-[var(--duration-fast)] hover:bg-black/65 hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-0"
332
+ "absolute top-1/2 z-10 flex size-11 -translate-y-1/2 items-center justify-center rounded-pill bg-black/45 text-white/75 ring-1 ring-white/0 outline-none backdrop-blur-sm transition-[background-color,scale,color,box-shadow] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:bg-white/20 hover:text-white hover:ring-white/30 hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-0 motion-reduce:transition-none motion-reduce:hover:scale-100 motion-reduce:active:scale-100"
320
333
  </script>
321
334
 
322
335
  <template>
@@ -19,6 +19,10 @@
19
19
  * straight from here into the lightbox with nothing in between. `thumb` is the
20
20
  * small source when one exists; without it the grid shows `src`, which is
21
21
  * correct but heavier, and that is the caller's call to make.
22
+ *
23
+ * An item marked `spoiler` arrives covered and takes a click to uncover. The
24
+ * cover is per image and the reveal is remembered per image, because a set is
25
+ * usually mixed — one marked screenshot among plain ones.
22
26
  */
23
27
  import { computed, ref } from "vue"
24
28
  import type { LightboxItem } from "./lightbox"
@@ -69,6 +73,8 @@ const props = withDefaults(
69
73
  const emit = defineEmits<{
70
74
  /** A thumbnail was activated, before the lightbox opens. */
71
75
  (e: "select", item: LightboxItem, index: number): void
76
+ /** A spoiler was uncovered. */
77
+ (e: "reveal", item: LightboxItem): void
72
78
  (e: "update:open", value: boolean): void
73
79
  }>()
74
80
 
@@ -173,9 +179,15 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
173
179
  function activate(i: number) {
174
180
  const item = props.items[i]
175
181
  if (!item) return
182
+ // `select` reports the position in the caller's own array, which is the one
183
+ // they can do anything with — the lightbox's filtered index is internal.
176
184
  emit("select", item, i)
177
185
  if (!props.openable) return
178
- index.value = i
186
+ // Covered images are not in the lightbox's list, so the index has to be
187
+ // translated into it.
188
+ const at = openIndexOf(i)
189
+ if (at === -1) return
190
+ index.value = at
179
191
  open.value = true
180
192
  emit("update:open", true)
181
193
  }
@@ -185,9 +197,57 @@ function openOverflow() {
185
197
  activate(visible.value.length)
186
198
  }
187
199
 
200
+ /*
201
+ * Which spoilers have been opened.
202
+ *
203
+ * Keyed by `src` rather than by index, because the index is not a name: a set
204
+ * that gains an image at the front, or is filtered, shifts every position under
205
+ * the cursor, and a grid keyed by index would then show the wrong picture as
206
+ * already-revealed — which for a spoiler is the one failure that matters.
207
+ */
208
+ const revealed = ref(new Set<string>())
209
+
210
+ function isHidden(item: LightboxItem): boolean {
211
+ return Boolean(item.spoiler) && !revealed.value.has(item.src)
212
+ }
213
+
214
+ /*
215
+ * A covered tile takes its click to uncover itself and STOPS there — it does
216
+ * not also open the lightbox. Revealing and viewing are two separate requests,
217
+ * and running them together puts a picture nobody has agreed to see yet
218
+ * full-screen on the first click. The second click opens it like any other.
219
+ */
220
+ function reveal(item: LightboxItem) {
221
+ // A new Set, not .add(): a Set mutated in place is the same object, so a
222
+ // computed reading it never sees a change.
223
+ revealed.value = new Set(revealed.value).add(item.src)
224
+ emit("reveal", item)
225
+ }
226
+
227
+ /*
228
+ * The lightbox never receives a covered image. Paging through the set with the
229
+ * arrow keys would otherwise walk straight into one full-screen, having asked
230
+ * nobody — the grid's cover would have been for nothing. Items already revealed
231
+ * pass through untouched, so opening the one you just uncovered still lands on
232
+ * it.
233
+ */
234
+ const openItems = computed(() => props.items.filter((item) => !isHidden(item)))
235
+
236
+ /** Index within `openItems` of the grid item at `i`, or -1 when it is covered. */
237
+ function openIndexOf(i: number): number {
238
+ const item = props.items[i]
239
+ return item ? openItems.value.indexOf(item) : -1
240
+ }
241
+
188
242
  defineExpose({
189
243
  /** Open the lightbox at an index — for a "view all" button outside the grid. */
190
244
  openAt: (i: number) => activate(i),
245
+ /** Uncover every spoiler in the set. */
246
+ revealAll: () => {
247
+ const next = new Set(revealed.value)
248
+ for (const item of props.items) if (item.spoiler) next.add(item.src)
249
+ revealed.value = next
250
+ },
191
251
  })
192
252
  </script>
193
253
 
@@ -208,11 +268,19 @@ defineExpose({
208
268
  'focus-visible:ring-2 focus-visible:ring-ring',
209
269
  ]"
210
270
  :aria-label="
211
- hiddenCount && i === visible.length - 1
212
- ? `Show ${hiddenCount} more`
213
- : item.title || `Image ${i + 1} of ${items.length}`
271
+ isHidden(item)
272
+ ? 'Spoiler — show image'
273
+ : hiddenCount && i === visible.length - 1
274
+ ? `Show ${hiddenCount} more`
275
+ : item.title || `Image ${i + 1} of ${items.length}`
276
+ "
277
+ @click="
278
+ isHidden(item)
279
+ ? reveal(item)
280
+ : hiddenCount && i === visible.length - 1
281
+ ? openOverflow()
282
+ : activate(i)
214
283
  "
215
- @click="hiddenCount && i === visible.length - 1 ? openOverflow() : activate(i)"
216
284
  >
217
285
  <slot name="item" :item="item" :index="i">
218
286
  <!-- decoding=async + loading=lazy: a gallery is often far down a page,
@@ -227,15 +295,18 @@ defineExpose({
227
295
  little pushes that feathered edge outside the clip. -->
228
296
  <img
229
297
  :src="item.thumb || item.src"
230
- :alt="item.title || ''"
298
+ :alt="isHidden(item) ? '' : item.title || ''"
231
299
  loading="lazy"
232
300
  decoding="async"
233
301
  class="size-full object-cover transition-transform duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
234
- :class="
235
- hiddenCount && i === visible.length - 1
302
+ :class="[
303
+ isHidden(item)
304
+ ? 'scale-[1.12] blur-[18px] brightness-[0.6] saturate-[1.1] group-hover:scale-[1.12]'
305
+ : '',
306
+ !isHidden(item) && hiddenCount && i === visible.length - 1
236
307
  ? 'scale-[1.06] blur-[2px] brightness-[0.45] group-hover:scale-[1.06]'
237
- : ''
238
- "
308
+ : '',
309
+ ]"
239
310
  >
240
311
  </slot>
241
312
 
@@ -252,8 +323,22 @@ defineExpose({
252
323
  and obvious zoomed. Dimming the image itself means the corner clips a
253
324
  finished pixel, which fades toward transparent instead of toward
254
325
  something that should never be on show. -->
326
+ <!-- A covered tile says so in words. The blur alone is ambiguous — a
327
+ heavily blurred photograph reads as one that failed to load, and the
328
+ label is the difference between "broken" and "click to see". -->
329
+ <span
330
+ v-if="isHidden(item)"
331
+ class="pointer-events-none absolute inset-0 flex items-center justify-center"
332
+ >
333
+ <span
334
+ class="rounded-pill bg-black/55 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white"
335
+ >
336
+ Spoiler
337
+ </span>
338
+ </span>
339
+
255
340
  <span
256
- v-if="hiddenCount && i === visible.length - 1"
341
+ v-else-if="hiddenCount && i === visible.length - 1"
257
342
  class="pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-medium text-white"
258
343
  >
259
344
  +{{ hiddenCount }}
@@ -272,7 +357,7 @@ defineExpose({
272
357
  v-if="openable"
273
358
  v-model:open="open"
274
359
  v-model:index="index"
275
- :items="items"
360
+ :items="openItems"
276
361
  :downloadable="downloadable"
277
362
  :rotatable="rotatable"
278
363
  />
@@ -14,6 +14,16 @@ export interface LightboxItem {
14
14
  description?: string
15
15
  /** Filename used when downloading; derived from `src` when absent. */
16
16
  filename?: string
17
+ /**
18
+ * Hide this image behind a cover until someone asks for it — a spoiler, a
19
+ * screenshot of something not everyone in the channel has reached yet, a
20
+ * picture that should not appear in a scroll past.
21
+ *
22
+ * A property of the ITEM rather than of the grid showing it: which images are
23
+ * spoilers is a fact about the images, and the usual set is a mixed one, with
24
+ * a marked picture sitting beside plain ones.
25
+ */
26
+ spoiler?: boolean
17
27
  }
18
28
 
19
29
  /** Last path segment of a URL, or a fallback — used to name a download. */