@leavepulse/ui 0.29.5 → 0.30.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 (39) hide show
  1. package/dist/chunks/LpActionBar-BlvtGB2P.js +173 -0
  2. package/dist/chunks/{LpAppShell-BJk3b-u4.js → LpAppShell-DnocEJAu.js} +1 -1
  3. package/dist/chunks/{LpCodeBlock-rS_6mO3D.js → LpCodeBlock-BWX1KZwk.js} +7 -13
  4. package/dist/chunks/LpColorPicker-CFC6iaNK.js +491 -0
  5. package/dist/chunks/{LpDisclosure-BnwP4Q-b.js → LpDisclosure-C1_YkMrd.js} +8 -2
  6. package/dist/chunks/{LpLogViewer-CVaGfTF_.js → LpLogViewer-TSRyt3t9.js} +3 -6
  7. package/dist/chunks/{LpSidebar-C_Mr-JPI.js → LpSidebar-CWSbczWi.js} +11 -2
  8. package/dist/component-names.d.ts +1 -1
  9. package/dist/components/LpActionBar.vue.d.ts +84 -0
  10. package/dist/components/LpActionBar.vue.js +4 -0
  11. package/dist/components/LpAppShell.vue.js +1 -1
  12. package/dist/components/LpCodeBlock.vue.js +1 -1
  13. package/dist/components/LpColorPicker.vue.d.ts +124 -0
  14. package/dist/components/LpColorPicker.vue.js +4 -0
  15. package/dist/components/LpDisclosure.vue.js +1 -1
  16. package/dist/components/LpLogViewer.vue.js +1 -1
  17. package/dist/components/LpNotificationBell.vue.d.ts +2 -2
  18. package/dist/components/LpSidebar.vue.d.ts +21 -0
  19. package/dist/components/LpSidebar.vue.js +1 -1
  20. package/dist/components/actionBar.d.ts +19 -0
  21. package/dist/components/actionBar.js +31 -0
  22. package/dist/components/color.d.ts +65 -0
  23. package/dist/components/color.js +158 -0
  24. package/dist/composables/useClipboard.d.ts +46 -0
  25. package/dist/composables/useClipboard.js +28 -0
  26. package/dist/index.d.ts +8 -0
  27. package/dist/index.js +145 -126
  28. package/package.json +1 -1
  29. package/src/component-names.ts +2 -0
  30. package/src/components/LpActionBar.vue +276 -0
  31. package/src/components/LpCodeBlock.vue +5 -10
  32. package/src/components/LpColorPicker.vue +784 -0
  33. package/src/components/LpDisclosure.vue +17 -2
  34. package/src/components/LpLogViewer.vue +5 -8
  35. package/src/components/LpSidebar.vue +42 -6
  36. package/src/components/actionBar.ts +73 -0
  37. package/src/components/color.ts +257 -0
  38. package/src/composables/useClipboard.ts +107 -0
  39. package/src/index.ts +21 -0
@@ -9,7 +9,7 @@
9
9
  * shown content isn't left below the fold.
10
10
  */
11
11
  import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from "reka-ui"
12
- import { type ComponentPublicInstance, nextTick, ref } from "vue"
12
+ import { type ComponentPublicInstance, computed, getCurrentInstance, nextTick, ref } from "vue"
13
13
  import LpIcon from "./LpIcon.vue"
14
14
 
15
15
  const props = withDefaults(
@@ -33,6 +33,21 @@ const props = withDefaults(
33
33
 
34
34
  const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()
35
35
 
36
+ /*
37
+ * Vue casts an absent Boolean prop to `false`, so `open` alone cannot say whether
38
+ * the caller controls this block. Passing that `false` straight to reka pins the
39
+ * block shut: it reads a controlled-closed collapsible, and the trigger stops
40
+ * doing anything at all — `defaultOpen` never gets a say. So control is decided
41
+ * by whether the attribute was actually handed to us, and the uncontrolled case
42
+ * passes `undefined`, which is what reka checks for.
43
+ */
44
+ const instance = getCurrentInstance()
45
+ const controlled = computed(() => {
46
+ const raw = instance?.vnode.props
47
+ return Boolean(raw && ("open" in raw || "onUpdate:open" in raw))
48
+ })
49
+ const rootOpen = computed(() => (controlled.value ? props.open : undefined))
50
+
36
51
  // reka's CollapsibleRoot renders a real element but exposes it as a component
37
52
  // instance, so reach through $el for the DOM node we scroll into view.
38
53
  const root = ref<ComponentPublicInstance | null>(null)
@@ -55,7 +70,7 @@ function onToggle(value: boolean) {
55
70
  <template>
56
71
  <CollapsibleRoot
57
72
  ref="root"
58
- :open="open"
73
+ :open="rootOpen"
59
74
  :default-open="defaultOpen"
60
75
  class="overflow-hidden rounded-control border border-line bg-surface-soft"
61
76
  @update:open="onToggle"
@@ -18,6 +18,7 @@ import {
18
18
  watch,
19
19
  } from "vue"
20
20
  import { prefersReducedMotion } from "../composables/easing"
21
+ import { useClipboard } from "../composables/useClipboard"
21
22
  import { useHotkeys } from "../composables/useHotkeys"
22
23
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
23
24
  import LpIcon from "./LpIcon.vue"
@@ -344,14 +345,10 @@ function fmtTime(time?: number | string): string {
344
345
  }
345
346
 
346
347
  // ── row context menu ─────────────────────────────────────────────────
347
- async function copy(text: string) {
348
- try {
349
- await navigator.clipboard?.writeText(text)
350
- } catch {
351
- // Clipboard can reject (insecure context / denied permission); swallow —
352
- // the menu action is best-effort and shouldn't throw into the UI.
353
- }
354
- }
348
+ // A menu item closes the menu as it runs, leaving nothing on screen to say the
349
+ // copy happened — so unlike LpCodeBlock's button, this one announces itself.
350
+ const { copy: writeClipboard } = useClipboard()
351
+ const copy = (text: string) => writeClipboard(text, { toast: "Copied to clipboard" })
355
352
 
356
353
  // The whole line as one copyable string: "time LEVEL [source] message".
357
354
  function lineText(line: LogLine): string {
@@ -56,6 +56,26 @@ const props = withDefaults(
56
56
  * flush, header-less look.
57
57
  */
58
58
  divider?: boolean
59
+ /**
60
+ * Where this nav lives.
61
+ *
62
+ * `rail` is the app shell's edge: one hairline on the inner side, square
63
+ * corners, the raised shell background.
64
+ *
65
+ * `panel` is a nav that sits inside a page next to the content it drives —
66
+ * a settings index, a table picker. It is a card: boxed on all four sides
67
+ * and rounded. Without it the rail's single border reads as a stray line
68
+ * down the middle of the page, and a consumer cannot cancel it with a class
69
+ * of their own.
70
+ */
71
+ variant?: "rail" | "panel"
72
+ /**
73
+ * Rail width. A plain `class` cannot set it — the component's own `w-60`
74
+ * wins, and the kit does not merge conflicting Tailwind classes — so the
75
+ * width is a prop. Any CSS length; a page-embedded nav usually wants to
76
+ * match the column it lives in.
77
+ */
78
+ width?: string
59
79
  /**
60
80
  * Which collapsible groups are folded away (v-model:collapsed). Leave it
61
81
  * unbound to let the sidebar fold long navs on its own — see
@@ -82,6 +102,7 @@ const props = withDefaults(
82
102
  responsive: false,
83
103
  mobileBreakpoint: "md",
84
104
  divider: true,
105
+ variant: "rail",
85
106
  autoCollapseAfter: 14,
86
107
  },
87
108
  )
@@ -170,6 +191,15 @@ const railClass = computed(() =>
170
191
  props.responsive ? RAIL_VISIBILITY[props.mobileBreakpoint] : "flex",
171
192
  )
172
193
 
194
+ // Chrome is per variant, not per consumer: a `class` cannot cancel a border the
195
+ // component already put on (the kit does not merge conflicting Tailwind classes),
196
+ // so the two looks are spelled out here instead.
197
+ const chromeClass = computed(() =>
198
+ props.variant === "panel"
199
+ ? "rounded-card border border-line bg-surface-raised"
200
+ : "border-r border-line bg-surface-raised",
201
+ )
202
+
173
203
  // This component has a fragment root (the desktop <nav> + the mobile drawer), so
174
204
  // Vue can't auto-inherit a consumer-passed `class`. Take attrs over manually and
175
205
  // land them on the desktop rail (where a layout class like `shrink-0` belongs).
@@ -179,17 +209,23 @@ defineOptions({ inheritAttrs: false })
179
209
  <template>
180
210
  <!-- Desktop / always-on rail -->
181
211
  <nav
182
- class="h-full w-60 flex-col gap-1 border-r border-line bg-surface-raised p-3"
183
- :class="railClass"
212
+ class="h-full flex-col gap-1 p-3"
213
+ :class="[railClass, chromeClass, width ? '' : 'w-60']"
214
+ :style="width ? { width } : undefined"
184
215
  aria-label="Sidebar"
185
216
  v-bind="$attrs"
186
217
  >
187
- <!-- Edge-to-edge (negative margins cancel the rail p-3) and h-16 to match the
188
- app header, so its bottom hairline continues the header's border-b. -->
218
+ <!-- Rail: edge-to-edge (negative margins cancel the p-3) and h-16 to match
219
+ the app header, so its bottom hairline continues the header's border-b.
220
+ Panel: kept inside the padding — bleeding to the edge would cut across
221
+ the rounded corners. -->
189
222
  <div
190
223
  v-if="$slots.header"
191
- class="-mx-3 -mt-3 mb-3 flex h-16 shrink-0 items-center px-3"
192
- :class="divider ? 'border-b border-line' : ''"
224
+ class="mb-3 flex shrink-0 items-center"
225
+ :class="[
226
+ variant === 'panel' ? 'pb-3' : '-mx-3 -mt-3 h-16 px-3',
227
+ divider ? 'border-b border-line' : '',
228
+ ]"
193
229
  >
194
230
  <slot name="header" />
195
231
  </div>
@@ -0,0 +1,73 @@
1
+ import { onScopeDispose, ref } from "vue"
2
+
3
+ /*
4
+ * Shared stack for LpActionBar.
5
+ *
6
+ * Two bars are not a contrived case: a cookie notice sits on a first visit
7
+ * while the page underneath is a form, and the moment that form is edited the
8
+ * unsaved-changes bar wants the same strip of screen. Both are fixed to the
9
+ * bottom, both are translucent, so they landed on top of each other and the
10
+ * text of one showed through the other.
11
+ *
12
+ * Each open bar registers here and gets back a live index. The bar offsets
13
+ * itself by the heights of everything below it, so they stack upwards in the
14
+ * order they opened — the oldest at the bottom, the newest above it, which is
15
+ * also the order a person met them in.
16
+ *
17
+ * The registry is pinned to a global symbol for the same reason the toast queue
18
+ * is: a barrel import and a deep import can resolve to two module instances,
19
+ * and two registries would be no registry at all.
20
+ */
21
+
22
+ export interface BarEntry {
23
+ id: number
24
+ /** Measured height in px, reported by the bar once it is on screen. */
25
+ height: number
26
+ }
27
+
28
+ const STATE_KEY = Symbol.for("@leavepulse/ui:action-bar-stack")
29
+ type GlobalWithStack = typeof globalThis & {
30
+ [STATE_KEY]?: { bars: ReturnType<typeof ref<BarEntry[]>>; nextId: number }
31
+ }
32
+ const g = globalThis as GlobalWithStack
33
+ const state = (g[STATE_KEY] ??= { bars: ref<BarEntry[]>([]), nextId: 0 })
34
+
35
+ /** Gap between stacked bars, matching the padding the bar sits in. */
36
+ export const STACK_GAP = 8
37
+
38
+ /**
39
+ * Registers a bar for as long as the calling scope lives. `setHeight` reports
40
+ * the measured height; `offset()` returns how far up this bar should sit —
41
+ * the total height of every bar that opened before it.
42
+ */
43
+ export function useBarStack() {
44
+ const id = state.nextId++
45
+ const bars = state.bars as ReturnType<typeof ref<BarEntry[]>>
46
+
47
+ function register() {
48
+ if (!bars.value!.some((b) => b.id === id)) {
49
+ bars.value = [...bars.value!, { id, height: 0 }]
50
+ }
51
+ }
52
+
53
+ function unregister() {
54
+ bars.value = bars.value!.filter((b) => b.id !== id)
55
+ }
56
+
57
+ function setHeight(height: number) {
58
+ bars.value = bars.value!.map((b) => (b.id === id ? { ...b, height } : b))
59
+ }
60
+
61
+ /** Sum of the heights below this one, plus a gap for each. */
62
+ function offset(): number {
63
+ const i = bars.value!.findIndex((b) => b.id === id)
64
+ if (i <= 0) return 0
65
+ return bars.value!
66
+ .slice(0, i)
67
+ .reduce((total, b) => total + b.height + STACK_GAP, 0)
68
+ }
69
+
70
+ onScopeDispose(unregister)
71
+
72
+ return { register, unregister, setHeight, offset, bars }
73
+ }
@@ -0,0 +1,257 @@
1
+ /*
2
+ * Colour conversion for LpColorPicker — kept out of the component so the maths
3
+ * is testable on its own and reusable by anything that has to meet a colour
4
+ * halfway (a swatch list, a theme editor, a chart legend).
5
+ *
6
+ * The picker works in HSV, not HSL: a saturation/value rectangle under a hue
7
+ * rail is the shape everybody already knows from design tools, and it is the
8
+ * one where dragging in a straight line does what the eye expects. HSL's
9
+ * lightness axis collapses to white and black at both ends of the rectangle,
10
+ * which wastes half the area on colours nobody is aiming for.
11
+ *
12
+ * Alpha travels alongside as 0..1 and is dropped from the output when it is
13
+ * fully opaque — "#3b82f6" rather than "#3b82f6ff", since the short form is
14
+ * what a person pastes into a stylesheet.
15
+ */
16
+
17
+ /** Hue 0..360, saturation/value 0..1, alpha 0..1. */
18
+ export interface Hsv {
19
+ h: number
20
+ s: number
21
+ v: number
22
+ a: number
23
+ }
24
+
25
+ /** Channels 0..255, alpha 0..1. */
26
+ export interface Rgb {
27
+ r: number
28
+ g: number
29
+ b: number
30
+ a: number
31
+ }
32
+
33
+ /** The string shapes a caller can hand the picker, and ask back out of it. */
34
+ export type ColorFormat = "hex" | "rgb" | "hsl"
35
+
36
+ const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n))
37
+
38
+ /** Wraps rather than clamps: hue is a circle, and 370° is 10°, not 360°. */
39
+ export function normalizeHue(h: number): number {
40
+ const wrapped = h % 360
41
+ return wrapped < 0 ? wrapped + 360 : wrapped
42
+ }
43
+
44
+ export function clampHsv(c: Hsv): Hsv {
45
+ return {
46
+ h: normalizeHue(c.h),
47
+ s: clamp(c.s, 0, 1),
48
+ v: clamp(c.v, 0, 1),
49
+ a: clamp(c.a, 0, 1),
50
+ }
51
+ }
52
+
53
+ export function hsvToRgb({ h, s, v, a }: Hsv): Rgb {
54
+ const hue = normalizeHue(h) / 60
55
+ const c = v * s
56
+ const x = c * (1 - Math.abs((hue % 2) - 1))
57
+ const m = v - c
58
+ // Sextant of the colour wheel; each one puts c/x/0 in a different order.
59
+ const [r, g, b] = (
60
+ [
61
+ [c, x, 0],
62
+ [x, c, 0],
63
+ [0, c, x],
64
+ [0, x, c],
65
+ [x, 0, c],
66
+ [c, 0, x],
67
+ ] as const
68
+ )[Math.floor(hue) % 6]
69
+ return {
70
+ r: Math.round((r + m) * 255),
71
+ g: Math.round((g + m) * 255),
72
+ b: Math.round((b + m) * 255),
73
+ a: clamp(a, 0, 1),
74
+ }
75
+ }
76
+
77
+ export function rgbToHsv({ r, g, b, a }: Rgb): Hsv {
78
+ const rn = r / 255
79
+ const gn = g / 255
80
+ const bn = b / 255
81
+ const max = Math.max(rn, gn, bn)
82
+ const min = Math.min(rn, gn, bn)
83
+ const d = max - min
84
+
85
+ let h = 0
86
+ if (d !== 0) {
87
+ if (max === rn) h = 60 * (((gn - bn) / d) % 6)
88
+ else if (max === gn) h = 60 * ((bn - rn) / d + 2)
89
+ else h = 60 * ((rn - gn) / d + 4)
90
+ }
91
+
92
+ return {
93
+ h: normalizeHue(h),
94
+ s: max === 0 ? 0 : d / max,
95
+ v: max,
96
+ a: clamp(a, 0, 1),
97
+ }
98
+ }
99
+
100
+ const hex2 = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0")
101
+
102
+ /** "#rrggbb", or "#rrggbbaa" when the colour is not fully opaque. */
103
+ export function rgbToHex({ r, g, b, a }: Rgb): string {
104
+ const base = `#${hex2(r)}${hex2(g)}${hex2(b)}`
105
+ return a >= 1 ? base : base + hex2(a * 255)
106
+ }
107
+
108
+ /**
109
+ * Parses #rgb / #rgba / #rrggbb / #rrggbbaa, with or without the leading `#`.
110
+ * Returns null for anything it doesn't recognise, so a half-typed value in the
111
+ * hex field can be left alone instead of snapping the picker to black.
112
+ */
113
+ export function parseHex(input: string): Rgb | null {
114
+ const s = input.trim().replace(/^#/, "")
115
+ if (!/^[0-9a-f]+$/i.test(s)) return null
116
+
117
+ // The 3/4-digit forms double each digit: "f80" is "ff8800".
118
+ const expand = (hex: string) =>
119
+ hex.length <= 4 ? hex.split("").map((ch) => ch + ch) : (hex.match(/.{2}/g) ?? [])
120
+
121
+ const parts = expand(s)
122
+ if (s.length !== 3 && s.length !== 4 && s.length !== 6 && s.length !== 8) return null
123
+
124
+ const [r, g, b, alpha] = parts.map((p) => parseInt(p, 16))
125
+ return { r, g, b, a: alpha === undefined ? 1 : alpha / 255 }
126
+ }
127
+
128
+ /**
129
+ * Parses a colour a person might paste: hex (3/4/6/8 digits, `#` optional),
130
+ * rgb()/rgba() and hsl()/hsla() in both the comma and the space/slash forms,
131
+ * percentages, hue in deg/turn/rad/grad, brackets optional, and a bare triple
132
+ * of numbers read as rgb. A round-trip through a caller's storage therefore
133
+ * comes back the colour it went in as, whatever shape it was stored in.
134
+ *
135
+ * Named colours ("rebeccapurple") and the newer spaces (lab, oklch, color())
136
+ * are out of scope: resolving those needs the browser's own parser, and a
137
+ * component that silently handled some and not others would be worse than one
138
+ * that is clear about what it takes.
139
+ */
140
+ export function parseColor(input: string): Rgb | null {
141
+ const s = input.trim()
142
+ if (!s) return null
143
+ if (s.startsWith("#") || /^[0-9a-f]{3,8}$/i.test(s)) return parseHex(s)
144
+
145
+ // Brackets optional, and a bare triple of numbers is taken as rgb. What gets
146
+ // pasted into a colour field is rarely canonical CSS: it comes off a design
147
+ // tool as "rgb 59 130 246", out of a spreadsheet as "59, 130, 246", or with
148
+ // the closing bracket lost to a sloppy selection. All of them name a colour
149
+ // unambiguously, and refusing them would be pedantry at the user's expense.
150
+ const fn = s.match(/^(rgba?|hsla?)\s*\(?([^)]*)\)?$/i) ?? (
151
+ /^[\d.,\s/%]+$/.test(s) ? ([null, "rgb", s] as unknown as RegExpMatchArray) : null
152
+ )
153
+ if (!fn) return null
154
+
155
+ // Both the legacy comma form and the modern space form, with an optional
156
+ // "/ alpha" — the two shapes browsers themselves accept.
157
+ const parts = fn[2].split(/[\s,/]+/).filter(Boolean)
158
+ if (parts.length < 3) return null
159
+
160
+ // `deg` is the default unit and `turn`/`rad`/`grad` are the others CSS allows
161
+ // for a hue; parseFloat drops the suffix, so only turn/rad/grad need scaling.
162
+ const num = (t: string) => parseFloat(t)
163
+ const angle = (t: string) => {
164
+ const n = parseFloat(t)
165
+ if (/turn$/i.test(t)) return n * 360
166
+ if (/rad$/i.test(t)) return (n * 180) / Math.PI
167
+ if (/grad$/i.test(t)) return n * 0.9
168
+ return n
169
+ }
170
+ const alpha = parts[3] === undefined ? 1 : parts[3].endsWith("%") ? num(parts[3]) / 100 : num(parts[3])
171
+ if (Number.isNaN(alpha)) return null
172
+
173
+ if (fn[1].toLowerCase().startsWith("rgb")) {
174
+ const [r, g, b] = parts.slice(0, 3).map((t) => (t.endsWith("%") ? (num(t) / 100) * 255 : num(t)))
175
+ if ([r, g, b].some(Number.isNaN)) return null
176
+ return { r: Math.round(clamp(r, 0, 255)), g: Math.round(clamp(g, 0, 255)), b: Math.round(clamp(b, 0, 255)), a: clamp(alpha, 0, 1) }
177
+ }
178
+
179
+ const h = angle(parts[0])
180
+ const sl = num(parts[1]) / 100
181
+ const l = num(parts[2]) / 100
182
+ if ([h, sl, l].some(Number.isNaN)) return null
183
+ return hslToRgb(h, clamp(sl, 0, 1), clamp(l, 0, 1), clamp(alpha, 0, 1))
184
+ }
185
+
186
+ function hslToRgb(h: number, s: number, l: number, a: number): Rgb {
187
+ // HSL→HSV, then reuse the one conversion that already exists.
188
+ const v = l + s * Math.min(l, 1 - l)
189
+ return hsvToRgb({ h, s: v === 0 ? 0 : 2 * (1 - l / v), v, a })
190
+ }
191
+
192
+ /** HSV→HSL, for the hsl() output format. Saturation/lightness come back 0..1. */
193
+ export function hsvToHsl({ h, s, v }: Hsv): { h: number; s: number; l: number } {
194
+ const l = v * (1 - s / 2)
195
+ const denom = Math.min(l, 1 - l)
196
+ return { h: normalizeHue(h), s: denom === 0 ? 0 : (v - l) / denom, l }
197
+ }
198
+
199
+ /** The colour as a CSS string in the requested format. */
200
+ export function formatColor(c: Hsv, format: ColorFormat): string {
201
+ const rgb = hsvToRgb(c)
202
+ const a = Math.round(clamp(c.a, 0, 1) * 100) / 100
203
+
204
+ if (format === "hex") return rgbToHex(rgb)
205
+ if (format === "rgb") {
206
+ return a >= 1
207
+ ? `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`
208
+ : `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${a})`
209
+ }
210
+
211
+ const { h, s, l } = hsvToHsl(c)
212
+ const hh = Math.round(h)
213
+ const ss = Math.round(s * 100)
214
+ const ll = Math.round(l * 100)
215
+ return a >= 1 ? `hsl(${hh}, ${ss}%, ${ll}%)` : `hsla(${hh}, ${ss}%, ${ll}%, ${a})`
216
+ }
217
+
218
+ /** Always opaque — for the swatch behind an alpha checkerboard, and for rails. */
219
+ export function opaqueCss(c: Hsv): string {
220
+ const { r, g, b } = hsvToRgb({ ...c, a: 1 })
221
+ return `rgb(${r}, ${g}, ${b})`
222
+ }
223
+
224
+ /**
225
+ * Whether dark ink reads better than light on this colour, by WCAG relative
226
+ * luminance. The picker uses it for the check mark on a selected swatch, which
227
+ * otherwise disappears into a pale preset.
228
+ */
229
+ export function isLight(c: Hsv): boolean {
230
+ const { r, g, b } = hsvToRgb(c)
231
+ const lin = (n: number) => {
232
+ const x = n / 255
233
+ return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
234
+ }
235
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) > 0.35
236
+ }
237
+
238
+ /**
239
+ * A neutral starting palette. Kit tokens are deliberately NOT used here: these
240
+ * are values a user picks *for their own data* (a tag, a calendar category),
241
+ * which must stay put when the app's theme changes — a preset that shifted with
242
+ * the theme would silently restyle everything already saved with it.
243
+ */
244
+ export const DEFAULT_SWATCHES = [
245
+ "#ef4444",
246
+ "#f97316",
247
+ "#f59e0b",
248
+ "#22c55e",
249
+ "#10b981",
250
+ "#06b6d4",
251
+ "#3b82f6",
252
+ "#6366f1",
253
+ "#8b5cf6",
254
+ "#ec4899",
255
+ "#64748b",
256
+ "#111827",
257
+ ] as const
@@ -0,0 +1,107 @@
1
+ import { onScopeDispose, ref } from "vue"
2
+ import { useToast } from "./useToast"
3
+
4
+ /*
5
+ * "Copy this text, tell the person it worked, and don't throw if it didn't."
6
+ *
7
+ * Four components had grown their own version of this — LpCodeBlock with a
8
+ * `copied` flag on a 1400ms timer, LpLogViewer with a silent swallow, the
9
+ * colour picker with another — each wrapping `navigator.clipboard.writeText`
10
+ * in a try/catch and each disagreeing about what the user should see. Copying
11
+ * is never the task a person came to do; it is a step inside one, so the thing
12
+ * that matters is the answer arriving and nothing blowing up on the way.
13
+ *
14
+ * Why every call is allowed to fail: the Clipboard API needs a secure context
15
+ * and, in some browsers, a permission. It is absent over plain http, rejects
16
+ * outside a user gesture, and is missing entirely in a non-DOM test runner. A
17
+ * copy that cannot happen is a disappointment, not an exception, and a rejected
18
+ * promise reaching a component means an unhandled rejection in the console of
19
+ * an app that did nothing wrong.
20
+ *
21
+ * The `copied` flag is here because the other half of the feedback is the
22
+ * button itself: a tick that replaces the copy icon for a moment reads faster
23
+ * than any message, and the two are complementary rather than alternatives.
24
+ */
25
+
26
+ /** How long `copied` stays true — long enough to read, short enough to not linger. */
27
+ const COPIED_MS = 1400
28
+
29
+ export interface CopyOptions {
30
+ /**
31
+ * Toast to raise on success. `false` (the default) stays silent, for callers
32
+ * whose button already shows the tick and would otherwise say it twice.
33
+ */
34
+ toast?: string | false
35
+ /**
36
+ * Toast to raise when the copy fails. Defaults to silence for the same
37
+ * reason the kit's other best-effort paths are silent: a copy that didn't
38
+ * happen is usually obvious, and a scary red box over a blocked clipboard
39
+ * helps nobody. Pass a string where losing the copy actually costs something.
40
+ */
41
+ errorToast?: string | false
42
+ }
43
+
44
+ export interface UseClipboard {
45
+ /**
46
+ * True for a moment after a successful copy. Bind it to swap a copy icon for
47
+ * a tick:
48
+ *
49
+ * ```vue
50
+ * <LpIcon :name="copied ? 'lucide:check' : 'lucide:copy'" />
51
+ * ```
52
+ */
53
+ copied: import("vue").Ref<boolean>
54
+ /**
55
+ * Writes `text` to the clipboard. Resolves true on success, false when the
56
+ * clipboard was unavailable or refused — it never rejects, so `await copy(x)`
57
+ * is safe to call bare from an event handler.
58
+ */
59
+ copy: (text: string, options?: CopyOptions) => Promise<boolean>
60
+ }
61
+
62
+ /**
63
+ * Clipboard writes with optional toast feedback.
64
+ *
65
+ * ```ts
66
+ * const { copy, copied } = useClipboard()
67
+ * copy(hex) // silent; bind `copied` to a tick
68
+ * copy(hex, { toast: "Copied #3b82f6" }) // says so
69
+ * ```
70
+ *
71
+ * Defaults are per-call rather than per-instance: one component often copies
72
+ * several things for different reasons — a menu item that should announce
73
+ * itself, a button that shows its own tick — and hoisting the choice to the
74
+ * composable would force those to agree.
75
+ */
76
+ export function useClipboard(): UseClipboard {
77
+ const copied = ref(false)
78
+ const { success, error } = useToast()
79
+ let timer: ReturnType<typeof setTimeout> | undefined
80
+
81
+ async function copy(text: string, options: CopyOptions = {}): Promise<boolean> {
82
+ const { toast = false, errorToast = false } = options
83
+ try {
84
+ // Optional-chained: `navigator.clipboard` is undefined, not throwing, on
85
+ // an insecure origin, and that should take the same path as a rejection.
86
+ await navigator.clipboard?.writeText(text)
87
+ // A missing API resolves `undefined` rather than rejecting, so confirm
88
+ // the write was actually possible before claiming it happened.
89
+ if (!navigator.clipboard) throw new Error("clipboard unavailable")
90
+
91
+ copied.value = true
92
+ clearTimeout(timer)
93
+ timer = setTimeout(() => (copied.value = false), COPIED_MS)
94
+ if (toast) success(toast)
95
+ return true
96
+ } catch {
97
+ if (errorToast) error(errorToast)
98
+ return false
99
+ }
100
+ }
101
+
102
+ // The reset outlives a component that unmounts inside the window — a menu
103
+ // that copies and closes is the ordinary case, not the exotic one.
104
+ onScopeDispose(() => clearTimeout(timer))
105
+
106
+ return { copied, copy }
107
+ }
package/src/index.ts CHANGED
@@ -21,6 +21,8 @@ export type {
21
21
  NominatimOptions,
22
22
  TileSource,
23
23
  } from "./components/address"
24
+ export { default as LpActionBar } from "./components/LpActionBar.vue"
25
+ export type { ActionBarAction } from "./components/LpActionBar.vue"
24
26
  export { default as LpAlert } from "./components/LpAlert.vue"
25
27
  export { default as LpAppShell } from "./components/LpAppShell.vue"
26
28
  export { default as LpAutocomplete } from "./components/LpAutocomplete.vue"
@@ -44,6 +46,23 @@ export type { ChartAxis, ChartPoint, ChartSeries } from "./components/chart"
44
46
  export { default as LpCheckbox } from "./components/LpCheckbox.vue"
45
47
  export { default as LpCodeBlock } from "./components/LpCodeBlock.vue"
46
48
  export type { CodeLang } from "./components/codeHighlight"
49
+ export { default as LpColorPicker } from "./components/LpColorPicker.vue"
50
+ export type { ColorPickerLabels } from "./components/LpColorPicker.vue"
51
+ export {
52
+ clampHsv,
53
+ DEFAULT_SWATCHES,
54
+ formatColor,
55
+ hsvToHsl,
56
+ hsvToRgb,
57
+ isLight,
58
+ normalizeHue,
59
+ opaqueCss,
60
+ parseColor,
61
+ parseHex,
62
+ rgbToHex,
63
+ rgbToHsv,
64
+ } from "./components/color"
65
+ export type { ColorFormat, Hsv, Rgb } from "./components/color"
47
66
  export { default as LpCommandPalette } from "./components/LpCommandPalette.vue"
48
67
  export type { Command } from "./components/LpCommandPalette.vue"
49
68
  export { default as LpConfirmDialog } from "./components/LpConfirmDialog.vue"
@@ -204,6 +223,8 @@ export type { PresetName } from "./theme/presets"
204
223
  export { parseConfig, serializeConfig, UI_CONFIG_VERSION } from "./theme/config"
205
224
  export type { UiConfig } from "./theme/config"
206
225
 
226
+ export { useClipboard } from "./composables/useClipboard"
227
+ export type { CopyOptions, UseClipboard } from "./composables/useClipboard"
207
228
  export { useHotkeys, isAppleKeyboard, appleModifierLabel } from "./composables/useHotkeys"
208
229
  export type { HotkeyBinding } from "./composables/useHotkeys"
209
230
  export { useInputFilter } from "./composables/useInputFilter"