@leavepulse/ui 0.54.0 → 0.55.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.
package/README.md CHANGED
@@ -61,7 +61,9 @@ useTheme().setSystem({ light, dark }) // follow the OS colour scheme
61
61
  ```
62
62
 
63
63
  `LpThemeProvider` applies a theme to one subtree only (a dark sidebar in a light
64
- app, a live preview).
64
+ app, a live preview). With `transition`, changing its `theme` reveals the new one
65
+ with the same circular transition as the page switch, from where the user last
66
+ pressed. `revealTransition(update, origin)` runs any repaint behind that reveal.
65
67
 
66
68
  ### App-wide configuration
67
69
 
@@ -0,0 +1,69 @@
1
+ import { defineComponent, ref, shallowRef, computed, watch, nextTick, openBlock, createBlock, resolveDynamicComponent, mergeProps, unref, withCtx, renderSlot } from "vue";
2
+ import { t as themeEntries, r as revealTransition } from "./useTheme-2qHG8NEU.js";
3
+ import { useMergedAttrs } from "../composables/useMergedClass.js";
4
+ const PRESS_FRESH_MS = 1500;
5
+ let lastPress = null;
6
+ if (typeof window !== "undefined") {
7
+ window.addEventListener(
8
+ "pointerdown",
9
+ (event) => lastPress = { x: event.clientX, y: event.clientY, at: performance.now() },
10
+ { capture: true, passive: true }
11
+ );
12
+ }
13
+ const __default__ = { inheritAttrs: false };
14
+ const _sfc_main = /* @__PURE__ */ defineComponent({
15
+ ...__default__,
16
+ __name: "LpThemeProvider",
17
+ props: {
18
+ theme: {},
19
+ as: { default: "div" },
20
+ transition: { type: Boolean, default: false }
21
+ },
22
+ setup(__props) {
23
+ const props = __props;
24
+ const { class: rootClass, attrs: rest } = useMergedAttrs("bg-surface text-ink");
25
+ const root = ref(null);
26
+ const shown = shallowRef(props.theme);
27
+ const style = computed(() => ({
28
+ ...Object.fromEntries(themeEntries(shown.value)),
29
+ colorScheme: shown.value.mode
30
+ }));
31
+ function originOf() {
32
+ if (lastPress && performance.now() - lastPress.at < PRESS_FRESH_MS) return lastPress;
33
+ const value = root.value;
34
+ const el = value instanceof HTMLElement ? value : value?.$el;
35
+ const box = el?.getBoundingClientRect?.();
36
+ return box ? { x: box.left + box.width / 2, y: box.top + box.height / 2 } : void 0;
37
+ }
38
+ watch(
39
+ () => props.theme,
40
+ (next) => {
41
+ if (!props.transition) {
42
+ shown.value = next;
43
+ return;
44
+ }
45
+ revealTransition(async () => {
46
+ shown.value = next;
47
+ await nextTick();
48
+ }, originOf());
49
+ }
50
+ );
51
+ return (_ctx, _cache) => {
52
+ return openBlock(), createBlock(resolveDynamicComponent(__props.as), mergeProps({
53
+ ref_key: "root",
54
+ ref: root,
55
+ style: style.value,
56
+ "data-theme-mode": shown.value.mode,
57
+ class: unref(rootClass)
58
+ }, unref(rest)), {
59
+ default: withCtx(() => [
60
+ renderSlot(_ctx.$slots, "default")
61
+ ]),
62
+ _: 3
63
+ }, 16, ["style", "data-theme-mode", "class"]);
64
+ };
65
+ }
66
+ });
67
+ export {
68
+ _sfc_main as _
69
+ };
@@ -1,6 +1,6 @@
1
1
  import { defineComponent, computed, onMounted, ref, openBlock, createBlock, withCtx, createElementVNode, normalizeClass, createElementBlock, createVNode, toDisplayString, createCommentVNode } from "vue";
2
2
  import { useLabels } from "../composables/useLpConfig.js";
3
- import { u as useTheme, p as presets } from "./useTheme-DJm1JSsY.js";
3
+ import { u as useTheme, p as presets } from "./useTheme-2qHG8NEU.js";
4
4
  import { _ as _sfc_main$1 } from "./LpContextMenu-EF_90u3g.js";
5
5
  import { _ as _sfc_main$2 } from "./LpIcon-DYtaid0K.js";
6
6
  const _hoisted_1 = ["aria-label", "title"];
@@ -439,10 +439,10 @@ function applyTheme(theme, root = document.documentElement) {
439
439
  root.style.colorScheme = theme.mode;
440
440
  detectBackdropFilter(root);
441
441
  }
442
- function applyThemeWithTransition(theme, origin, root = document.documentElement, durationMs = 480) {
442
+ function revealTransition(update, origin, durationMs = 480) {
443
443
  const doc = document;
444
444
  if (typeof doc.startViewTransition !== "function" || prefersReducedMotion()) {
445
- applyTheme(theme, root);
445
+ void update();
446
446
  return;
447
447
  }
448
448
  const x = origin?.x ?? window.innerWidth / 2;
@@ -451,9 +451,9 @@ function applyThemeWithTransition(theme, origin, root = document.documentElement
451
451
  Math.max(x, window.innerWidth - x),
452
452
  Math.max(y, window.innerHeight - y)
453
453
  );
454
- const transition = doc.startViewTransition(() => applyTheme(theme, root));
454
+ const transition = doc.startViewTransition(update);
455
455
  transition.ready.then(() => {
456
- root.animate(
456
+ document.documentElement.animate(
457
457
  {
458
458
  clipPath: [`circle(${endRadius}px at ${x}px ${y}px)`, `circle(0px at ${x}px ${y}px)`]
459
459
  },
@@ -471,6 +471,9 @@ function applyThemeWithTransition(theme, origin, root = document.documentElement
471
471
  );
472
472
  });
473
473
  }
474
+ function applyThemeWithTransition(theme, origin, root = document.documentElement, durationMs = 480) {
475
+ revealTransition(() => applyTheme(theme, root), origin, durationMs);
476
+ }
474
477
  function serializeTheme(theme) {
475
478
  return JSON.stringify(theme);
476
479
  }
@@ -614,14 +617,15 @@ export {
614
617
  light as l,
615
618
  prefersDarkScheme as m,
616
619
  nord as n,
617
- setGlassBackdrop as o,
620
+ rose as o,
618
621
  presets as p,
619
- themeFromCookie as q,
620
- rose as r,
622
+ setGlassBackdrop as q,
623
+ revealTransition as r,
621
624
  serializeTheme as s,
622
625
  themeEntries as t,
623
626
  useTheme as u,
624
- themeToCssRule as v,
625
- themeToCssVars as w,
626
- violet as x
627
+ themeFromCookie as v,
628
+ themeToCssRule as w,
629
+ themeToCssVars as x,
630
+ violet as y
627
631
  };
@@ -6,12 +6,17 @@ declare const __VLS_export: __VLS_WithSlots<import("vue").DefineComponent<{
6
6
  theme: TokenSet;
7
7
  /** Root element. A wrapper that should not affect layout can use `class="contents"`. */
8
8
  as?: string | Component;
9
+ /** Reveal a change of `theme` with the circular transition. */
10
+ transition?: boolean;
9
11
  }, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{
10
12
  theme: TokenSet;
11
13
  /** Root element. A wrapper that should not affect layout can use `class="contents"`. */
12
14
  as?: string | Component;
15
+ /** Reveal a change of `theme` with the circular transition. */
16
+ transition?: boolean;
13
17
  }> & Readonly<{}>, {
14
18
  as: string | Component;
19
+ transition: boolean;
15
20
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>, {
16
21
  default?: (props: {}) => any;
17
22
  }>;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpThemeProvider-Be4r2TQX.js";
1
+ import { _ as _sfc_main } from "../chunks/LpThemeProvider-CQ2jC9TA.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpThemeSwitcher-CPmhYOjX.js";
1
+ import { _ as _sfc_main } from "../chunks/LpThemeSwitcher-Fz85BCRS.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
package/dist/index.d.ts CHANGED
@@ -168,7 +168,7 @@ export { blockTitle, defineBlocks } from "./layout/registry";
168
168
  export type { BlockDef, BlockRegistry } from "./layout/registry";
169
169
  export { useLayout } from "./layout/useLayout";
170
170
  export type { UseLayout, UseLayoutOptions } from "./layout/useLayout";
171
- export { applyTheme, applyThemeWithTransition, bootstrapTheme, parseTheme, serializeTheme, THEME_COOKIE, themeFromCookie, themeToCssRule, themeToCssVars, useTheme, } from "./theme/useTheme";
171
+ export { applyTheme, applyThemeWithTransition, revealTransition, bootstrapTheme, parseTheme, serializeTheme, THEME_COOKIE, themeFromCookie, themeToCssRule, themeToCssVars, useTheme, } from "./theme/useTheme";
172
172
  export type { RevealOrigin } from "./theme/useTheme";
173
173
  export { detectBackdropFilter, GLASS_BACKDROP_ATTR, isBrokenBackdropEngine, NO_BACKDROP_ATTR, setGlassBackdrop } from "./theme/backdrop";
174
174
  export { DEFAULT_SURFACE } from "./theme/tokens";
package/dist/index.js CHANGED
@@ -100,7 +100,7 @@ import { _ as _71 } from "./chunks/LpTabs-BMSBB78h.js";
100
100
  import { _ as _72 } from "./chunks/LpTagsInput-D1SuqbhB.js";
101
101
  import { addTags, splitTags } from "./components/tags.js";
102
102
  import { _ as _73 } from "./chunks/LpTextarea-uuScWo6-.js";
103
- import { _ as _74 } from "./chunks/LpThemeSwitcher-CPmhYOjX.js";
103
+ import { _ as _74 } from "./chunks/LpThemeSwitcher-Fz85BCRS.js";
104
104
  import { _ as _75 } from "./chunks/LpTilt-B7KHX1zH.js";
105
105
  import { _ as _76 } from "./chunks/LpToaster-DTtWuKvq.js";
106
106
  import { _ as _77 } from "./chunks/LpTooltip-CXVKcOXT.js";
@@ -111,8 +111,8 @@ import { deserializeLayout, serializeLayout } from "./layout/tree.js";
111
111
  import { addLeaf, addTab, countBlocks, countLeaves, makeLayout, moveLeaf, removeBlock, removeLeaf, reorderTab, resizeAt, setActiveTab } from "./layout/tree.js";
112
112
  import { blockTitle, defineBlocks } from "./layout/registry.js";
113
113
  import { layoutController, useLayout } from "./layout/useLayout.js";
114
- import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/useTheme-DJm1JSsY.js";
115
- import { D, G, N, T, b as b2, c as c2, d, e, f, g, h, i as i2, l, j, n, k, m, r, o, t, q, v, w, u, x } from "./chunks/useTheme-DJm1JSsY.js";
114
+ import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/useTheme-2qHG8NEU.js";
115
+ import { D, G, N, T, b as b2, c as c2, d, e, f, g, h, i as i2, l, j, n, k, m, r, o, q, t, v, w, x, u, y } from "./chunks/useTheme-2qHG8NEU.js";
116
116
  import { useClipboard } from "./composables/useClipboard.js";
117
117
  import { activeShortcuts, appleModifierLabel, findHotkeyConflicts, hotkeyConflicts, hotkeySteps, isAppleKeyboard, matchesHotkey, mergeShortcutGroups, pendingHotkey, sequenceProgress, useAppleKeyboard, useHotkeys } from "./composables/useHotkeys.js";
118
118
  import { anyModalOpen, topModalElement, useModalLayer } from "./components/modalDepth.js";
@@ -137,7 +137,7 @@ import { useShift } from "./composables/useShift.js";
137
137
  import { useTilt } from "./composables/useTilt.js";
138
138
  import { useZoomPan } from "./composables/useZoomPan.js";
139
139
  import { useToast } from "./composables/useToast.js";
140
- import { _ as _81 } from "./chunks/LpThemeProvider-Be4r2TQX.js";
140
+ import { _ as _81 } from "./chunks/LpThemeProvider-CQ2jC9TA.js";
141
141
  import { isInternalHref, resolveLink } from "./components/link.js";
142
142
  function defineTheme(base, overrides = {}) {
143
143
  const src = typeof base === "string" ? presets[base] : base;
@@ -399,10 +399,11 @@ export {
399
399
  resolveCssColor,
400
400
  resolveLink,
401
401
  resolveSlices,
402
+ r as revealTransition,
402
403
  rgbToHex,
403
404
  rgbToHsv,
404
405
  ringWindow,
405
- r as rose,
406
+ o as rose,
406
407
  sameRange,
407
408
  seamProgress,
408
409
  seamReached,
@@ -411,7 +412,7 @@ export {
411
412
  serializeLayout,
412
413
  serializeTheme,
413
414
  setActiveTab,
414
- o as setGlassBackdrop,
415
+ q as setGlassBackdrop,
415
416
  sortNodes,
416
417
  splitTags,
417
418
  stepMatch,
@@ -420,9 +421,9 @@ export {
420
421
  subtreeSize,
421
422
  svgToPng,
422
423
  t as themeEntries,
423
- q as themeFromCookie,
424
- v as themeToCssRule,
425
- w as themeToCssVars,
424
+ v as themeFromCookie,
425
+ w as themeToCssRule,
426
+ x as themeToCssVars,
426
427
  tileUrl,
427
428
  toCsv,
428
429
  toRecentItem,
@@ -459,5 +460,5 @@ export {
459
460
  useTilt,
460
461
  useToast,
461
462
  useZoomPan,
462
- x as violet
463
+ y as violet
463
464
  };
@@ -15,6 +15,7 @@ export interface RevealOrigin {
15
15
  x: number;
16
16
  y: number;
17
17
  }
18
+ export declare function revealTransition(update: () => void | Promise<void>, origin?: RevealOrigin, durationMs?: number): void;
18
19
  export declare function applyThemeWithTransition(theme: TokenSet, origin?: RevealOrigin, root?: HTMLElement, durationMs?: number): void;
19
20
  export declare function serializeTheme(theme: TokenSet): string;
20
21
  export declare function themeToCssVars(theme: TokenSet): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -1,4 +1,16 @@
1
1
  <script lang="ts">
2
+ // The last press anywhere on the page: a theme change triggered by a toggle
3
+ // reveals from the toggle the user just pressed.
4
+ const PRESS_FRESH_MS = 1500
5
+ let lastPress: { x: number; y: number; at: number } | null = null
6
+ if (typeof window !== "undefined") {
7
+ window.addEventListener(
8
+ "pointerdown",
9
+ (event) => (lastPress = { x: event.clientX, y: event.clientY, at: performance.now() }),
10
+ { capture: true, passive: true },
11
+ )
12
+ }
13
+
2
14
  export default { inheritAttrs: false }
3
15
  </script>
4
16
 
@@ -9,10 +21,14 @@ export default { inheritAttrs: false }
9
21
  * keeps its own look. Every token is declared on this element, derived ones
10
22
  * included, so kit components inside re-skin while the page around them keeps
11
23
  * the global theme. Nothing is persisted; this is presentation only.
24
+ *
25
+ * With `transition`, a change of `theme` repaints the subtree behind the same
26
+ * circular reveal the global theme switch uses, growing from where the user
27
+ * last pressed, or from the subtree's centre.
12
28
  */
13
- import { computed, type Component } from "vue"
29
+ import { computed, nextTick, ref, shallowRef, watch, type Component, type ComponentPublicInstance } from "vue"
14
30
  import type { TokenSet } from "../theme/tokens"
15
- import { themeEntries } from "../theme/useTheme"
31
+ import { revealTransition, themeEntries, type RevealOrigin } from "../theme/useTheme"
16
32
  import { useMergedAttrs } from "../composables/useMergedClass"
17
33
 
18
34
  const props = withDefaults(
@@ -20,22 +36,49 @@ const props = withDefaults(
20
36
  theme: TokenSet
21
37
  /** Root element. A wrapper that should not affect layout can use `class="contents"`. */
22
38
  as?: string | Component
39
+ /** Reveal a change of `theme` with the circular transition. */
40
+ transition?: boolean
23
41
  }>(),
24
- { as: "div" },
42
+ { as: "div", transition: false },
25
43
  )
26
44
 
27
45
  // Paints its own surface and text so the scoped theme reads as one; a class
28
46
  // like `bg-transparent` overrides that.
29
47
  const { class: rootClass, attrs: rest } = useMergedAttrs("bg-surface text-ink")
30
48
 
49
+ const root = ref<HTMLElement | ComponentPublicInstance | null>(null)
50
+ const shown = shallowRef(props.theme)
51
+
31
52
  const style = computed(() => ({
32
- ...Object.fromEntries(themeEntries(props.theme)),
33
- colorScheme: props.theme.mode,
53
+ ...Object.fromEntries(themeEntries(shown.value)),
54
+ colorScheme: shown.value.mode,
34
55
  }))
56
+
57
+ function originOf(): RevealOrigin | undefined {
58
+ if (lastPress && performance.now() - lastPress.at < PRESS_FRESH_MS) return lastPress
59
+ const value = root.value
60
+ const el = value instanceof HTMLElement ? value : (value?.$el as Element | undefined)
61
+ const box = el?.getBoundingClientRect?.()
62
+ return box ? { x: box.left + box.width / 2, y: box.top + box.height / 2 } : undefined
63
+ }
64
+
65
+ watch(
66
+ () => props.theme,
67
+ (next) => {
68
+ if (!props.transition) {
69
+ shown.value = next
70
+ return
71
+ }
72
+ revealTransition(async () => {
73
+ shown.value = next
74
+ await nextTick()
75
+ }, originOf())
76
+ },
77
+ )
35
78
  </script>
36
79
 
37
80
  <template>
38
- <component :is="as" :style="style" :data-theme-mode="theme.mode" :class="rootClass" v-bind="rest">
81
+ <component :is="as" ref="root" :style="style" :data-theme-mode="shown.mode" :class="rootClass" v-bind="rest">
39
82
  <slot />
40
83
  </component>
41
84
  </template>
package/src/index.ts CHANGED
@@ -245,6 +245,7 @@ export type { UseLayout, UseLayoutOptions } from "./layout/useLayout"
245
245
  export {
246
246
  applyTheme,
247
247
  applyThemeWithTransition,
248
+ revealTransition,
248
249
  bootstrapTheme,
249
250
  parseTheme,
250
251
  serializeTheme,
@@ -167,30 +167,28 @@ export interface RevealOrigin {
167
167
  }
168
168
 
169
169
  type ViewTransitionDocument = Document & {
170
- startViewTransition?: (cb: () => void) => { ready: Promise<void>; finished: Promise<void> }
170
+ startViewTransition?: (cb: () => void | Promise<void>) => { ready: Promise<void>; finished: Promise<void> }
171
171
  }
172
172
 
173
173
  /*
174
- * Apply a theme with the "circular reveal" transition — a clip-path circle
175
- * grows from `origin` (usually the toggle button's centre), repainting the page
176
- * underneath. Uses the View Transitions API; falls back to a plain applyTheme
177
- * when it's unsupported or the user prefers reduced motion (so behaviour is
178
- * identical, just without the animation). The actual token write still happens
179
- * through applyTheme, so this stays a thin wrapper — no duplicated var logic.
174
+ * Run `update` behind a "circular reveal": a clip-path circle grows from
175
+ * `origin` (viewport px, the centre when omitted) and shows the repainted page
176
+ * underneath. Only what `update` changed looks different, so repainting one
177
+ * subtree reveals just that subtree. Uses the View Transitions API and falls
178
+ * back to a plain update when it's unsupported or the user prefers reduced
179
+ * motion. `update` may be async (a framework flush); the snapshot waits for it.
180
180
  */
181
- export function applyThemeWithTransition(
182
- theme: TokenSet,
181
+ export function revealTransition(
182
+ update: () => void | Promise<void>,
183
183
  origin?: RevealOrigin,
184
- root: HTMLElement = document.documentElement,
185
184
  durationMs = 480,
186
185
  ): void {
187
186
  const doc = document as ViewTransitionDocument
188
187
  if (typeof doc.startViewTransition !== "function" || prefersReducedMotion()) {
189
- applyTheme(theme, root)
188
+ void update()
190
189
  return
191
190
  }
192
191
 
193
- // Centre of the viewport is the default origin if no click point is given.
194
192
  const x = origin?.x ?? window.innerWidth / 2
195
193
  const y = origin?.y ?? window.innerHeight / 2
196
194
  // Radius that reaches the farthest viewport corner from the origin.
@@ -199,13 +197,15 @@ export function applyThemeWithTransition(
199
197
  Math.max(y, window.innerHeight - y),
200
198
  )
201
199
 
202
- const transition = doc.startViewTransition(() => applyTheme(theme, root))
200
+ const transition = doc.startViewTransition(update)
203
201
  transition.ready.then(() => {
204
202
  // Animate the OLD snapshot shrinking to a point, revealing the (already
205
203
  // repainted) live page underneath through the growing hole. Clipping the
206
204
  // old layer — not growing the new one — avoids a one-frame flash of the new
207
205
  // theme before the circle starts, which read as the theme "changing twice".
208
- root.animate(
206
+ // The pseudo-elements belong to the document element whatever was updated,
207
+ // so the animation always targets it.
208
+ document.documentElement.animate(
209
209
  {
210
210
  clipPath: [`circle(${endRadius}px at ${x}px ${y}px)`, `circle(0px at ${x}px ${y}px)`],
211
211
  },
@@ -224,6 +224,20 @@ export function applyThemeWithTransition(
224
224
  })
225
225
  }
226
226
 
227
+ /*
228
+ * Apply a theme with the circular reveal, usually from the toggle button's
229
+ * centre. `root` scopes the theme to a subtree; the reveal still runs on the
230
+ * document, since that is where the transition snapshots live.
231
+ */
232
+ export function applyThemeWithTransition(
233
+ theme: TokenSet,
234
+ origin?: RevealOrigin,
235
+ root: HTMLElement = document.documentElement,
236
+ durationMs = 480,
237
+ ): void {
238
+ revealTransition(() => applyTheme(theme, root), origin, durationMs)
239
+ }
240
+
227
241
  export function serializeTheme(theme: TokenSet): string {
228
242
  return JSON.stringify(theme)
229
243
  }
@@ -1,35 +0,0 @@
1
- import { defineComponent, computed, openBlock, createBlock, resolveDynamicComponent, mergeProps, unref, withCtx, renderSlot } from "vue";
2
- import { t as themeEntries } from "./useTheme-DJm1JSsY.js";
3
- import { useMergedAttrs } from "../composables/useMergedClass.js";
4
- const __default__ = { inheritAttrs: false };
5
- const _sfc_main = /* @__PURE__ */ defineComponent({
6
- ...__default__,
7
- __name: "LpThemeProvider",
8
- props: {
9
- theme: {},
10
- as: { default: "div" }
11
- },
12
- setup(__props) {
13
- const props = __props;
14
- const { class: rootClass, attrs: rest } = useMergedAttrs("bg-surface text-ink");
15
- const style = computed(() => ({
16
- ...Object.fromEntries(themeEntries(props.theme)),
17
- colorScheme: props.theme.mode
18
- }));
19
- return (_ctx, _cache) => {
20
- return openBlock(), createBlock(resolveDynamicComponent(__props.as), mergeProps({
21
- style: style.value,
22
- "data-theme-mode": __props.theme.mode,
23
- class: unref(rootClass)
24
- }, unref(rest)), {
25
- default: withCtx(() => [
26
- renderSlot(_ctx.$slots, "default")
27
- ]),
28
- _: 3
29
- }, 16, ["style", "data-theme-mode", "class"]);
30
- };
31
- }
32
- });
33
- export {
34
- _sfc_main as _
35
- };