@leavepulse/ui 0.32.0 → 0.33.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.
@@ -6,7 +6,7 @@ import { _ as _sfc_main$3 } from "./LpButton-5VXXCjwF.js";
6
6
  import { _ as _sfc_main$1 } from "./LpFormField-CazBc2-a.js";
7
7
  import { _ as _sfc_main$4 } from "./LpIcon-CCnX5_2j.js";
8
8
  import { _ as _sfc_main$5 } from "./LpInput-9EXWUM9B.js";
9
- import { _ as _sfc_main$7 } from "./LpModal-D_o-56To.js";
9
+ import { _ as _sfc_main$7 } from "./LpModal-DAqXzsm-.js";
10
10
  import { _ as _sfc_main$6 } from "./LpSelect-ANIVtrpM.js";
11
11
  import { useMergedAttrs } from "../composables/useMergedClass.js";
12
12
  const _hoisted_1 = { class: "flex items-stretch gap-2" };
@@ -1,7 +1,7 @@
1
1
  import { defineComponent, computed, openBlock, createBlock, createSlots, withCtx, renderSlot, createVNode, createTextVNode, toDisplayString, createElementVNode, normalizeClass } from "vue";
2
2
  import { _ as _sfc_main$2 } from "./LpButton-5VXXCjwF.js";
3
3
  import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
4
- import { _ as _sfc_main$1 } from "./LpModal-D_o-56To.js";
4
+ import { _ as _sfc_main$1 } from "./LpModal-DAqXzsm-.js";
5
5
  const _hoisted_1 = { class: "inline-flex items-center gap-2.5" };
6
6
  const _sfc_main = /* @__PURE__ */ defineComponent({
7
7
  __name: "LpConfirmDialog",
@@ -36,7 +36,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
36
36
  }
37
37
  }
38
38
  const { class: rootClass, attrs: rest } = useMergedAttrs(
39
- "overflow-hidden rounded-control border border-line bg-surface-soft"
39
+ "shrink-0 overflow-hidden rounded-control border border-line bg-surface-soft"
40
40
  );
41
41
  return (_ctx, _cache) => {
42
42
  return openBlock(), createBlock(unref(CollapsibleRoot), mergeProps({
@@ -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,16 +1,16 @@
1
- import { defineComponent, computed, useSlots, ref, watch, openBlock, createBlock, unref, withCtx, createVNode, createElementVNode, mergeProps, createElementBlock, renderSlot, createTextVNode, toDisplayString, createCommentVNode, normalizeClass } from "vue";
1
+ import { defineComponent, computed, useSlots, watch, onBeforeUnmount, ref, openBlock, createBlock, unref, withCtx, createVNode, normalizeStyle, createElementVNode, mergeProps, createElementBlock, renderSlot, createTextVNode, toDisplayString, createCommentVNode, normalizeClass } from "vue";
2
2
  import { DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose } from "reka-ui";
3
3
  import { useShift } from "../composables/useShift.js";
4
+ import { useModalLayer } from "../components/modalDepth.js";
4
5
  import { CLOSE_ICON } from "../components/dropdown.js";
5
6
  import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
6
7
  import { _ as _sfc_main$2 } from "./LpScrollArea-D8xyKXcF.js";
7
- const _hoisted_1 = { class: "fixed inset-0 z-(--z-modal) flex items-center justify-center pointer-events-none" };
8
- const _hoisted_2 = {
8
+ const _hoisted_1 = {
9
9
  key: 0,
10
10
  class: "flex shrink-0 items-start justify-between gap-4 p-5 pb-3"
11
11
  };
12
- const _hoisted_3 = { class: "flex flex-col gap-1" };
13
- const _hoisted_4 = {
12
+ const _hoisted_2 = { class: "flex flex-col gap-1" };
13
+ const _hoisted_3 = {
14
14
  key: 3,
15
15
  class: "flex shrink-0 justify-end gap-2 p-5 pt-4"
16
16
  };
@@ -27,6 +27,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
27
27
  },
28
28
  emits: ["update:open"],
29
29
  setup(__props) {
30
+ const { claim, release, layer } = useModalLayer();
30
31
  const props = __props;
31
32
  const describedByAttrs = computed(
32
33
  () => props.description ? {} : { "aria-describedby": void 0 }
@@ -44,6 +45,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
44
45
  }[props.size];
45
46
  });
46
47
  const slots = useSlots();
48
+ watch(() => props.open, (open) => open ? claim() : release(), { immediate: true });
49
+ onBeforeUnmount(release);
47
50
  const bodyPad = computed(
48
51
  () => [
49
52
  "px-5",
@@ -103,8 +106,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
103
106
  default: withCtx(() => [
104
107
  createVNode(unref(DialogPortal), null, {
105
108
  default: withCtx(() => [
106
- createVNode(unref(DialogOverlay), { class: "lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]" }),
107
- createElementVNode("div", _hoisted_1, [
109
+ createVNode(unref(DialogOverlay), {
110
+ class: "lp-scrim fixed inset-0 data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]",
111
+ style: normalizeStyle({ zIndex: unref(layer).scrim })
112
+ }, null, 8, ["style"]),
113
+ createElementVNode("div", {
114
+ class: "fixed inset-0 flex items-center justify-center pointer-events-none",
115
+ style: normalizeStyle({ zIndex: unref(layer).panel })
116
+ }, [
108
117
  createVNode(unref(DialogContent), mergeProps({
109
118
  ref_key: "panelRef",
110
119
  ref: panelRef,
@@ -112,8 +121,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
112
121
  style: __props.width ? { width: __props.width } : void 0
113
122
  }, describedByAttrs.value, { onOpenAutoFocus }), {
114
123
  default: withCtx(() => [
115
- __props.title || _ctx.$slots.title ? (openBlock(), createElementBlock("header", _hoisted_2, [
116
- createElementVNode("div", _hoisted_3, [
124
+ __props.title || _ctx.$slots.title ? (openBlock(), createElementBlock("header", _hoisted_1, [
125
+ createElementVNode("div", _hoisted_2, [
117
126
  createVNode(unref(DialogTitle), { class: "text-base font-semibold text-ink" }, {
118
127
  default: withCtx(() => [
119
128
  renderSlot(_ctx.$slots, "title", {}, () => [
@@ -162,13 +171,13 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
162
171
  ]),
163
172
  _: 3
164
173
  }, 8, ["class", "content-class"])),
165
- _ctx.$slots.footer ? (openBlock(), createElementBlock("footer", _hoisted_4, [
174
+ _ctx.$slots.footer ? (openBlock(), createElementBlock("footer", _hoisted_3, [
166
175
  renderSlot(_ctx.$slots, "footer")
167
176
  ])) : createCommentVNode("", true)
168
177
  ]),
169
178
  _: 3
170
179
  }, 16, ["class", "style"])
171
- ])
180
+ ], 4)
172
181
  ]),
173
182
  _: 3
174
183
  })
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAddressInput-C35TsJXt.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAddressInput-FZXfljWV.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpConfirmDialog-CjOGcj1e.js";
1
+ import { _ as _sfc_main } from "../chunks/LpConfirmDialog-CQBVaLpo.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpDisclosure-y7wDBgLa.js";
1
+ import { _ as _sfc_main } from "../chunks/LpDisclosure-ChQYmjlR.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -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
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpModal-D_o-56To.js";
1
+ import { _ as _sfc_main } from "../chunks/LpModal-DAqXzsm-.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, openBlock, createBlock, withCtx, createElementVNode, createElementBlock, Fragment, renderList, toDisplayString, unref } from "vue";
2
- import { _ as _sfc_main$1 } from "../chunks/LpModal-D_o-56To.js";
2
+ import { _ as _sfc_main$1 } from "../chunks/LpModal-DAqXzsm-.js";
3
3
  import { appleModifierLabel } from "../composables/useHotkeys.js";
4
4
  import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
5
5
  import '../assets/LpShortcutsDialog-B7H-RZLQ.css';const _hoisted_1 = { class: "shortcut-groups" };
@@ -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;
@@ -0,0 +1,13 @@
1
+ import { ComputedRef } from 'vue';
2
+ /**
3
+ * Register a dialog as open and get back the z-indexes it should paint with.
4
+ * The result is reactive: opening another dialog moves this one down a step.
5
+ */
6
+ export declare function useModalLayer(): {
7
+ claim: () => void;
8
+ release: () => void;
9
+ layer: ComputedRef<{
10
+ scrim: string;
11
+ panel: string;
12
+ }>;
13
+ };
@@ -0,0 +1,29 @@
1
+ import { ref, computed } from "vue";
2
+ const STEP = 10;
3
+ const stack = ref([]);
4
+ let nextId = 0;
5
+ function useModalLayer() {
6
+ const id = nextId++;
7
+ const fromTop = computed(() => {
8
+ const at = stack.value.indexOf(id);
9
+ return at === -1 ? 0 : stack.value.length - 1 - at;
10
+ });
11
+ return {
12
+ claim: () => {
13
+ if (!stack.value.includes(id)) stack.value = [...stack.value, id];
14
+ },
15
+ release: () => {
16
+ stack.value = stack.value.filter((x) => x !== id);
17
+ },
18
+ layer: computed(() => {
19
+ const drop = fromTop.value * STEP;
20
+ return {
21
+ scrim: `calc(var(--z-overlay) - ${drop})`,
22
+ panel: `calc(var(--z-modal) - ${drop})`
23
+ };
24
+ })
25
+ };
26
+ }
27
+ export {
28
+ useModalLayer
29
+ };
@@ -1,6 +1,7 @@
1
1
  import { ref, watch, toValue, onBeforeUnmount } from "vue";
2
2
  import { prefersReducedMotion } from "./easing.js";
3
3
  const TWEEN_MS = 160;
4
+ const RETARGET_AFTER_MS = 40;
4
5
  function useShift(options = {}) {
5
6
  const el = ref(null);
6
7
  const tweening = ref(false);
@@ -12,13 +13,19 @@ function useShift(options = {}) {
12
13
  let lastHeight = 0;
13
14
  let lastWidth = 0;
14
15
  let inFlight = false;
16
+ let tweenStart = 0;
15
17
  const axisOf = () => toValue(options.axis) ?? "height";
16
18
  const wantsHeight = () => axisOf() !== "width";
17
19
  const wantsWidth = () => axisOf() !== "height";
18
20
  function retune() {
19
21
  const node = el.value;
20
22
  if (!node) return;
21
- if (inFlight) return;
23
+ if (inFlight) {
24
+ if (performance.now() - tweenStart < RETARGET_AFTER_MS) return;
25
+ const live = node.getBoundingClientRect();
26
+ lastHeight = Math.round(live.height);
27
+ lastWidth = Math.round(live.width);
28
+ }
22
29
  const fromHeight = lastHeight;
23
30
  const fromWidth = lastWidth;
24
31
  if (wantsHeight()) {
@@ -44,6 +51,7 @@ function useShift(options = {}) {
44
51
  if (movesHeight) node.style.height = `${toHeight}px`;
45
52
  if (movesWidth) node.style.width = `${toWidth}px`;
46
53
  inFlight = true;
54
+ tweenStart = performance.now();
47
55
  resizing.value = true;
48
56
  clearTimeout(settleTimer);
49
57
  const wait = prefersReducedMotion() ? 0 : toValue(options.duration) ?? TWEEN_MS;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-C35TsJXt.js";
2
+ import { _ as _2 } from "./chunks/LpAddressInput-FZXfljWV.js";
3
3
  import { _ as _3 } from "./chunks/LpMapPicker-ul4oYno6.js";
4
4
  import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
5
5
  import { _ as _4 } from "./chunks/LpActionBar-BlvtGB2P.js";
@@ -19,10 +19,10 @@ import { _ as _15 } from "./chunks/LpCodeBlock-Bo0B4TFR.js";
19
19
  import { _ as _16 } from "./chunks/LpColorPicker-CFC6iaNK.js";
20
20
  import { DEFAULT_SWATCHES, clampHsv, formatColor, hsvToHsl, hsvToRgb, isLight, normalizeHue, opaqueCss, parseColor, parseHex, rgbToHex, rgbToHsv } from "./components/color.js";
21
21
  import { default as default3 } from "./components/LpCommandPalette.vue.js";
22
- import { _ as _17 } from "./chunks/LpConfirmDialog-CjOGcj1e.js";
22
+ import { _ as _17 } from "./chunks/LpConfirmDialog-CQBVaLpo.js";
23
23
  import { _ as _18 } from "./chunks/LpContextMenu-B6WBLleS.js";
24
24
  import { _ as _19 } from "./chunks/LpDatePicker-6JFXllj5.js";
25
- import { _ as _20 } from "./chunks/LpDisclosure-y7wDBgLa.js";
25
+ import { _ as _20 } from "./chunks/LpDisclosure-ChQYmjlR.js";
26
26
  import { _ as _21 } from "./chunks/LpDivider-CUX46KR0.js";
27
27
  import { _ as _22 } from "./chunks/LpDrawer-DAJN5rGK.js";
28
28
  import { _ as _23 } from "./chunks/LpDropdownMenu-DJpoqRCb.js";
@@ -35,12 +35,12 @@ 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";
43
- import { _ as _34 } from "./chunks/LpModal-D_o-56To.js";
42
+ import { _ as _33 } from "./chunks/LpMediaGallery-Cid7PGYx.js";
43
+ import { _ as _34 } from "./chunks/LpModal-DAqXzsm-.js";
44
44
  import { _ as _35 } from "./chunks/LpNotificationBell-lHePf89W.js";
45
45
  import { _ as _36 } from "./chunks/LpNumberField-CWuX08iX.js";
46
46
  import { _ as _37 } from "./chunks/LpNumberFlow-amWYaeuA.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.1",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -75,8 +75,23 @@ function onToggle(value: boolean) {
75
75
  }
76
76
 
77
77
  // The disclosure carries a bordered control frame; stacking several into one list means removing the per-item border from the outside.
78
+ /*
79
+ * shrink-0 is load-bearing inside a flex column.
80
+ *
81
+ * A disclosure is nearly always in one — the body of a dialog, a settings
82
+ * column, a form. A flex item defaults to `flex-shrink: 1`, so when that column
83
+ * runs out of room every child is squeezed, and this one hides the evidence
84
+ * with its own `overflow-hidden`: measured in a dialog whose content overflows,
85
+ * the root sat at 22px while the trigger inside it was 40px — a header cut in
86
+ * half, spilling past the panel, and snapping back and forth by 20px as the
87
+ * surrounding layout settled.
88
+ *
89
+ * A collapsible owns its height: closed it is exactly its header, open it is
90
+ * the header plus the animating body. Nothing outside gets to shorten that —
91
+ * the column it sits in is the thing that should scroll instead.
92
+ */
78
93
  const { class: rootClass, attrs: rest } = useMergedAttrs(
79
- "overflow-hidden rounded-control border border-line bg-surface-soft",
94
+ "shrink-0 overflow-hidden rounded-control border border-line bg-surface-soft",
80
95
  )
81
96
  </script>
82
97
 
@@ -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
  />
@@ -9,12 +9,32 @@ import {
9
9
  DialogTitle,
10
10
  } from "reka-ui"
11
11
  import type { ComponentPublicInstance } from "vue"
12
- import { computed, ref, useSlots, watch } from "vue"
12
+ import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
13
13
  import { useShift } from "../composables/useShift"
14
+ import { useModalLayer } from "./modalDepth"
14
15
  import { CLOSE_ICON } from "./dropdown"
15
16
  import LpIcon from "./LpIcon.vue"
16
17
  import LpScrollArea from "./LpScrollArea.vue"
17
18
 
19
+ /*
20
+ * Stacking depth, so a dialog opened FROM a dialog sits above the one that
21
+ * opened it — scrim included.
22
+ *
23
+ * The fixed rungs cannot express this on their own. Every modal took
24
+ * --z-overlay for its scrim and --z-modal for its panel, so a nested pair
25
+ * landed at exactly the heights of the outer pair: the second scrim, at 100,
26
+ * sat UNDER the first panel at 110. It still darkened the page, which is why
27
+ * this reads as "the blur is missing" rather than as a stacking bug — a
28
+ * backdrop-filter only blurs what is painted beneath it, and the panel it was
29
+ * supposed to blur was above.
30
+ *
31
+ * Each level adds a step, keeping the scrim one below its own panel and both
32
+ * above everything the level beneath put on screen. The same problem, and the
33
+ * same fix, as the lightbox's own rungs in tokens.css — only the depth here is
34
+ * not known until a dialog finds itself inside another.
35
+ */
36
+ const { claim, release, layer } = useModalLayer()
37
+
18
38
  const props = withDefaults(
19
39
  defineProps<{
20
40
  open?: boolean
@@ -75,6 +95,17 @@ const widthClass = computed(() => {
75
95
  const slots = useSlots()
76
96
  // The body clips overflow and focus rings are drawn outside their control, so a
77
97
  // field flush against the top edge lost its ring; pt-1 clears the ring's width.
98
+ /*
99
+ * Claim a level as this dialog opens, give it back as it closes. `immediate`
100
+ * matters: a dialog that starts out open has to have its rung before the first
101
+ * paint, or its scrim lands on the default one for a frame.
102
+ */
103
+ watch(() => props.open, (open) => (open ? claim() : release()), { immediate: true })
104
+
105
+ // A dialog torn down while still open would otherwise stay on the stack, holding
106
+ // every dialog under it a step lower than it should be.
107
+ onBeforeUnmount(release)
108
+
78
109
  const bodyPad = computed(() =>
79
110
  [
80
111
  "px-5",
@@ -178,7 +209,8 @@ watch(
178
209
  <DialogRoot :open="open" @update:open="(v) => $emit('update:open', v)">
179
210
  <DialogPortal>
180
211
  <DialogOverlay
181
- class="lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
212
+ class="lp-scrim fixed inset-0 data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
213
+ :style="{ zIndex: layer.scrim }"
182
214
  />
183
215
  <!-- Centred by a full-screen flex wrapper rather than by translating the
184
216
  panel off its own centre. With `top:50% / -translate-y-1/2` the panel
@@ -193,7 +225,10 @@ watch(
193
225
  this wrapper: `max-h-full` on a centred flex child resolves against a
194
226
  box it is free to overflow, so a tall body stopped handing its
195
227
  overflow to the scroll area below and simply ran off-screen. -->
196
- <div class="fixed inset-0 z-(--z-modal) flex items-center justify-center pointer-events-none">
228
+ <div
229
+ class="fixed inset-0 flex items-center justify-center pointer-events-none"
230
+ :style="{ zIndex: layer.panel }"
231
+ >
197
232
  <!-- Without a description reka-ui warns on every open, and the opt-out it
198
233
  checks for is an ABSENT `aria-describedby` — despite the message
199
234
  naming the string "undefined" (a leftover from Radix, where
@@ -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. */
@@ -0,0 +1,72 @@
1
+ /*
2
+ * Stacking order for dialogs that are open at the same time.
3
+ *
4
+ * Not provide/inject, which was the first shape tried: it only works when the
5
+ * second dialog is rendered INSIDE the first one's subtree, and the ordinary
6
+ * way to write a dialog opened from a dialog is to put both at the top level of
7
+ * the same component and toggle them with separate flags. Those two are
8
+ * siblings, so the inner one injects nothing and lands on the same rungs as the
9
+ * outer — which is the bug.
10
+ *
11
+ * What decides the order is the sequence of OPENING: a dialog opened while
12
+ * another is up belongs above it, wherever it sits in the markup.
13
+ *
14
+ * The newest dialog keeps the scale's own --z-overlay/--z-modal and the ones
15
+ * beneath are pushed DOWN, rather than each new dialog climbing higher. Climbing
16
+ * runs out of room immediately: --z-popover is only 10 above --z-modal, so the
17
+ * second nested dialog would tie with every popover and the third with every
18
+ * tooltip — and a select inside a dialog is ordinary. Descending has no such
19
+ * ceiling, and it keeps the frontmost dialog exactly where a single one has
20
+ * always been.
21
+ */
22
+ import { computed, ref, type ComputedRef } from "vue"
23
+
24
+ /*
25
+ * Rungs per level. Has to clear a whole level, not just its scrim: --z-overlay
26
+ * is 100 and --z-modal 110, so a gap of 2 left the upper scrim under the lower
27
+ * PANEL — it darkened that panel and could not blur it, a backdrop-filter
28
+ * having nothing of it underneath to work on.
29
+ */
30
+ const STEP = 10
31
+
32
+ /** Levels currently claimed, oldest first. */
33
+ const stack = ref<number[]>([])
34
+ let nextId = 0
35
+
36
+ /**
37
+ * Register a dialog as open and get back the z-indexes it should paint with.
38
+ * The result is reactive: opening another dialog moves this one down a step.
39
+ */
40
+ export function useModalLayer(): {
41
+ claim: () => void
42
+ release: () => void
43
+ layer: ComputedRef<{ scrim: string; panel: string }>
44
+ } {
45
+ const id = nextId++
46
+
47
+ /*
48
+ * How far from the TOP of the stack, so the newest dialog is 0 and keeps the
49
+ * unmodified rungs. A dialog that is not in the stack reads 0 too: it is
50
+ * either closed, or opening this very frame and about to claim.
51
+ */
52
+ const fromTop = computed(() => {
53
+ const at = stack.value.indexOf(id)
54
+ return at === -1 ? 0 : stack.value.length - 1 - at
55
+ })
56
+
57
+ return {
58
+ claim: () => {
59
+ if (!stack.value.includes(id)) stack.value = [...stack.value, id]
60
+ },
61
+ release: () => {
62
+ stack.value = stack.value.filter((x) => x !== id)
63
+ },
64
+ layer: computed(() => {
65
+ const drop = fromTop.value * STEP
66
+ return {
67
+ scrim: `calc(var(--z-overlay) - ${drop})`,
68
+ panel: `calc(var(--z-modal) - ${drop})`,
69
+ }
70
+ }),
71
+ }
72
+ }
@@ -65,6 +65,14 @@ export interface UseShift {
65
65
  /** Kept in step with the `duration-fast` token the transition utilities use. */
66
66
  const TWEEN_MS = 160
67
67
 
68
+ /*
69
+ * How long after a tween starts a second observer call still counts as the same
70
+ * move rather than a new one. Both observers report one swap within a frame or
71
+ * two; anything later is content that is still changing size and has to be
72
+ * followed. Two frames at 60Hz, with room to spare for a slow one.
73
+ */
74
+ const RETARGET_AFTER_MS = 40
75
+
68
76
  export function useShift(
69
77
  options: UseShiftOptions = {},
70
78
  ): UseShift {
@@ -81,6 +89,8 @@ export function useShift(
81
89
  let lastWidth = 0
82
90
  /** True while a tween owns the size, so observers don't cancel it midway. */
83
91
  let inFlight = false
92
+ /** When the in-flight tween started, to tell a duplicate from a follow-up. */
93
+ let tweenStart = 0
84
94
 
85
95
  const axisOf = () => toValue(options.axis) ?? "height"
86
96
  const wantsHeight = () => axisOf() !== "width"
@@ -96,9 +106,29 @@ export function useShift(
96
106
  function retune() {
97
107
  const node = el.value
98
108
  if (!node) return
99
- // One swap wakes both observers — the mutation as content lands, the resize
100
- // as it reflows — and the second call would clear the pin the first wrote.
101
- if (inFlight) return
109
+
110
+ /*
111
+ * A tween already owns the size. Where it came from decides what to do:
112
+ *
113
+ * - One swap wakes both observers — the mutation as content lands, the
114
+ * resize as it reflows — and the second call arrives within a frame or
115
+ * two of the first, describing the same move. Re-running it would clear
116
+ * the pin the first one wrote, so it is dropped.
117
+ * - Content that animates its OWN height keeps reporting new sizes for as
118
+ * long as it runs. A collapsible body opening inside a dialog fires the
119
+ * observer every frame for 260ms, and dropping all of those pinned the
120
+ * panel at the size the body happened to have one frame in — measured,
121
+ * 212px held for 170ms and then 135px arriving in a single frame, which
122
+ * is the "modal snaps open" report. Those retunes must RETARGET the
123
+ * tween instead: start from where the box is painted right now and ease
124
+ * on to the new figure.
125
+ */
126
+ if (inFlight) {
127
+ if (performance.now() - tweenStart < RETARGET_AFTER_MS) return
128
+ const live = node.getBoundingClientRect()
129
+ lastHeight = Math.round(live.height)
130
+ lastWidth = Math.round(live.width)
131
+ }
102
132
 
103
133
  // Remembered, not measured: by the time either observer fires the box has
104
134
  // already reflowed, so the element can only report where it is going.
@@ -145,6 +175,7 @@ export function useShift(
145
175
  if (movesWidth) node.style.width = `${toWidth}px`
146
176
 
147
177
  inFlight = true
178
+ tweenStart = performance.now()
148
179
  resizing.value = true
149
180
  clearTimeout(settleTimer)
150
181
  const wait = prefersReducedMotion() ? 0 : (toValue(options.duration) ?? TWEEN_MS)