@leavepulse/ui 0.12.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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/component-names.d.ts +1 -0
- package/dist/components/LayoutCanvas.vue.d.ts +28 -0
- package/dist/components/LayoutNode.vue.d.ts +40 -0
- package/dist/components/LpAlert.vue.d.ts +44 -0
- package/dist/components/LpAppShell.vue.d.ts +83 -0
- package/dist/components/LpAutocomplete.vue.d.ts +50 -0
- package/dist/components/LpAvatar.vue.d.ts +20 -0
- package/dist/components/LpBadge.vue.d.ts +47 -0
- package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
- package/dist/components/LpButton.vue.d.ts +113 -0
- package/dist/components/LpCalendar.vue.d.ts +20 -0
- package/dist/components/LpCard.vue.d.ts +29 -0
- package/dist/components/LpCheckbox.vue.d.ts +28 -0
- package/dist/components/LpCodeBlock.vue.d.ts +39 -0
- package/dist/components/LpCommandPalette.vue.d.ts +42 -0
- package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
- package/dist/components/LpContextMenu.vue.d.ts +30 -0
- package/dist/components/LpDatePicker.vue.d.ts +25 -0
- package/dist/components/LpDisclosure.vue.d.ts +37 -0
- package/dist/components/LpDivider.vue.d.ts +17 -0
- package/dist/components/LpDrawer.vue.d.ts +88 -0
- package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
- package/dist/components/LpEmptyState.vue.d.ts +18 -0
- package/dist/components/LpFormField.vue.d.ts +21 -0
- package/dist/components/LpIcon.vue.d.ts +8 -0
- package/dist/components/LpInfraNode.vue.d.ts +21 -0
- package/dist/components/LpInput.vue.d.ts +68 -0
- package/dist/components/LpLaneNode.vue.d.ts +24 -0
- package/dist/components/LpLink.vue.d.ts +27 -0
- package/dist/components/LpLogViewer.vue.d.ts +92 -0
- package/dist/components/LpModal.vue.d.ts +43 -0
- package/dist/components/LpNotificationBell.vue.d.ts +51 -0
- package/dist/components/LpNumberField.vue.d.ts +14 -0
- package/dist/components/LpOtpInput.vue.d.ts +27 -0
- package/dist/components/LpPagination.vue.d.ts +25 -0
- package/dist/components/LpPasswordInput.vue.d.ts +28 -0
- package/dist/components/LpPhoneInput.vue.d.ts +37 -0
- package/dist/components/LpPopover.vue.d.ts +34 -0
- package/dist/components/LpProgress.vue.d.ts +12 -0
- package/dist/components/LpRadio.vue.d.ts +18 -0
- package/dist/components/LpRadioGroup.vue.d.ts +26 -0
- package/dist/components/LpScrollArea.vue.d.ts +24 -0
- package/dist/components/LpSegmented.vue.d.ts +24 -0
- package/dist/components/LpSelect.vue.d.ts +45 -0
- package/dist/components/LpServiceNode.vue.d.ts +14 -0
- package/dist/components/LpSidebar.vue.d.ts +85 -0
- package/dist/components/LpSidebarNav.vue.d.ts +34 -0
- package/dist/components/LpSkeleton.vue.d.ts +6 -0
- package/dist/components/LpSlider.vue.d.ts +23 -0
- package/dist/components/LpStat.vue.d.ts +22 -0
- package/dist/components/LpStepper.vue.d.ts +10 -0
- package/dist/components/LpSwitch.vue.d.ts +11 -0
- package/dist/components/LpTable.vue.d.ts +63 -0
- package/dist/components/LpTableOfContents.vue.d.ts +19 -0
- package/dist/components/LpTabs.vue.d.ts +45 -0
- package/dist/components/LpTextarea.vue.d.ts +17 -0
- package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
- package/dist/components/LpToaster.vue.d.ts +3 -0
- package/dist/components/LpTooltip.vue.d.ts +19 -0
- package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
- package/dist/components/LpUptimeBar.vue.d.ts +30 -0
- package/dist/components/codeHighlight.d.ts +8 -0
- package/dist/components/countries-data.d.ts +2 -0
- package/dist/components/countries.d.ts +20 -0
- package/dist/components/dropdown.d.ts +29 -0
- package/dist/components/sidebar.d.ts +18 -0
- package/dist/composables/useInputFilter.d.ts +14 -0
- package/dist/composables/usePillTransition.d.ts +9 -0
- package/dist/composables/useToast.d.ts +39 -0
- package/dist/countries-data-9rgqJWHo.js +203 -0
- package/dist/icons.d.ts +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +17600 -0
- package/dist/layout/registry.d.ts +26 -0
- package/dist/layout/tree.d.ts +75 -0
- package/dist/layout/useLayout.d.ts +39 -0
- package/dist/theme/config.d.ts +16 -0
- package/dist/theme/define.d.ts +12 -0
- package/dist/theme/presets.d.ts +20 -0
- package/dist/theme/tokens.d.ts +72 -0
- package/dist/theme/useTheme.d.ts +36 -0
- package/package.json +93 -0
- package/src/canvas.css +67 -0
- package/src/component-names.ts +65 -0
- package/src/components/LayoutCanvas.vue +76 -0
- package/src/components/LayoutNode.vue +248 -0
- package/src/components/LpAlert.vue +59 -0
- package/src/components/LpAppShell.vue +210 -0
- package/src/components/LpAutocomplete.vue +244 -0
- package/src/components/LpAvatar.vue +67 -0
- package/src/components/LpBadge.vue +32 -0
- package/src/components/LpBreadcrumbs.vue +35 -0
- package/src/components/LpButton.vue +111 -0
- package/src/components/LpCalendar.vue +134 -0
- package/src/components/LpCard.vue +71 -0
- package/src/components/LpCheckbox.vue +44 -0
- package/src/components/LpCodeBlock.vue +298 -0
- package/src/components/LpCommandPalette.vue +323 -0
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpContextMenu.vue +111 -0
- package/src/components/LpDatePicker.vue +117 -0
- package/src/components/LpDisclosure.vue +92 -0
- package/src/components/LpDivider.vue +20 -0
- package/src/components/LpDrawer.vue +402 -0
- package/src/components/LpDropdownMenu.vue +54 -0
- package/src/components/LpEmptyState.vue +21 -0
- package/src/components/LpFormField.vue +31 -0
- package/src/components/LpIcon.vue +49 -0
- package/src/components/LpInfraNode.vue +108 -0
- package/src/components/LpInput.vue +147 -0
- package/src/components/LpLaneNode.vue +48 -0
- package/src/components/LpLink.vue +48 -0
- package/src/components/LpLogViewer.vue +569 -0
- package/src/components/LpModal.vue +123 -0
- package/src/components/LpNotificationBell.vue +238 -0
- package/src/components/LpNumberField.vue +56 -0
- package/src/components/LpOtpInput.vue +81 -0
- package/src/components/LpPagination.vue +165 -0
- package/src/components/LpPasswordInput.vue +50 -0
- package/src/components/LpPhoneInput.vue +241 -0
- package/src/components/LpPopover.vue +42 -0
- package/src/components/LpProgress.vue +30 -0
- package/src/components/LpRadio.vue +28 -0
- package/src/components/LpRadioGroup.vue +43 -0
- package/src/components/LpScrollArea.vue +82 -0
- package/src/components/LpSegmented.vue +83 -0
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpServiceNode.vue +57 -0
- package/src/components/LpSidebar.vue +200 -0
- package/src/components/LpSidebarNav.vue +118 -0
- package/src/components/LpSkeleton.vue +16 -0
- package/src/components/LpSlider.vue +53 -0
- package/src/components/LpStat.vue +90 -0
- package/src/components/LpStepper.vue +46 -0
- package/src/components/LpSwitch.vue +19 -0
- package/src/components/LpTable.vue +252 -0
- package/src/components/LpTableOfContents.vue +183 -0
- package/src/components/LpTabs.vue +120 -0
- package/src/components/LpTextarea.vue +39 -0
- package/src/components/LpThemeSwitcher.vue +168 -0
- package/src/components/LpToaster.vue +216 -0
- package/src/components/LpTooltip.vue +30 -0
- package/src/components/LpTopologyCanvas.vue +370 -0
- package/src/components/LpUptimeBar.vue +126 -0
- package/src/components/codeHighlight.ts +137 -0
- package/src/components/countries-data.ts +207 -0
- package/src/components/countries.ts +67 -0
- package/src/components/dropdown.ts +64 -0
- package/src/components/sidebar.ts +26 -0
- package/src/composables/useInputFilter.ts +89 -0
- package/src/composables/usePillTransition.ts +19 -0
- package/src/composables/useToast.ts +92 -0
- package/src/icons.ts +11 -0
- package/src/index.css +21 -0
- package/src/index.ts +146 -0
- package/src/layout/registry.ts +44 -0
- package/src/layout/tree.ts +288 -0
- package/src/layout/useLayout.ts +134 -0
- package/src/nuxt.ts +22 -0
- package/src/theme/config.ts +48 -0
- package/src/theme/define.ts +50 -0
- package/src/theme/presets.ts +279 -0
- package/src/theme/tokens.ts +151 -0
- package/src/theme/useTheme.ts +288 -0
- package/src/tokens/scrollbar.css +138 -0
- package/src/tokens/tokens.css +333 -0
- package/src/tokens/utilities.css +23 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Theme engine. A theme is a TokenSet (plain JSON); applyTheme() writes it to
|
|
3
|
+
* CSS variables at runtime so every kit component re-skins live. Includes an
|
|
4
|
+
* anti-flash bootstrap (paint the cached theme before mount) and JSON
|
|
5
|
+
* serialize/parse with validation.
|
|
6
|
+
*/
|
|
7
|
+
import { computed, readonly, shallowRef, type ComputedRef, type DeepReadonly, type Ref } from "vue"
|
|
8
|
+
import {
|
|
9
|
+
COLOR_VARS,
|
|
10
|
+
DEFAULT_SURFACE,
|
|
11
|
+
DENSITY_VARS,
|
|
12
|
+
FONT_VARS,
|
|
13
|
+
SHAPE_VARS,
|
|
14
|
+
SURFACE_VARS,
|
|
15
|
+
type ColorTokens,
|
|
16
|
+
type DensityTokens,
|
|
17
|
+
type FontTokens,
|
|
18
|
+
type ShapeTokens,
|
|
19
|
+
type SurfaceTokens,
|
|
20
|
+
type TokenSet,
|
|
21
|
+
} from "./tokens"
|
|
22
|
+
|
|
23
|
+
const CACHE_KEY = "leavepulse-ui-theme"
|
|
24
|
+
|
|
25
|
+
// ── self-managed theme state (singleton) ─────────────────────────────────
|
|
26
|
+
// The kit owns the active theme — like an i18n locale — so every consumer
|
|
27
|
+
// shares one source of truth instead of each wiring its own ref + bootstrap.
|
|
28
|
+
// Module-level (created once per app), reactive, and lazily hydrated from the
|
|
29
|
+
// cache on the client. SSR-safe: hydration only touches localStorage in a
|
|
30
|
+
// browser; on the server `current` stays null until the client mounts.
|
|
31
|
+
const _current = shallowRef<TokenSet | null>(null)
|
|
32
|
+
let _hydrated = false
|
|
33
|
+
|
|
34
|
+
// The fallback used when nothing is cached yet. Consumers can override it via
|
|
35
|
+
// initTheme(fallback); defaults to whatever the first apply() sets.
|
|
36
|
+
let _fallback: TokenSet | null = null
|
|
37
|
+
|
|
38
|
+
function hydrateOnce(): void {
|
|
39
|
+
if (_hydrated || typeof window === "undefined") return
|
|
40
|
+
_hydrated = true
|
|
41
|
+
try {
|
|
42
|
+
const cached = localStorage.getItem(CACHE_KEY)
|
|
43
|
+
if (cached) {
|
|
44
|
+
_current.value = parseTheme(cached)
|
|
45
|
+
applyTheme(_current.value)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
/* ignore — fall through to fallback */
|
|
50
|
+
}
|
|
51
|
+
if (_fallback) {
|
|
52
|
+
_current.value = _fallback
|
|
53
|
+
applyTheme(_fallback)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function applyTheme(theme: TokenSet, root: HTMLElement = document.documentElement): void {
|
|
58
|
+
for (const [key, cssVar] of Object.entries(COLOR_VARS)) {
|
|
59
|
+
root.style.setProperty(cssVar, theme.colors[key as keyof ColorTokens])
|
|
60
|
+
}
|
|
61
|
+
for (const [key, cssVar] of Object.entries(SHAPE_VARS)) {
|
|
62
|
+
root.style.setProperty(cssVar, `${theme.shape[key as keyof ShapeTokens]}px`)
|
|
63
|
+
}
|
|
64
|
+
for (const [key, cssVar] of Object.entries(DENSITY_VARS)) {
|
|
65
|
+
root.style.setProperty(cssVar, `${theme.density[key as keyof DensityTokens]}px`)
|
|
66
|
+
}
|
|
67
|
+
for (const [key, cssVar] of Object.entries(FONT_VARS)) {
|
|
68
|
+
root.style.setProperty(cssVar, theme.font[key as keyof FontTokens])
|
|
69
|
+
}
|
|
70
|
+
// Skin axis is optional; fall back to a flat default so themes saved before
|
|
71
|
+
// it existed still paint correctly.
|
|
72
|
+
const surface = theme.surface ?? DEFAULT_SURFACE
|
|
73
|
+
for (const [key, cssVar] of Object.entries(SURFACE_VARS)) {
|
|
74
|
+
root.style.setProperty(cssVar, surface[key as keyof SurfaceTokens])
|
|
75
|
+
}
|
|
76
|
+
root.dataset.themeMode = theme.mode
|
|
77
|
+
root.style.colorScheme = theme.mode
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Point a circular theme reveal expands from (viewport px). */
|
|
81
|
+
export interface RevealOrigin {
|
|
82
|
+
x: number
|
|
83
|
+
y: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type ViewTransitionDocument = Document & {
|
|
87
|
+
startViewTransition?: (cb: () => void) => { ready: Promise<void>; finished: Promise<void> }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function prefersReducedMotion(): boolean {
|
|
91
|
+
return (
|
|
92
|
+
typeof window !== "undefined" &&
|
|
93
|
+
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* Apply a theme with the "circular reveal" transition — a clip-path circle
|
|
99
|
+
* grows from `origin` (usually the toggle button's centre), repainting the page
|
|
100
|
+
* underneath. Uses the View Transitions API; falls back to a plain applyTheme
|
|
101
|
+
* when it's unsupported or the user prefers reduced motion (so behaviour is
|
|
102
|
+
* identical, just without the animation). The actual token write still happens
|
|
103
|
+
* through applyTheme, so this stays a thin wrapper — no duplicated var logic.
|
|
104
|
+
*/
|
|
105
|
+
export function applyThemeWithTransition(
|
|
106
|
+
theme: TokenSet,
|
|
107
|
+
origin?: RevealOrigin,
|
|
108
|
+
root: HTMLElement = document.documentElement,
|
|
109
|
+
durationMs = 480,
|
|
110
|
+
): void {
|
|
111
|
+
const doc = document as ViewTransitionDocument
|
|
112
|
+
if (typeof doc.startViewTransition !== "function" || prefersReducedMotion()) {
|
|
113
|
+
applyTheme(theme, root)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Centre of the viewport is the default origin if no click point is given.
|
|
118
|
+
const x = origin?.x ?? window.innerWidth / 2
|
|
119
|
+
const y = origin?.y ?? window.innerHeight / 2
|
|
120
|
+
// Radius that reaches the farthest viewport corner from the origin.
|
|
121
|
+
const endRadius = Math.hypot(
|
|
122
|
+
Math.max(x, window.innerWidth - x),
|
|
123
|
+
Math.max(y, window.innerHeight - y),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const transition = doc.startViewTransition(() => applyTheme(theme, root))
|
|
127
|
+
transition.ready.then(() => {
|
|
128
|
+
// Animate the OLD snapshot shrinking to a point, revealing the (already
|
|
129
|
+
// repainted) live page underneath through the growing hole. Clipping the
|
|
130
|
+
// old layer — not growing the new one — avoids a one-frame flash of the new
|
|
131
|
+
// theme before the circle starts, which read as the theme "changing twice".
|
|
132
|
+
root.animate(
|
|
133
|
+
{
|
|
134
|
+
clipPath: [`circle(${endRadius}px at ${x}px ${y}px)`, `circle(0px at ${x}px ${y}px)`],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
duration: durationMs,
|
|
138
|
+
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
|
139
|
+
// Hold the fully-shrunk circle after the keyframes end. Without it the
|
|
140
|
+
// old snapshot snaps back to its default (un-clipped) state for the one
|
|
141
|
+
// frame between animation-end and the browser tearing down the
|
|
142
|
+
// view-transition pseudo-elements — that flash is the old theme
|
|
143
|
+
// "blinking" at the very end.
|
|
144
|
+
fill: "forwards",
|
|
145
|
+
pseudoElement: "::view-transition-old(root)",
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function serializeTheme(theme: TokenSet): string {
|
|
152
|
+
return JSON.stringify(theme)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/*
|
|
156
|
+
* Render a theme as CSS custom-property declarations (the body of a rule, e.g.
|
|
157
|
+
* `--color-brand: #00bcff; …`). Same var mapping as applyTheme, so a server can
|
|
158
|
+
* inline the theme into a <style> for the first paint while the client uses
|
|
159
|
+
* applyTheme — no duplicated values, the TokenSet stays the single source.
|
|
160
|
+
*/
|
|
161
|
+
export function themeToCssVars(theme: TokenSet): string {
|
|
162
|
+
const decls: string[] = []
|
|
163
|
+
for (const [key, cssVar] of Object.entries(COLOR_VARS)) {
|
|
164
|
+
decls.push(`${cssVar}: ${theme.colors[key as keyof ColorTokens]}`)
|
|
165
|
+
}
|
|
166
|
+
for (const [key, cssVar] of Object.entries(SHAPE_VARS)) {
|
|
167
|
+
decls.push(`${cssVar}: ${theme.shape[key as keyof ShapeTokens]}px`)
|
|
168
|
+
}
|
|
169
|
+
for (const [key, cssVar] of Object.entries(DENSITY_VARS)) {
|
|
170
|
+
decls.push(`${cssVar}: ${theme.density[key as keyof DensityTokens]}px`)
|
|
171
|
+
}
|
|
172
|
+
for (const [key, cssVar] of Object.entries(FONT_VARS)) {
|
|
173
|
+
decls.push(`${cssVar}: ${theme.font[key as keyof FontTokens]}`)
|
|
174
|
+
}
|
|
175
|
+
const surface = theme.surface ?? DEFAULT_SURFACE
|
|
176
|
+
for (const [key, cssVar] of Object.entries(SURFACE_VARS)) {
|
|
177
|
+
decls.push(`${cssVar}: ${surface[key as keyof SurfaceTokens]}`)
|
|
178
|
+
}
|
|
179
|
+
return decls.join("; ")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Render a theme as a complete CSS rule (default selector `:root`). */
|
|
183
|
+
export function themeToCssRule(theme: TokenSet, selector = ":root"): string {
|
|
184
|
+
return `${selector} { color-scheme: ${theme.mode}; ${themeToCssVars(theme)}; }`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const DEFAULT_FONT = {
|
|
188
|
+
sans: '"Inter", "Segoe UI", system-ui, sans-serif',
|
|
189
|
+
mono: '"JetBrains Mono", "SF Mono", ui-monospace, monospace',
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Parse + shallow-validate a theme JSON; throws on a malformed shape. */
|
|
193
|
+
export function parseTheme(json: string): TokenSet {
|
|
194
|
+
const raw = JSON.parse(json) as Partial<TokenSet>
|
|
195
|
+
if (!raw || typeof raw !== "object" || !raw.colors || !raw.shape || !raw.density) {
|
|
196
|
+
throw new Error("Invalid theme: missing colors/shape/density")
|
|
197
|
+
}
|
|
198
|
+
// font is a newer axis — fill it in for configs saved before it existed.
|
|
199
|
+
return { ...raw, font: raw.font ?? DEFAULT_FONT } as TokenSet
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── anti-flash bootstrap ─────────────────────────────────────
|
|
203
|
+
// Call once before mount with your default theme; paints the cached theme (if
|
|
204
|
+
// any) synchronously so the first frame already matches the saved look.
|
|
205
|
+
export function bootstrapTheme(fallback: TokenSet): TokenSet {
|
|
206
|
+
let theme = fallback
|
|
207
|
+
try {
|
|
208
|
+
const cached = localStorage.getItem(CACHE_KEY)
|
|
209
|
+
if (cached) theme = parseTheme(cached)
|
|
210
|
+
} catch {
|
|
211
|
+
/* ignore, use fallback */
|
|
212
|
+
}
|
|
213
|
+
applyTheme(theme)
|
|
214
|
+
return theme
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function cacheTheme(theme: TokenSet): void {
|
|
218
|
+
try {
|
|
219
|
+
localStorage.setItem(CACHE_KEY, serializeTheme(theme))
|
|
220
|
+
} catch {
|
|
221
|
+
/* storage unavailable — non-fatal */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface UseTheme {
|
|
226
|
+
/** The active theme, kept in sync by the kit. Null until hydrated (SSR/first
|
|
227
|
+
* paint) — reactive, so a switcher binds straight to it. */
|
|
228
|
+
current: DeepReadonly<Ref<TokenSet | null>>
|
|
229
|
+
/** The active theme's name (""="not yet hydrated"). */
|
|
230
|
+
currentName: ComputedRef<string>
|
|
231
|
+
/** Set + persist a theme (with the circular reveal if `origin`/transition).
|
|
232
|
+
* This is the one call a switcher needs — it updates `current` too. */
|
|
233
|
+
setTheme: (theme: TokenSet, origin?: RevealOrigin) => void
|
|
234
|
+
/** Seed the fallback (used when nothing is cached) and hydrate now. Call once
|
|
235
|
+
* at app start (client) — optional; the first useTheme() hydrates anyway. */
|
|
236
|
+
init: (fallback: TokenSet) => TokenSet
|
|
237
|
+
apply: (theme: TokenSet) => void
|
|
238
|
+
applyWithTransition: (theme: TokenSet, origin?: RevealOrigin) => void
|
|
239
|
+
serialize: typeof serializeTheme
|
|
240
|
+
parse: typeof parseTheme
|
|
241
|
+
bootstrap: typeof bootstrapTheme
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function useTheme(): UseTheme {
|
|
245
|
+
// Lazily hydrate the singleton the first time anyone asks (client only).
|
|
246
|
+
hydrateOnce()
|
|
247
|
+
|
|
248
|
+
// The single "set a theme" path the kit owns: apply, cache, and update the
|
|
249
|
+
// shared reactive state so every consumer + switcher reflects it.
|
|
250
|
+
const setTheme = (theme: TokenSet, origin?: RevealOrigin) => {
|
|
251
|
+
if (origin) applyThemeWithTransition(theme, origin)
|
|
252
|
+
else applyThemeWithTransition(theme)
|
|
253
|
+
cacheTheme(theme)
|
|
254
|
+
_current.value = theme
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
current: readonly(_current),
|
|
259
|
+
currentName: computed(() => _current.value?.name ?? ""),
|
|
260
|
+
setTheme,
|
|
261
|
+
init: (fallback: TokenSet) => {
|
|
262
|
+
_fallback = fallback
|
|
263
|
+
if (!_hydrated) hydrateOnce()
|
|
264
|
+
if (!_current.value) {
|
|
265
|
+
_current.value = bootstrapTheme(fallback)
|
|
266
|
+
}
|
|
267
|
+
return _current.value
|
|
268
|
+
},
|
|
269
|
+
apply: (theme: TokenSet) => {
|
|
270
|
+
applyTheme(theme)
|
|
271
|
+
cacheTheme(theme)
|
|
272
|
+
_current.value = theme
|
|
273
|
+
},
|
|
274
|
+
/**
|
|
275
|
+
* Apply + cache a theme with the circular reveal animation. Pass the click
|
|
276
|
+
* point (e.g. from a toggle's `event`) as `origin` so the circle grows from
|
|
277
|
+
* the button; omit it to reveal from the viewport centre.
|
|
278
|
+
*/
|
|
279
|
+
applyWithTransition: (theme: TokenSet, origin?: RevealOrigin) => {
|
|
280
|
+
applyThemeWithTransition(theme, origin)
|
|
281
|
+
cacheTheme(theme)
|
|
282
|
+
_current.value = theme
|
|
283
|
+
},
|
|
284
|
+
serialize: serializeTheme,
|
|
285
|
+
parse: parseTheme,
|
|
286
|
+
bootstrap: bootstrapTheme,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Native scrollbar skin — opt-in. Styles the browser's OWN scrollbars (the
|
|
3
|
+
* window scroll and any `overflow:auto/scroll` element) to match LpScrollArea:
|
|
4
|
+
* a thin, rounded, brand-tinted thumb on a transparent track. Use this when you
|
|
5
|
+
* want the kit look on scroll that LpScrollArea can't own — the page itself,
|
|
6
|
+
* sticky panels, code blocks — without wrapping everything in a component.
|
|
7
|
+
*
|
|
8
|
+
* It does NOT replace the native scroll mechanism (no container, no JS), so
|
|
9
|
+
* sticky positioning, scroll restoration, anchor jumps and IntersectionObserver
|
|
10
|
+
* keep working. It's purely cosmetic.
|
|
11
|
+
*
|
|
12
|
+
* Reads --color-brand (kit token), so import it AFTER the tokens:
|
|
13
|
+
* @import "@leavepulse/ui";
|
|
14
|
+
* @import "@leavepulse/ui/scrollbar";
|
|
15
|
+
*
|
|
16
|
+
* Falls back to a neutral tint if --color-brand is absent.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
:root {
|
|
20
|
+
--lp-scrollbar-size: 10px;
|
|
21
|
+
--lp-scrollbar-thumb: color-mix(
|
|
22
|
+
in oklab,
|
|
23
|
+
var(--color-brand, #7a8a84) 40%,
|
|
24
|
+
transparent
|
|
25
|
+
);
|
|
26
|
+
--lp-scrollbar-thumb-hover: var(--color-brand, #c7d2cc);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Firefox — Chrome ignores ::-webkit-* once scrollbar-width is set, so scope it. */
|
|
30
|
+
@supports (-moz-appearance: none) {
|
|
31
|
+
html {
|
|
32
|
+
scrollbar-width: thin;
|
|
33
|
+
scrollbar-color: var(--lp-scrollbar-thumb) transparent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
*::-webkit-scrollbar {
|
|
38
|
+
width: var(--lp-scrollbar-size);
|
|
39
|
+
height: var(--lp-scrollbar-size);
|
|
40
|
+
background: transparent;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
*::-webkit-scrollbar-track,
|
|
44
|
+
*::-webkit-scrollbar-track-piece {
|
|
45
|
+
background: transparent;
|
|
46
|
+
border: none;
|
|
47
|
+
box-shadow: none;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
*::-webkit-scrollbar-thumb {
|
|
51
|
+
background-color: var(--lp-scrollbar-thumb);
|
|
52
|
+
border-radius: 9999px;
|
|
53
|
+
/* Transparent border + padding-box clip = a slim thumb floating in a wider
|
|
54
|
+
hit area, the way LpScrollArea's overlay bar reads. */
|
|
55
|
+
border: 3px solid transparent;
|
|
56
|
+
background-clip: padding-box;
|
|
57
|
+
min-height: 40px;
|
|
58
|
+
transition: background-color var(--duration-fast, 120ms) ease;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
*::-webkit-scrollbar-thumb:hover {
|
|
62
|
+
background-color: var(--lp-scrollbar-thumb-hover);
|
|
63
|
+
background-clip: padding-box;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
*::-webkit-scrollbar-corner {
|
|
67
|
+
background: transparent;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
*::-webkit-scrollbar-button {
|
|
71
|
+
display: none;
|
|
72
|
+
width: 0;
|
|
73
|
+
height: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Chrome reads the root scrollbar from body, not html/*, so repeat for body. */
|
|
77
|
+
body::-webkit-scrollbar {
|
|
78
|
+
width: var(--lp-scrollbar-size);
|
|
79
|
+
height: var(--lp-scrollbar-size);
|
|
80
|
+
background: transparent;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
body::-webkit-scrollbar-track,
|
|
84
|
+
body::-webkit-scrollbar-track-piece {
|
|
85
|
+
background: transparent;
|
|
86
|
+
border: none;
|
|
87
|
+
box-shadow: none;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
body::-webkit-scrollbar-thumb {
|
|
91
|
+
background-color: var(--lp-scrollbar-thumb);
|
|
92
|
+
border-radius: 9999px;
|
|
93
|
+
border: 3px solid transparent;
|
|
94
|
+
background-clip: padding-box;
|
|
95
|
+
min-height: 40px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
body::-webkit-scrollbar-thumb:hover {
|
|
99
|
+
background-color: var(--lp-scrollbar-thumb-hover);
|
|
100
|
+
background-clip: padding-box;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
body::-webkit-scrollbar-corner {
|
|
104
|
+
background: transparent;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
body::-webkit-scrollbar-button {
|
|
108
|
+
display: none;
|
|
109
|
+
width: 0;
|
|
110
|
+
height: 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/*
|
|
114
|
+
* Per-element opt-out (hide the bar entirely — carousels, the CodeBlock editor,
|
|
115
|
+
* etc.) lives in utilities.css as `.lp-scrollbar-none`, shipped with the main
|
|
116
|
+
* import so it's available even without this opt-in skin.
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/*
|
|
120
|
+
* GLOBAL opt-in: drop the native scrollbar from the layout flow site-wide so it
|
|
121
|
+
* never reserves viewport width (the page still scrolls — bar is just hidden).
|
|
122
|
+
* Add the class to <html> at the app root:
|
|
123
|
+
* <html class="lp-scroll-hide">
|
|
124
|
+
* Pair with LpScrollArea (or the skin above) for any region that should still
|
|
125
|
+
* show a bar. This only hides the document's own bar; nested overflow elements
|
|
126
|
+
* keep theirs unless they also carry .lp-scrollbar-none.
|
|
127
|
+
*/
|
|
128
|
+
html.lp-scroll-hide {
|
|
129
|
+
scrollbar-width: none;
|
|
130
|
+
-ms-overflow-style: none;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
html.lp-scroll-hide::-webkit-scrollbar,
|
|
134
|
+
html.lp-scroll-hide body::-webkit-scrollbar {
|
|
135
|
+
display: none;
|
|
136
|
+
width: 0;
|
|
137
|
+
height: 0;
|
|
138
|
+
}
|