@leavepulse/ui 0.18.3 → 0.19.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.
Files changed (31) hide show
  1. package/dist/assets/{LpCommandPalette-D315B47-.css → LpCommandPalette-Cp6Y-j7g.css} +4 -4
  2. package/dist/chunks/{LpAddressInput-Dfyn88Dd.js → LpAddressInput-N8O-LtCS.js} +1 -1
  3. package/dist/chunks/{LpAppShell-BvAVgVLW.js → LpAppShell-D42la4ZD.js} +1 -1
  4. package/dist/chunks/{LpConfirmDialog-BxBU4Pj9.js → LpConfirmDialog-B-amVJsM.js} +1 -1
  5. package/dist/chunks/{LpDrawer-DaCsNuW3.js → LpDrawer-CYm-nUIZ.js} +1 -1
  6. package/dist/chunks/{LpLightbox-CWdIsthW.js → LpLightbox-CzhFe9wZ.js} +2 -2
  7. package/dist/chunks/{LpModal-CYKAaSHO.js → LpModal-vlrgcEY_.js} +75 -5
  8. package/dist/chunks/{LpSidebar-BBG9NhAn.js → LpSidebar-DlLZJk1u.js} +1 -1
  9. package/dist/chunks/{LpThemeSwitcher-Ct-lnXMa.js → LpThemeSwitcher-B_kj0T2b.js} +21 -4
  10. package/dist/components/LpAddressInput.vue.js +1 -1
  11. package/dist/components/LpAppShell.vue.js +1 -1
  12. package/dist/components/LpCommandPalette.vue.js +3 -3
  13. package/dist/components/LpConfirmDialog.vue.js +1 -1
  14. package/dist/components/LpDrawer.vue.js +1 -1
  15. package/dist/components/LpLightbox.vue.js +1 -1
  16. package/dist/components/LpModal.vue.d.ts +5 -5
  17. package/dist/components/LpModal.vue.js +1 -1
  18. package/dist/components/LpSidebar.vue.js +1 -1
  19. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +16 -13
  22. package/dist/theme/backdrop.d.ts +9 -0
  23. package/package.json +1 -1
  24. package/src/components/LpCommandPalette.vue +1 -1
  25. package/src/components/LpDrawer.vue +1 -1
  26. package/src/components/LpLightbox.vue +4 -2
  27. package/src/components/LpModal.vue +123 -4
  28. package/src/index.ts +1 -0
  29. package/src/theme/backdrop.ts +51 -0
  30. package/src/theme/useTheme.ts +4 -0
  31. package/src/tokens/tokens.css +48 -0
@@ -1,16 +1,16 @@
1
1
 
2
2
  /* Each result eases in; honours reduced motion. */
3
- .lp-cmd-item[data-v-d845063e] {
4
- animation: lp-cmd-in-d845063e 140ms ease-out both;
3
+ .lp-cmd-item[data-v-7fba2dd8] {
4
+ animation: lp-cmd-in-7fba2dd8 140ms ease-out both;
5
5
  }
6
- @keyframes lp-cmd-in-d845063e {
6
+ @keyframes lp-cmd-in-7fba2dd8 {
7
7
  from {
8
8
  opacity: 0;
9
9
  transform: translateY(3px);
10
10
  }
11
11
  }
12
12
  @media (prefers-reduced-motion: reduce) {
13
- .lp-cmd-item[data-v-d845063e] {
13
+ .lp-cmd-item[data-v-7fba2dd8] {
14
14
  animation: none;
15
15
  }
16
16
  }
@@ -6,7 +6,7 @@ import { _ as _sfc_main$3 } from "./LpButton-5VXXCjwF.js";
6
6
  import { _ as _sfc_main$1 } from "./LpFormField-Bn-ZwC6z.js";
7
7
  import { _ as _sfc_main$4 } from "./LpIcon-CCnX5_2j.js";
8
8
  import { _ as _sfc_main$5 } from "./LpInput-BauZRyzm.js";
9
- import { _ as _sfc_main$7 } from "./LpModal-CYKAaSHO.js";
9
+ import { _ as _sfc_main$7 } from "./LpModal-vlrgcEY_.js";
10
10
  import { _ as _sfc_main$6 } from "./LpSelect-D5mU4cZO.js";
11
11
  const _hoisted_1 = { class: "flex flex-col gap-3" };
12
12
  const _hoisted_2 = { class: "flex items-stretch gap-2" };
@@ -2,7 +2,7 @@ import { defineComponent, computed, watch, openBlock, createElementBlock, create
2
2
  import { _ as _sfc_main$2 } from "./LpButton-5VXXCjwF.js";
3
3
  import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
4
4
  import { _ as _sfc_main$4 } from "./LpScrollArea-DQ0Yudfu.js";
5
- import { _ as _sfc_main$1 } from "./LpSidebar-BBG9NhAn.js";
5
+ import { _ as _sfc_main$1 } from "./LpSidebar-DlLZJk1u.js";
6
6
  const _hoisted_1 = { class: "flex h-dvh overflow-hidden bg-surface text-ink" };
7
7
  const _hoisted_2 = { class: "flex min-w-0 flex-1 flex-col" };
8
8
  const _hoisted_3 = { class: "flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-4 md:px-6" };
@@ -1,6 +1,6 @@
1
1
  import { defineComponent, openBlock, createBlock, withCtx, renderSlot, createVNode, createTextVNode, toDisplayString } from "vue";
2
2
  import { _ as _sfc_main$2 } from "./LpButton-5VXXCjwF.js";
3
- import { _ as _sfc_main$1 } from "./LpModal-CYKAaSHO.js";
3
+ import { _ as _sfc_main$1 } from "./LpModal-vlrgcEY_.js";
4
4
  const _sfc_main = /* @__PURE__ */ defineComponent({
5
5
  __name: "LpConfirmDialog",
6
6
  props: {
@@ -215,7 +215,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
215
215
  default: withCtx(() => [
216
216
  createVNode(unref(DrawerPortal), null, {
217
217
  default: withCtx(() => [
218
- createVNode(unref(DrawerOverlay), { class: "fixed inset-0 z-(--z-overlay) bg-black/50 backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]" }),
218
+ createVNode(unref(DrawerOverlay), { class: "lp-scrim fixed inset-0 z-(--z-overlay) backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]" }),
219
219
  createVNode(unref(DrawerContent), {
220
220
  class: normalizeClass(contentClass.value),
221
221
  style: normalizeStyle(sizeStyle.value),
@@ -210,9 +210,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
210
210
  default: withCtx(() => [
211
211
  createVNode(unref(DialogPortal), null, {
212
212
  default: withCtx(() => [
213
- createVNode(unref(DialogOverlay), { class: "fixed inset-0 z-(--z-overlay) bg-black/85 backdrop-blur-sm data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]" }),
213
+ createVNode(unref(DialogOverlay), { class: "lp-scrim fixed inset-0 z-(--z-lightbox-overlay) backdrop-blur-sm data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]" }),
214
214
  createVNode(unref(DialogContent), {
215
- class: "fixed inset-0 z-(--z-modal) flex flex-col outline-none data-[state=open]:animate-[lightbox-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[lightbox-out_140ms_cubic-bezier(0.4,0,1,1)] motion-reduce:animate-none",
215
+ class: "fixed inset-0 z-(--z-lightbox) flex flex-col outline-none data-[state=open]:animate-[lightbox-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[lightbox-out_140ms_cubic-bezier(0.4,0,1,1)] motion-reduce:animate-none",
216
216
  onKeydown
217
217
  }, {
218
218
  default: withCtx(() => [
@@ -1,4 +1,4 @@
1
- import { defineComponent, computed, useSlots, openBlock, createBlock, unref, withCtx, createVNode, createElementVNode, mergeProps, createElementBlock, renderSlot, createTextVNode, toDisplayString, createCommentVNode, normalizeClass } from "vue";
1
+ import { defineComponent, computed, useSlots, ref, watch, onBeforeUnmount, openBlock, createBlock, unref, withCtx, createVNode, 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 { CLOSE_ICON } from "../components/dropdown.js";
4
4
  import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
@@ -13,6 +13,7 @@ const _hoisted_4 = {
13
13
  key: 3,
14
14
  class: "flex shrink-0 justify-end gap-2 p-5 pt-4"
15
15
  };
16
+ const TWEEN_MS = 160;
16
17
  const _sfc_main = /* @__PURE__ */ defineComponent({
17
18
  __name: "LpModal",
18
19
  props: {
@@ -49,6 +50,73 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
49
50
  slots.footer ? "" : "pb-5"
50
51
  ].filter(Boolean).join(" ")
51
52
  );
53
+ const panelRef = ref(null);
54
+ const tweening = ref(false);
55
+ const resizing = ref(false);
56
+ let ro;
57
+ let mo;
58
+ let raf = 0;
59
+ let settleTimer;
60
+ const retune = (el) => {
61
+ const from = el.offsetHeight;
62
+ el.style.height = "";
63
+ const to = el.offsetHeight;
64
+ if (to === 0) return;
65
+ if (to === from) {
66
+ el.style.height = `${to}px`;
67
+ return;
68
+ }
69
+ el.style.height = `${from}px`;
70
+ void el.offsetHeight;
71
+ el.style.height = `${to}px`;
72
+ resizing.value = true;
73
+ clearTimeout(settleTimer);
74
+ settleTimer = setTimeout(() => {
75
+ resizing.value = false;
76
+ }, TWEEN_MS);
77
+ };
78
+ watch(
79
+ () => props.open,
80
+ (isOpen) => {
81
+ ro?.disconnect();
82
+ mo?.disconnect();
83
+ ro = void 0;
84
+ mo = void 0;
85
+ cancelAnimationFrame(raf);
86
+ clearTimeout(settleTimer);
87
+ tweening.value = false;
88
+ resizing.value = false;
89
+ const el = panelRef.value?.$el;
90
+ if (!isOpen || !(el instanceof HTMLElement)) return;
91
+ el.style.height = `${el.offsetHeight}px`;
92
+ raf = requestAnimationFrame(() => {
93
+ tweening.value = true;
94
+ });
95
+ const watchChildren = () => {
96
+ if (!ro) return;
97
+ ro.disconnect();
98
+ for (const child of Array.from(el.children)) ro.observe(child);
99
+ };
100
+ if (typeof ResizeObserver !== "undefined") {
101
+ ro = new ResizeObserver(() => retune(el));
102
+ watchChildren();
103
+ }
104
+ if (typeof MutationObserver !== "undefined") {
105
+ mo = new MutationObserver(() => {
106
+ watchChildren();
107
+ retune(el);
108
+ });
109
+ mo.observe(el, { childList: true, subtree: true, characterData: true });
110
+ }
111
+ },
112
+ { flush: "post" }
113
+ );
114
+ onBeforeUnmount(() => {
115
+ ro?.disconnect();
116
+ mo?.disconnect();
117
+ cancelAnimationFrame(raf);
118
+ clearTimeout(settleTimer);
119
+ });
52
120
  return (_ctx, _cache) => {
53
121
  return openBlock(), createBlock(unref(DialogRoot), {
54
122
  open: __props.open,
@@ -57,10 +125,12 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
57
125
  default: withCtx(() => [
58
126
  createVNode(unref(DialogPortal), null, {
59
127
  default: withCtx(() => [
60
- createVNode(unref(DialogOverlay), { class: "fixed inset-0 z-(--z-overlay) bg-black/50 backdrop-blur-sm data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]" }),
128
+ createVNode(unref(DialogOverlay), { class: "lp-scrim fixed inset-0 z-(--z-overlay) backdrop-blur-sm data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]" }),
61
129
  createElementVNode("div", _hoisted_1, [
62
130
  createVNode(unref(DialogContent), mergeProps({
63
- class: ["pointer-events-auto flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]", widthClass.value],
131
+ ref_key: "panelRef",
132
+ ref: panelRef,
133
+ class: ["pointer-events-auto flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]", [widthClass.value, tweening.value ? "transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none" : ""]],
64
134
  style: __props.width ? { width: __props.width } : void 0
65
135
  }, describedByAttrs.value), {
66
136
  default: withCtx(() => [
@@ -105,14 +175,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
105
175
  renderSlot(_ctx.$slots, "default")
106
176
  ], 2)) : (openBlock(), createBlock(_sfc_main$2, {
107
177
  key: 2,
108
- class: "min-h-0 flex-1 text-sm text-ink/90",
178
+ class: normalizeClass(["min-h-0 flex-1 text-sm text-ink/90", resizing.value ? "[&_[data-scrollbarimpl]]:invisible" : ""]),
109
179
  "content-class": bodyPad.value
110
180
  }, {
111
181
  default: withCtx(() => [
112
182
  renderSlot(_ctx.$slots, "default")
113
183
  ]),
114
184
  _: 3
115
- }, 8, ["content-class"])),
185
+ }, 8, ["class", "content-class"])),
116
186
  _ctx.$slots.footer ? (openBlock(), createElementBlock("footer", _hoisted_4, [
117
187
  renderSlot(_ctx.$slots, "footer")
118
188
  ])) : createCommentVNode("", true)
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, computed, useId, openBlock, createElementBlock, Fragment, createElementVNode, mergeProps, normalizeClass, renderSlot, createCommentVNode, createVNode, createSlots, withCtx, normalizeProps, guardReactiveProps, createBlock } from "vue";
2
- import { _ as _sfc_main$2 } from "./LpDrawer-DaCsNuW3.js";
2
+ import { _ as _sfc_main$2 } from "./LpDrawer-CYm-nUIZ.js";
3
3
  import { _ as _sfc_main$1 } from "./LpSidebarNav-DSDa5mAY.js";
4
4
  const _hoisted_1 = {
5
5
  key: 1,
@@ -249,6 +249,19 @@ const nord = {
249
249
  font: { ...interFont }
250
250
  };
251
251
  const presets = { dark, light, lime, leavepulse, rose, amber, violet, nord };
252
+ const NO_BACKDROP_ATTR = "data-lp-no-backdrop";
253
+ function isBrokenBackdropEngine(nav = navigator) {
254
+ const ua = nav.userAgent;
255
+ if (!/AppleWebKit/.test(ua)) return false;
256
+ if (/Chrome|Chromium|Edg\//.test(ua)) return false;
257
+ return !/Mac|iPhone|iPad|iPod/.test(nav.platform || "");
258
+ }
259
+ function detectBackdropFilter(root = document.documentElement) {
260
+ if (typeof navigator === "undefined") return true;
261
+ const usable = !isBrokenBackdropEngine();
262
+ root.toggleAttribute(NO_BACKDROP_ATTR, !usable);
263
+ return usable;
264
+ }
252
265
  const COLOR_VARS = {
253
266
  surface: "--color-surface",
254
267
  surfaceRaised: "--color-surface-raised",
@@ -341,6 +354,7 @@ function applyTheme(theme, root = document.documentElement) {
341
354
  }
342
355
  root.dataset.themeMode = theme.mode;
343
356
  root.style.colorScheme = theme.mode;
357
+ detectBackdropFilter(root);
344
358
  }
345
359
  function prefersReducedMotion() {
346
360
  return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
@@ -600,6 +614,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
600
614
  });
601
615
  export {
602
616
  DEFAULT_SURFACE as D,
617
+ NO_BACKDROP_ATTR as N,
603
618
  THEME_COOKIE as T,
604
619
  _sfc_main as _,
605
620
  parseTheme as a,
@@ -608,11 +623,13 @@ export {
608
623
  applyThemeWithTransition as d,
609
624
  bootstrapTheme as e,
610
625
  dark as f,
611
- light as g,
612
- lime as h,
613
- themeToCssRule as i,
614
- themeToCssVars as j,
626
+ detectBackdropFilter as g,
627
+ light as h,
628
+ isBrokenBackdropEngine as i,
629
+ lime as j,
630
+ themeToCssRule as k,
615
631
  leavepulse as l,
632
+ themeToCssVars as m,
616
633
  nord as n,
617
634
  presets as p,
618
635
  rose as r,
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAddressInput-Dfyn88Dd.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAddressInput-N8O-LtCS.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAppShell-BvAVgVLW.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAppShell-D42la4ZD.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -2,7 +2,7 @@ import { defineComponent, ref, computed, watch, nextTick, onMounted, onBeforeUnm
2
2
  import { DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription } from "reka-ui";
3
3
  import { _ as _sfc_main$1 } from "../chunks/LpIcon-CCnX5_2j.js";
4
4
  import { _ as _sfc_main$2 } from "../chunks/LpScrollArea-DQ0Yudfu.js";
5
- import '../assets/LpCommandPalette-D315B47-.css';const _hoisted_1 = { class: "flex shrink-0 items-center gap-2.5 border-b border-line px-4" };
5
+ import '../assets/LpCommandPalette-Cp6Y-j7g.css';const _hoisted_1 = { class: "flex shrink-0 items-center gap-2.5 border-b border-line px-4" };
6
6
  const _hoisted_2 = ["placeholder", "onKeydown"];
7
7
  const _hoisted_3 = {
8
8
  key: 0,
@@ -167,7 +167,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
167
167
  default: withCtx(() => [
168
168
  createVNode(unref(DialogPortal), null, {
169
169
  default: withCtx(() => [
170
- createVNode(unref(DialogOverlay), { class: "fixed inset-0 z-(--z-overlay) bg-black/50 backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]" }),
170
+ createVNode(unref(DialogOverlay), { class: "lp-scrim fixed inset-0 z-(--z-overlay) backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]" }),
171
171
  createVNode(unref(DialogContent), {
172
172
  class: "fixed left-1/2 top-[12vh] z-(--z-modal) flex max-h-[76vh] w-[min(92vw,40rem)] -translate-x-1/2 flex-col overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[pop-in_160ms_var(--ease-emphasized)] data-[state=closed]:animate-[pop-out_130ms_ease]",
173
173
  onOpenAutoFocus: _cache[1] || (_cache[1] = withModifiers(() => {
@@ -304,7 +304,7 @@ const _export_sfc = (sfc, props) => {
304
304
  }
305
305
  return target;
306
306
  };
307
- const LpCommandPalette = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-d845063e"]]);
307
+ const LpCommandPalette = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-7fba2dd8"]]);
308
308
  export {
309
309
  LpCommandPalette as default
310
310
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpConfirmDialog-BxBU4Pj9.js";
1
+ import { _ as _sfc_main } from "../chunks/LpConfirmDialog-B-amVJsM.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpDrawer-DaCsNuW3.js";
1
+ import { _ as _sfc_main } from "../chunks/LpDrawer-CYm-nUIZ.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpLightbox-CWdIsthW.js";
1
+ import { _ as _sfc_main } from "../chunks/LpLightbox-CzhFe9wZ.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -16,15 +16,15 @@ type __VLS_Props = {
16
16
  */
17
17
  fillBody?: boolean;
18
18
  };
19
- declare var __VLS_33: {}, __VLS_52: {}, __VLS_60: {}, __VLS_62: {};
19
+ declare var __VLS_35: {}, __VLS_54: {}, __VLS_62: {}, __VLS_64: {};
20
20
  type __VLS_Slots = {} & {
21
- title?: (props: typeof __VLS_33) => any;
21
+ title?: (props: typeof __VLS_35) => any;
22
22
  } & {
23
- default?: (props: typeof __VLS_52) => any;
23
+ default?: (props: typeof __VLS_54) => any;
24
24
  } & {
25
- default?: (props: typeof __VLS_60) => any;
25
+ default?: (props: typeof __VLS_62) => any;
26
26
  } & {
27
- footer?: (props: typeof __VLS_62) => any;
27
+ footer?: (props: typeof __VLS_64) => any;
28
28
  };
29
29
  declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
30
30
  "update:open": (value: boolean) => any;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpModal-CYKAaSHO.js";
1
+ import { _ as _sfc_main } from "../chunks/LpModal-vlrgcEY_.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpSidebar-BBG9NhAn.js";
1
+ import { _ as _sfc_main } from "../chunks/LpSidebar-DlLZJk1u.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpThemeSwitcher-Ct-lnXMa.js";
1
+ import { _ as _sfc_main } from "../chunks/LpThemeSwitcher-B_kj0T2b.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
package/dist/index.d.ts CHANGED
@@ -98,6 +98,7 @@ export { useLayout } from './layout/useLayout';
98
98
  export type { UseLayout, UseLayoutOptions } from './layout/useLayout';
99
99
  export { applyTheme, applyThemeWithTransition, bootstrapTheme, parseTheme, serializeTheme, THEME_COOKIE, themeFromCookie, themeToCssRule, themeToCssVars, useTheme, } from './theme/useTheme';
100
100
  export type { RevealOrigin } from './theme/useTheme';
101
+ export { detectBackdropFilter, isBrokenBackdropEngine, NO_BACKDROP_ATTR } from './theme/backdrop';
101
102
  export { DEFAULT_SURFACE } from './theme/tokens';
102
103
  export { defineTheme } from './theme/define';
103
104
  export type { ThemeOverrides } from './theme/define';
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-Dfyn88Dd.js";
2
+ import { _ as _2 } from "./chunks/LpAddressInput-N8O-LtCS.js";
3
3
  import { _ as _3 } from "./chunks/LpMapPicker-DFKrd1Ne.js";
4
4
  import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
5
5
  import { _ as _4 } from "./chunks/LpAlert-BAYHDSS1.js";
6
- import { _ as _5 } from "./chunks/LpAppShell-BvAVgVLW.js";
6
+ import { _ as _5 } from "./chunks/LpAppShell-D42la4ZD.js";
7
7
  import { _ as _6 } from "./chunks/LpAutocomplete-DIgXmtCA.js";
8
8
  import { _ as _7 } from "./chunks/LpAvatar-B1Ez0ZAx.js";
9
9
  import { _ as _8 } from "./chunks/LpBadge-CXzBPnwO.js";
@@ -14,12 +14,12 @@ import { _ as _12 } from "./chunks/LpCard-BmrTvWOe.js";
14
14
  import { _ as _13 } from "./chunks/LpCheckbox-BO0zuuPh.js";
15
15
  import { _ as _14 } from "./chunks/LpCodeBlock-CSiOSPs8.js";
16
16
  import { default as default2 } from "./components/LpCommandPalette.vue.js";
17
- import { _ as _15 } from "./chunks/LpConfirmDialog-BxBU4Pj9.js";
17
+ import { _ as _15 } from "./chunks/LpConfirmDialog-B-amVJsM.js";
18
18
  import { _ as _16 } from "./chunks/LpContextMenu-D0mzXqA7.js";
19
19
  import { _ as _17 } from "./chunks/LpDatePicker-dY2q4dr4.js";
20
20
  import { _ as _18 } from "./chunks/LpDisclosure-DP6yfgfM.js";
21
21
  import { _ as _19 } from "./chunks/LpDivider-CUX46KR0.js";
22
- import { _ as _20 } from "./chunks/LpDrawer-DaCsNuW3.js";
22
+ import { _ as _20 } from "./chunks/LpDrawer-CYm-nUIZ.js";
23
23
  import { _ as _21 } from "./chunks/LpDropdownMenu-DJpoqRCb.js";
24
24
  import { _ as _22 } from "./chunks/LpEmptyState-CrfyRJUG.js";
25
25
  import { _ as _23 } from "./chunks/LpFileTree-DXb3yKzH.js";
@@ -28,11 +28,11 @@ import { _ as _24 } from "./chunks/LpFormField-Bn-ZwC6z.js";
28
28
  import { _ as _25 } from "./chunks/LpIcon-CCnX5_2j.js";
29
29
  import { _ as _26 } from "./chunks/LpInfraNode-C_PHcthC.js";
30
30
  import { _ as _27 } from "./chunks/LpInput-BauZRyzm.js";
31
- import { _ as _28 } from "./chunks/LpLightbox-CWdIsthW.js";
31
+ import { _ as _28 } from "./chunks/LpLightbox-CzhFe9wZ.js";
32
32
  import { fileNameOf } from "./components/lightbox.js";
33
33
  import { _ as _29 } from "./chunks/LpLink-DHwOEp4e.js";
34
34
  import { _ as _30 } from "./chunks/LpLogViewer-tmKPNTuU.js";
35
- import { _ as _31 } from "./chunks/LpModal-CYKAaSHO.js";
35
+ import { _ as _31 } from "./chunks/LpModal-vlrgcEY_.js";
36
36
  import { _ as _32 } from "./chunks/LpNotificationBell-DHze8wYN.js";
37
37
  import { _ as _33 } from "./chunks/LpNumberField-C5ar-OwE.js";
38
38
  import { _ as _34 } from "./chunks/LpOtpInput-DqaT0Z75.js";
@@ -48,7 +48,7 @@ import { _ as _42 } from "./chunks/LpScrollArea-DQ0Yudfu.js";
48
48
  import { _ as _43 } from "./chunks/LpSegmented-BmpanyAo.js";
49
49
  import { _ as _44 } from "./chunks/LpSelect-D5mU4cZO.js";
50
50
  import { _ as _45 } from "./chunks/LpServiceNode-Dc-Ut5RS.js";
51
- import { _ as _46 } from "./chunks/LpSidebar-BBG9NhAn.js";
51
+ import { _ as _46 } from "./chunks/LpSidebar-DlLZJk1u.js";
52
52
  import { _ as _47 } from "./chunks/LpSkeleton-eSFIo4kD.js";
53
53
  import { _ as _48 } from "./chunks/LpSlider-B1SF3pRV.js";
54
54
  import { _ as _49 } from "./chunks/LpStat-CdbEFOvn.js";
@@ -58,8 +58,8 @@ import { _ as _52 } from "./chunks/LpTable-DqZ-L4Au.js";
58
58
  import { _ as _53 } from "./chunks/LpTableOfContents-CjvxPMpU.js";
59
59
  import { _ as _54 } from "./chunks/LpTabs-D7aTVPOb.js";
60
60
  import { _ as _55 } from "./chunks/LpTextarea-CRAkhemZ.js";
61
- import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/LpThemeSwitcher-Ct-lnXMa.js";
62
- import { D, _ as _56, T, b, c, d, e, f, l, g, h, n, r, t, i, j, u, v } from "./chunks/LpThemeSwitcher-Ct-lnXMa.js";
61
+ import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/LpThemeSwitcher-B_kj0T2b.js";
62
+ import { D, _ as _56, N, T, b, c, d, e, f, g, i, l, h, j, n, r, t, k, m, u, v } from "./chunks/LpThemeSwitcher-B_kj0T2b.js";
63
63
  import { _ as _57 } from "./chunks/LpTilt-DuyKbpkA.js";
64
64
  import { _ as _58 } from "./chunks/LpToaster-gDB1EaU3.js";
65
65
  import { _ as _59 } from "./chunks/LpTooltip-CIwoRCag.js";
@@ -167,6 +167,7 @@ export {
167
167
  _59 as LpTooltip,
168
168
  _60 as LpTopologyCanvas,
169
169
  _61 as LpUptimeBar,
170
+ N as NO_BACKDROP_ATTR,
170
171
  OSM_TILES,
171
172
  T as THEME_COOKIE,
172
173
  UI_CONFIG_VERSION,
@@ -188,6 +189,7 @@ export {
188
189
  defineBlocks,
189
190
  defineTheme,
190
191
  deserializeLayout,
192
+ g as detectBackdropFilter,
191
193
  emptyAddress,
192
194
  fileIcon,
193
195
  fileNameOf,
@@ -195,9 +197,10 @@ export {
195
197
  formatAddress,
196
198
  formatModified,
197
199
  formatSize,
200
+ i as isBrokenBackdropEngine,
198
201
  l as leavepulse,
199
- g as light,
200
- h as lime,
202
+ h as light,
203
+ j as lime,
201
204
  loadCountries,
202
205
  makeLayout,
203
206
  matchCountryByValue,
@@ -220,8 +223,8 @@ export {
220
223
  subtreeIds,
221
224
  subtreeSize,
222
225
  t as themeFromCookie,
223
- i as themeToCssRule,
224
- j as themeToCssVars,
226
+ k as themeToCssRule,
227
+ m as themeToCssVars,
225
228
  tileUrl,
226
229
  unproject,
227
230
  useInputFilter,
@@ -0,0 +1,9 @@
1
+ /** Set on <html> when backdrop-filter resolves but does not composite. */
2
+ export declare const NO_BACKDROP_ATTR = "data-lp-no-backdrop";
3
+ export declare function isBrokenBackdropEngine(nav?: Navigator): boolean;
4
+ /**
5
+ * Flag the document when backdrop-filter cannot be trusted, so the fallback
6
+ * rules in tokens.css can trade blur for opacity. Safe to call more than once
7
+ * and a no-op outside the browser.
8
+ */
9
+ export declare function detectBackdropFilter(root?: HTMLElement): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.18.3",
3
+ "version": "0.19.1",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -212,7 +212,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onHotkey))
212
212
  <DialogRoot :open="open" @update:open="(v) => emit('update:open', v)">
213
213
  <DialogPortal>
214
214
  <DialogOverlay
215
- class="fixed inset-0 z-(--z-overlay) bg-black/50 backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
215
+ class="lp-scrim fixed inset-0 z-(--z-overlay) backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
216
216
  />
217
217
  <DialogContent
218
218
  class="fixed left-1/2 top-[12vh] z-(--z-modal) flex max-h-[76vh] w-[min(92vw,40rem)] -translate-x-1/2 flex-col overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[pop-in_160ms_var(--ease-emphasized)] data-[state=closed]:animate-[pop-out_130ms_ease]"
@@ -345,7 +345,7 @@ const edgeStripClass = computed(() =>
345
345
  >
346
346
  <DrawerPortal>
347
347
  <DrawerOverlay
348
- class="fixed inset-0 z-(--z-overlay) bg-black/50 backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
348
+ class="lp-scrim fixed inset-0 z-(--z-overlay) backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
349
349
  />
350
350
  <DrawerContent
351
351
  :class="contentClass"
@@ -270,11 +270,13 @@ const ARROW =
270
270
  <template>
271
271
  <DialogRoot :open="open" @update:open="(v) => emit('update:open', v)">
272
272
  <DialogPortal>
273
+ <!-- Sits on the lightbox rung, not the shared modal one: opening this
274
+ from inside a dialog must cover that dialog, not tie with it. -->
273
275
  <DialogOverlay
274
- class="fixed inset-0 z-(--z-overlay) bg-black/85 backdrop-blur-sm data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
276
+ class="lp-scrim fixed inset-0 z-(--z-lightbox-overlay) backdrop-blur-sm data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
275
277
  />
276
278
  <DialogContent
277
- class="fixed inset-0 z-(--z-modal) flex flex-col outline-none data-[state=open]:animate-[lightbox-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[lightbox-out_140ms_cubic-bezier(0.4,0,1,1)] motion-reduce:animate-none"
279
+ class="fixed inset-0 z-(--z-lightbox) flex flex-col outline-none data-[state=open]:animate-[lightbox-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[lightbox-out_140ms_cubic-bezier(0.4,0,1,1)] motion-reduce:animate-none"
278
280
  @keydown="onKeydown"
279
281
  >
280
282
  <!-- reka wants both for a labelled dialog; the caption is the title. -->
@@ -8,7 +8,8 @@ import {
8
8
  DialogRoot,
9
9
  DialogTitle,
10
10
  } from "reka-ui"
11
- import { computed, useSlots } from "vue"
11
+ import type { ComponentPublicInstance } from "vue"
12
+ import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
12
13
  import { CLOSE_ICON } from "./dropdown"
13
14
  import LpIcon from "./LpIcon.vue"
14
15
  import LpScrollArea from "./LpScrollArea.vue"
@@ -70,13 +71,125 @@ const bodyPad = computed(() =>
70
71
  .filter(Boolean)
71
72
  .join(" "),
72
73
  )
74
+
75
+ /*
76
+ * Height is content-derived, so a body that arrives late — a placeholder swapped
77
+ * for the loaded thing — resizes the panel in a single frame, right on top of
78
+ * the open animation. CSS alone can't smooth it: `height: auto` isn't
79
+ * interpolable, and the keyword-interpolation escape hatches
80
+ * (`interpolate-size`, `calc-size()`) are Chromium-only, so they would do
81
+ * nothing in the WebKitGTK launcher. Measuring and pinning a pixel height is
82
+ * what works on every engine.
83
+ *
84
+ * The pin is always the panel's own natural height, so `max-h` still clips and
85
+ * the body keeps `flex-1 min-h-0` — an over-tall panel hands its overflow to the
86
+ * scroll area exactly as before. A panel whose size never moves is pinned once
87
+ * and never transitions.
88
+ */
89
+ // reka renders a real element but exposes it as a component instance, so the
90
+ // panel is reached through $el.
91
+ const panelRef = ref<ComponentPublicInstance | null>(null)
92
+ const tweening = ref(false)
93
+ const resizing = ref(false)
94
+
95
+ /** Kept in step with the `duration-fast` utility on the panel. */
96
+ const TWEEN_MS = 160
97
+
98
+ let ro: ResizeObserver | undefined
99
+ let mo: MutationObserver | undefined
100
+ let raf = 0
101
+ let settleTimer: ReturnType<typeof setTimeout> | undefined
102
+
103
+ // The height is driven imperatively rather than through a style binding. By the
104
+ // time replacement content is in the DOM the panel has already reflowed to its
105
+ // new size, so a bound value would only ever be written once, with no earlier
106
+ // height for the transition to start from. Writing the OLD height, forcing a
107
+ // reflow, then writing the new one gives the transition both ends.
108
+ const retune = (el: HTMLElement) => {
109
+ const from = el.offsetHeight
110
+ el.style.height = ""
111
+ const to = el.offsetHeight
112
+ if (to === 0) return
113
+ if (to === from) {
114
+ el.style.height = `${to}px`
115
+ return
116
+ }
117
+ el.style.height = `${from}px`
118
+ void el.offsetHeight
119
+ el.style.height = `${to}px`
120
+
121
+ // While the panel is still catching up, the body is shorter than the content
122
+ // it already holds, so the scroll area would flash a bar for an overflow that
123
+ // resolves itself the moment the transition lands.
124
+ resizing.value = true
125
+ clearTimeout(settleTimer)
126
+ settleTimer = setTimeout(() => {
127
+ resizing.value = false
128
+ }, TWEEN_MS)
129
+ }
130
+
131
+ // Keyed off `open` rather than the ref: the panel is portalled and remounts on
132
+ // every open, and the teardown has to run on close, when the ref is already gone.
133
+ watch(
134
+ () => props.open,
135
+ (isOpen) => {
136
+ ro?.disconnect()
137
+ mo?.disconnect()
138
+ ro = undefined
139
+ mo = undefined
140
+ cancelAnimationFrame(raf)
141
+ clearTimeout(settleTimer)
142
+ tweening.value = false
143
+ resizing.value = false
144
+
145
+ const el = panelRef.value?.$el
146
+ if (!isOpen || !(el instanceof HTMLElement)) return
147
+
148
+ el.style.height = `${el.offsetHeight}px`
149
+ // The opening panel must not animate its height in from the pre-content box,
150
+ // so the transition only arms once that first pin has painted.
151
+ raf = requestAnimationFrame(() => {
152
+ tweening.value = true
153
+ })
154
+
155
+ // Two triggers, since either can move the height alone: the resize observer
156
+ // catches a child growing in place (an image decoding, a list filling in),
157
+ // the mutation observer catches one subtree swapped for another, where every
158
+ // observed box may keep its size. Children are observed, never the panel —
159
+ // that would feed the pin straight back in.
160
+ const watchChildren = () => {
161
+ if (!ro) return
162
+ ro.disconnect()
163
+ for (const child of Array.from(el.children)) ro.observe(child)
164
+ }
165
+ if (typeof ResizeObserver !== "undefined") {
166
+ ro = new ResizeObserver(() => retune(el))
167
+ watchChildren()
168
+ }
169
+ if (typeof MutationObserver !== "undefined") {
170
+ mo = new MutationObserver(() => {
171
+ watchChildren()
172
+ retune(el)
173
+ })
174
+ mo.observe(el, { childList: true, subtree: true, characterData: true })
175
+ }
176
+ },
177
+ { flush: "post" },
178
+ )
179
+
180
+ onBeforeUnmount(() => {
181
+ ro?.disconnect()
182
+ mo?.disconnect()
183
+ cancelAnimationFrame(raf)
184
+ clearTimeout(settleTimer)
185
+ })
73
186
  </script>
74
187
 
75
188
  <template>
76
189
  <DialogRoot :open="open" @update:open="(v) => $emit('update:open', v)">
77
190
  <DialogPortal>
78
191
  <DialogOverlay
79
- class="fixed inset-0 z-(--z-overlay) bg-black/50 backdrop-blur-sm data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
192
+ class="lp-scrim fixed inset-0 z-(--z-overlay) backdrop-blur-sm data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
80
193
  />
81
194
  <!-- Centred by a full-screen flex wrapper rather than by translating the
82
195
  panel off its own centre. With `top:50% / -translate-y-1/2` the panel
@@ -100,8 +213,9 @@ const bodyPad = computed(() =>
100
213
  pass a description keeps the generated id and the link to
101
214
  DialogDescription. -->
102
215
  <DialogContent
103
- class="pointer-events-auto flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]"
104
- :class="widthClass"
216
+ ref="panelRef"
217
+ class="pointer-events-auto flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]"
218
+ :class="[widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
105
219
  :style="width ? { width } : undefined"
106
220
  v-bind="describedByAttrs"
107
221
  >
@@ -135,9 +249,14 @@ const bodyPad = computed(() =>
135
249
  >
136
250
  <slot />
137
251
  </div>
252
+ <!-- While the panel is still growing it is briefly shorter than the body
253
+ it already holds, so the scroll area raises a bar for an overflow
254
+ that resolves itself as the transition lands. Hiding just the bar
255
+ for the length of the tween leaves scrolling itself untouched. -->
138
256
  <LpScrollArea
139
257
  v-else
140
258
  class="min-h-0 flex-1 text-sm text-ink/90"
259
+ :class="resizing ? '[&_[data-scrollbarimpl]]:invisible' : ''"
141
260
  :content-class="bodyPad"
142
261
  >
143
262
  <slot />
package/src/index.ts CHANGED
@@ -165,6 +165,7 @@ export {
165
165
  useTheme,
166
166
  } from "./theme/useTheme"
167
167
  export type { RevealOrigin } from "./theme/useTheme"
168
+ export { detectBackdropFilter, isBrokenBackdropEngine, NO_BACKDROP_ATTR } from "./theme/backdrop"
168
169
  export { DEFAULT_SURFACE } from "./theme/tokens"
169
170
  export { defineTheme } from "./theme/define"
170
171
  export type { ThemeOverrides } from "./theme/define"
@@ -0,0 +1,51 @@
1
+ /*
2
+ * backdrop-filter capability detection.
3
+ *
4
+ * Every glass surface in the kit (dialog scrims, dropdown panels, the glass
5
+ * skin) leans on backdrop-filter. WebKitGTK — the engine Tauri embeds on Linux,
6
+ * and the one behind WebKit2GTK desktop shells — parses the property, reports
7
+ * it as supported through BOTH CSS.supports() and @supports, resolves it to a
8
+ * real computed value, and then composites it as a no-op. The layer tints; it
9
+ * never blurs.
10
+ *
11
+ * That makes the usual feature query worthless: there is no CSS condition that
12
+ * separates a working implementation from this one, because the engine answers
13
+ * "yes" to all of them. Tailwind's own Safari workaround does not fire either
14
+ * (WebKitGTK matches `margin-trim`, which the hack uses to rule Safari out).
15
+ *
16
+ * So the check is a runtime one, and deliberately conservative: it only flags
17
+ * the engine fingerprint that is known to be broken, and treats anything it
18
+ * cannot positively identify as working. A false negative costs a blur nobody
19
+ * sees; a false positive would strip the blur from browsers that render it
20
+ * correctly, which is the worse failure.
21
+ */
22
+
23
+ /** Set on <html> when backdrop-filter resolves but does not composite. */
24
+ export const NO_BACKDROP_ATTR = "data-lp-no-backdrop"
25
+
26
+ /*
27
+ * WebKit that is neither Chromium nor an Apple platform. Chromium keeps
28
+ * "AppleWebKit" in its UA string, so it has to be excluded explicitly; real
29
+ * Safari only ever runs on a Mac/iOS platform string. What survives both
30
+ * filters is WebKitGTK/WPE — the broken case.
31
+ */
32
+ export function isBrokenBackdropEngine(nav: Navigator = navigator): boolean {
33
+ const ua = nav.userAgent
34
+ if (!/AppleWebKit/.test(ua)) return false
35
+ if (/Chrome|Chromium|Edg\//.test(ua)) return false
36
+ return !/Mac|iPhone|iPad|iPod/.test(nav.platform || "")
37
+ }
38
+
39
+ /**
40
+ * Flag the document when backdrop-filter cannot be trusted, so the fallback
41
+ * rules in tokens.css can trade blur for opacity. Safe to call more than once
42
+ * and a no-op outside the browser.
43
+ */
44
+ export function detectBackdropFilter(
45
+ root: HTMLElement = document.documentElement,
46
+ ): boolean {
47
+ if (typeof navigator === "undefined") return true
48
+ const usable = !isBrokenBackdropEngine()
49
+ root.toggleAttribute(NO_BACKDROP_ATTR, !usable)
50
+ return usable
51
+ }
@@ -5,6 +5,7 @@
5
5
  * serialize/parse with validation.
6
6
  */
7
7
  import { computed, readonly, shallowRef, type ComputedRef, type DeepReadonly, type Ref } from "vue"
8
+ import { detectBackdropFilter } from "./backdrop"
8
9
  import {
9
10
  COLOR_VARS,
10
11
  DEFAULT_SURFACE,
@@ -75,6 +76,9 @@ export function applyTheme(theme: TokenSet, root: HTMLElement = document.documen
75
76
  }
76
77
  root.dataset.themeMode = theme.mode
77
78
  root.style.colorScheme = theme.mode
79
+ // Engine capability, not theme data — but this is the one path every consumer
80
+ // already calls, so the glass fallback lands without extra wiring.
81
+ detectBackdropFilter(root)
78
82
  }
79
83
 
80
84
  /** Point a circular theme reveal expands from (viewport px). */
@@ -120,6 +120,15 @@
120
120
  --z-popover: 120;
121
121
  --z-tooltip: 130;
122
122
  --z-toast: 200;
123
+
124
+ /* A lightbox is usually opened FROM a dialog — click a thumbnail in a modal
125
+ and the full image comes up over it. Sharing --z-overlay/--z-modal left
126
+ that order to DOM insertion alone, so the lightbox's own scrim landed on
127
+ top of the modal panel and swallowed it while double-darkening the page
128
+ behind. Its own rung above the modal (but below tooltip/toast, which must
129
+ outrank everything) makes the stack explicit. */
130
+ --z-lightbox-overlay: 140;
131
+ --z-lightbox: 150;
123
132
  }
124
133
 
125
134
  /* ── overlay enter/exit animations ───────────────
@@ -379,6 +388,45 @@
379
388
  -webkit-backdrop-filter: blur(var(--surface-panel-blur)) saturate(var(--surface-panel-saturate));
380
389
  }
381
390
 
391
+ /*
392
+ * No-backdrop fallback. WebKitGTK — the engine behind Tauri on Linux — parses
393
+ * backdrop-filter and reports it through both CSS.supports() and @supports,
394
+ * then composites it as a no-op: the layer tints but never blurs. Since the
395
+ * lie makes @supports useless here, detectBackdropFilter() sets this flag on
396
+ * <html> instead, and every glass surface trades the blur it cannot get for
397
+ * the opacity it can. Without it a translucent panel over busy content reads
398
+ * as unfinished rather than frosted.
399
+ */
400
+ :root[data-lp-no-backdrop] {
401
+ --surface-panel-blur: 0px;
402
+ --surface-panel-saturate: 100%;
403
+ }
404
+
405
+ :root[data-lp-no-backdrop] .lp-skin-panel {
406
+ backdrop-filter: none;
407
+ -webkit-backdrop-filter: none;
408
+ }
409
+
410
+ /*
411
+ * Scrims lean on the same trick. `.lp-scrim` marks the full-screen overlay
412
+ * behind a dialog; where the blur lands the tint stays light, and where it
413
+ * cannot the tint carries the separation on its own.
414
+ *
415
+ * The bump is deliberately mild. Scrims stack — a lightbox opened from a modal
416
+ * paints two — and opacity compounds, so a value tuned to look right alone goes
417
+ * nearly black in a pair. 0.62 reads as a clear backdrop on its own and still
418
+ * leaves the stacked case legible.
419
+ */
420
+ .lp-scrim {
421
+ background-color: rgb(0 0 0 / var(--scrim-opacity, 0.5));
422
+ }
423
+
424
+ :root[data-lp-no-backdrop] .lp-scrim {
425
+ backdrop-filter: none;
426
+ -webkit-backdrop-filter: none;
427
+ --scrim-opacity: 0.62;
428
+ }
429
+
382
430
  /* The overlay gradient rides above the base fill but under the content. */
383
431
  .lp-skin-panel::before {
384
432
  content: "";