@leavepulse/ui 0.24.0 → 0.25.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.
Files changed (104) hide show
  1. package/dist/assets/LpNumberFlow-rZkwQwlU.css +116 -0
  2. package/dist/assets/LpRollingText-CR1mb0Vt.css +74 -0
  3. package/dist/assets/LpSparkline-0mKTq7pb.css +51 -0
  4. package/dist/assets/LpTooltip-B61SYd4K.css +17 -0
  5. package/dist/assets/LpUptimeBar-B98o9krI.css +18 -0
  6. package/dist/chunks/{LpAppShell-CaQYQKZV.js → LpAppShell-B5k0J3Cf.js} +1 -1
  7. package/dist/chunks/LpChart-tNXs1ttr.js +671 -0
  8. package/dist/chunks/{LpCodeBlock-CSiOSPs8.js → LpCodeBlock-rS_6mO3D.js} +1 -1
  9. package/dist/chunks/{LpConfirmDialog-CObRah9c.js → LpConfirmDialog-CjOGcj1e.js} +32 -4
  10. package/dist/chunks/{LpDisclosure-DP6yfgfM.js → LpDisclosure-BnwP4Q-b.js} +1 -1
  11. package/dist/chunks/{LpFileTree-nU1wRRxZ.js → LpFileTree-BaV7f0O7.js} +1 -1
  12. package/dist/chunks/{LpFileTreeNode-D7WgUPcJ.js → LpFileTreeNode-BzpBa7R7.js} +6 -8
  13. package/dist/chunks/{LpLogViewer-B1aLUVdz.js → LpLogViewer-CcQ3s3Fp.js} +2 -1
  14. package/dist/chunks/{LpMapPicker-DFKrd1Ne.js → LpMapPicker-CXvoTrAe.js} +2 -4
  15. package/dist/chunks/{LpNotificationBell-X1M30e0X.js → LpNotificationBell-Dvu-b6Vn.js} +56 -27
  16. package/dist/chunks/LpNumberFlow-amWYaeuA.js +221 -0
  17. package/dist/chunks/LpPie-BkjWlfSw.js +525 -0
  18. package/dist/chunks/LpProgress-DoIP4jQR.js +198 -0
  19. package/dist/chunks/LpRollingText-CM5gkSeF.js +50 -0
  20. package/dist/chunks/{LpSidebar-D3GGLSE7.js → LpSidebar-7Vpy2IJc.js} +1 -1
  21. package/dist/chunks/{LpSidebarNav-X14xMdPa.js → LpSidebarNav-CFX5NxGs.js} +1 -1
  22. package/dist/chunks/LpSparkline-C6xQ2dwv.js +175 -0
  23. package/dist/chunks/{LpStat-CdbEFOvn.js → LpStat-CDanpzU0.js} +43 -5
  24. package/dist/chunks/{LpThemeSwitcher-BgpJgrnP.js → LpThemeSwitcher-7mAK6bI7.js} +2 -4
  25. package/dist/chunks/LpTooltip-hCLPAqMG.js +131 -0
  26. package/dist/chunks/LpUptimeBar-DcQ8HikG.js +152 -0
  27. package/dist/component-names.d.ts +1 -1
  28. package/dist/components/LpAppShell.vue.js +1 -1
  29. package/dist/components/LpChart.vue.d.ts +88 -0
  30. package/dist/components/LpChart.vue.js +4 -0
  31. package/dist/components/LpCodeBlock.vue.js +1 -1
  32. package/dist/components/LpConfirmDialog.vue.d.ts +18 -2
  33. package/dist/components/LpConfirmDialog.vue.js +1 -1
  34. package/dist/components/LpDisclosure.vue.js +1 -1
  35. package/dist/components/LpFileTree.vue.d.ts +1 -1
  36. package/dist/components/LpFileTree.vue.js +1 -1
  37. package/dist/components/LpFileTreeNode.vue.js +1 -1
  38. package/dist/components/LpLightbox.vue.d.ts +1 -1
  39. package/dist/components/LpLogViewer.vue.d.ts +1 -1
  40. package/dist/components/LpLogViewer.vue.js +1 -1
  41. package/dist/components/LpMapPicker.vue.js +1 -1
  42. package/dist/components/LpNotificationBell.vue.d.ts +12 -0
  43. package/dist/components/LpNotificationBell.vue.js +1 -1
  44. package/dist/components/LpNumberFlow.vue.d.ts +35 -0
  45. package/dist/components/LpNumberFlow.vue.js +4 -0
  46. package/dist/components/LpPie.vue.d.ts +134 -0
  47. package/dist/components/LpPie.vue.js +4 -0
  48. package/dist/components/LpProgress.vue.d.ts +56 -2
  49. package/dist/components/LpProgress.vue.js +1 -1
  50. package/dist/components/LpRollingText.vue.d.ts +33 -0
  51. package/dist/components/LpRollingText.vue.js +4 -0
  52. package/dist/components/LpSidebar.vue.js +1 -1
  53. package/dist/components/LpSidebarNav.vue.js +1 -1
  54. package/dist/components/LpSparkline.vue.d.ts +48 -0
  55. package/dist/components/LpSparkline.vue.js +4 -0
  56. package/dist/components/LpStat.vue.d.ts +25 -2
  57. package/dist/components/LpStat.vue.js +1 -1
  58. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  59. package/dist/components/LpTooltip.vue.d.ts +24 -3
  60. package/dist/components/LpTooltip.vue.js +1 -1
  61. package/dist/components/LpUptimeBar.vue.js +1 -1
  62. package/dist/components/chart.d.ts +180 -0
  63. package/dist/components/chart.js +353 -0
  64. package/dist/composables/easing.d.ts +46 -0
  65. package/dist/composables/easing.js +23 -0
  66. package/dist/composables/usePillTransition.d.ts +13 -1
  67. package/dist/composables/usePillTransition.js +22 -2
  68. package/dist/composables/useReveal.d.ts +26 -0
  69. package/dist/composables/useReveal.js +45 -0
  70. package/dist/composables/useSettled.js +2 -2
  71. package/dist/composables/useShift.js +2 -4
  72. package/dist/index.d.ts +14 -0
  73. package/dist/index.js +126 -103
  74. package/package.json +2 -1
  75. package/src/component-names.ts +5 -0
  76. package/src/components/LpChart.vue +846 -0
  77. package/src/components/LpCodeBlock.vue +1 -1
  78. package/src/components/LpConfirmDialog.vue +49 -0
  79. package/src/components/LpDisclosure.vue +1 -1
  80. package/src/components/LpFileTreeNode.vue +9 -10
  81. package/src/components/LpLogViewer.vue +2 -3
  82. package/src/components/LpMapPicker.vue +2 -5
  83. package/src/components/LpNotificationBell.vue +76 -15
  84. package/src/components/LpNumberFlow.vue +524 -0
  85. package/src/components/LpPie.vue +778 -0
  86. package/src/components/LpProgress.vue +283 -6
  87. package/src/components/LpRollingText.vue +157 -0
  88. package/src/components/LpSidebarNav.vue +1 -1
  89. package/src/components/LpSparkline.vue +312 -0
  90. package/src/components/LpStat.vue +52 -4
  91. package/src/components/LpTooltip.vue +185 -3
  92. package/src/components/LpUptimeBar.vue +150 -53
  93. package/src/components/chart.ts +686 -0
  94. package/src/composables/easing.ts +88 -0
  95. package/src/composables/usePillTransition.ts +71 -6
  96. package/src/composables/useReveal.ts +113 -0
  97. package/src/composables/useSettled.ts +2 -4
  98. package/src/composables/useShift.ts +2 -8
  99. package/src/index.ts +19 -0
  100. package/src/theme/useTheme.ts +2 -8
  101. package/src/tokens/tokens.css +36 -0
  102. package/dist/chunks/LpProgress-BhI0IbjC.js +0 -37
  103. package/dist/chunks/LpTooltip-D1hbjQWG.js +0 -63
  104. package/dist/chunks/LpUptimeBar-CB366k0O.js +0 -127
@@ -268,7 +268,7 @@ function onInput(e: Event) {
268
268
  v-else
269
269
  ><button
270
270
  type="button"
271
- class="lp-fold-toggle flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
271
+ class="lp-fold-toggle flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
272
272
  :aria-expanded="openState[seg.id]"
273
273
  @click="toggleFold(seg.id)"
274
274
  ><LpIcon
@@ -1,6 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  // Confirmation dialog built on LpModal. Resolves an action via confirm/cancel.
3
+ import { computed } from "vue"
3
4
  import LpButton from "./LpButton.vue"
5
+ import LpIcon from "./LpIcon.vue"
4
6
  import LpModal from "./LpModal.vue"
5
7
 
6
8
  const props = withDefaults(
@@ -10,7 +12,23 @@ const props = withDefaults(
10
12
  description?: string
11
13
  confirmLabel?: string
12
14
  cancelLabel?: string
15
+ /**
16
+ * The action destroys something. Colours the confirm button, and marks the
17
+ * title with a warning glyph — a destructive prompt should be recognisable
18
+ * before the words are read, since it is the one dialog people dismiss on
19
+ * reflex.
20
+ */
13
21
  danger?: boolean
22
+ /**
23
+ * Override the marker: any iconify name, or `""` to leave the title plain —
24
+ * for a `danger` action whose dialog body already carries the warning and
25
+ * would otherwise state it twice.
26
+ *
27
+ * A string rather than `string | false`, deliberately: a union including
28
+ * Boolean makes Vue boolean-cast the prop, so an absent `icon` would arrive
29
+ * as `false` and be indistinguishable from a caller suppressing the marker.
30
+ */
31
+ icon?: string
14
32
  /**
15
33
  * The confirm action is in flight: the button disables and swaps to
16
34
  * `loadingLabel`, and confirming no longer closes the dialog — the owner
@@ -28,6 +46,22 @@ const emit = defineEmits<{
28
46
  (e: "cancel"): void
29
47
  }>()
30
48
 
49
+ /*
50
+ * Only a destructive prompt is marked by default. A routine confirmation
51
+ * ("Save these changes?") with a warning badge cries wolf, and the badge stops
52
+ * meaning anything on the dialog where it matters.
53
+ *
54
+ * "Not given" cannot be tested with `undefined` here. A prop whose type union
55
+ * includes Boolean is boolean-cast by Vue, so an absent `icon` arrives as
56
+ * `false` — the same value the caller passes to suppress the marker — and the
57
+ * default silently never applied. A string is an override, and everything else
58
+ * (absent, or an explicit false) falls through to the danger default.
59
+ */
60
+ const marker = computed<string>(() => {
61
+ if (props.icon !== undefined) return props.icon
62
+ return props.danger ? "lucide:triangle-alert" : ""
63
+ })
64
+
31
65
  function confirm() {
32
66
  emit("confirm")
33
67
  // An async confirm owns its own dismissal; closing here would tear the
@@ -47,6 +81,21 @@ function cancel() {
47
81
  :description="description"
48
82
  @update:open="(v) => $emit('update:open', v)"
49
83
  >
84
+ <template v-if="marker" #title>
85
+ <span class="inline-flex items-center gap-2.5">
86
+ <span
87
+ class="flex size-9 shrink-0 items-center justify-center rounded-full"
88
+ :class="
89
+ danger
90
+ ? 'border border-danger/30 bg-danger-soft text-danger'
91
+ : 'border border-line bg-surface-soft text-muted-strong'
92
+ "
93
+ >
94
+ <LpIcon :name="marker" :size="20" />
95
+ </span>
96
+ {{ title }}
97
+ </span>
98
+ </template>
50
99
  <slot />
51
100
  <template #footer>
52
101
  <LpButton variant="ghost" :disabled="loading" @click="cancel">
@@ -61,7 +61,7 @@ function onToggle(value: boolean) {
61
61
  @update:open="onToggle"
62
62
  >
63
63
  <CollapsibleTrigger
64
- class="group flex w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-ink outline-none focus-visible:ring-2 focus-visible:ring-ring"
64
+ class="group flex w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-ink outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
65
65
  >
66
66
  <LpIcon v-if="icon" :name="icon" :size="14" class="text-muted" />
67
67
  <span class="min-w-0 flex-1 text-left">
@@ -10,6 +10,7 @@
10
10
  * Tab enters the tree once, arrows move within it. The parent owns that state.
11
11
  */
12
12
  import { computed } from "vue"
13
+ import { easingToken, prefersReducedMotion } from "../composables/easing"
13
14
  import {
14
15
  fileIcon,
15
16
  formatModified,
@@ -110,35 +111,30 @@ function rowDelay(index: number): string {
110
111
  return `${Math.min(index * 18, 180)}ms`
111
112
  }
112
113
 
113
- /** Respect the OS setting — skip the height animation entirely. */
114
- function reducedMotion(): boolean {
115
- return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
116
- }
117
-
118
114
  // Expand/collapse the branch by animating from 0 to its measured height. The
119
115
  // Web Animations API is used directly (rather than a CSS class) because the
120
116
  // target height isn't known until the rows are in the DOM.
121
117
  function onBranchEnter(el: Element, done: () => void) {
122
- if (reducedMotion()) return done()
118
+ if (prefersReducedMotion()) return done()
123
119
  const height = (el as HTMLElement).scrollHeight
124
120
  el.animate(
125
121
  [
126
122
  { height: "0px", opacity: 0 },
127
123
  { height: `${height}px`, opacity: 1 },
128
124
  ],
129
- { duration: 220, easing: "cubic-bezier(0.2, 0, 0, 1)" },
125
+ { duration: 220, easing: easingToken("ease-emphasized") },
130
126
  ).onfinish = () => done()
131
127
  }
132
128
 
133
129
  function onBranchLeave(el: Element, done: () => void) {
134
- if (reducedMotion()) return done()
130
+ if (prefersReducedMotion()) return done()
135
131
  const height = (el as HTMLElement).scrollHeight
136
132
  el.animate(
137
133
  [
138
134
  { height: `${height}px`, opacity: 1 },
139
135
  { height: "0px", opacity: 0 },
140
136
  ],
141
- { duration: 180, easing: "cubic-bezier(0.2, 0, 0, 1)" },
137
+ { duration: 180, easing: easingToken("ease-emphasized") },
142
138
  ).onfinish = () => done()
143
139
  }
144
140
 
@@ -163,10 +159,13 @@ function onActivate() {
163
159
  >
164
160
  <!-- Passthrough when the node has no menu, so the row keeps the native one. -->
165
161
  <LpContextMenu :items="node.menu ?? []">
162
+ <!-- The ring is drawn inset, like LpTable's rows: a row spans the full
163
+ width of the branch, and a branch clips its overflow to animate its
164
+ height, so an outset ring loses its left and right edge. -->
166
165
  <div
167
166
  :data-node-id="node.id"
168
167
  :tabindex="isFocused ? 0 : -1"
169
- class="group/row relative flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-control py-1 pr-2 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
168
+ class="group/row relative flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-control py-1 pr-2 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
170
169
  :class="[
171
170
  node.disabled
172
171
  ? 'cursor-not-allowed text-muted/50'
@@ -8,6 +8,7 @@
8
8
  * needs.
9
9
  */
10
10
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, TransitionGroup, watch } from "vue"
11
+ import { prefersReducedMotion } from "../composables/easing"
11
12
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
12
13
  import LpIcon from "./LpIcon.vue"
13
14
  import LpScrollArea from "./LpScrollArea.vue"
@@ -466,9 +467,7 @@ function restoreScrollAnchor() {
466
467
  function rideToBottom(smooth = true, force = false) {
467
468
  const el = viewport()
468
469
  if (!el) return
469
- const reduce =
470
- typeof window !== "undefined" &&
471
- window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
470
+ const reduce = prefersReducedMotion()
472
471
  const far = !force && el.scrollHeight - el.scrollTop - el.clientHeight > el.clientHeight
473
472
  el.scrollTo({ top: el.scrollHeight, behavior: smooth && !reduce && !far ? "smooth" : "auto" })
474
473
  }
@@ -14,6 +14,7 @@
14
14
  * is how the zoom reads as continuous instead of as a hard cut between levels.
15
15
  */
16
16
  import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
17
+ import { prefersReducedMotion } from "../composables/easing"
17
18
  import { type LatLon, OSM_TILES, type TileSource, project, tileUrl, unproject } from "./address"
18
19
  import LpIcon from "./LpIcon.vue"
19
20
 
@@ -335,7 +336,7 @@ function onPointerUp(e: PointerEvent) {
335
336
  * rather than per-frame, which keeps the feel identical at 60 and 144 Hz.
336
337
  */
337
338
  function glide() {
338
- if (reducedMotion() || Math.hypot(velocity.x, velocity.y) < 0.05) {
339
+ if (prefersReducedMotion() || Math.hypot(velocity.x, velocity.y) < 0.05) {
339
340
  velocity = { x: 0, y: 0 }
340
341
  return
341
342
  }
@@ -355,10 +356,6 @@ function glide() {
355
356
  glideFrame = requestAnimationFrame(step)
356
357
  }
357
358
 
358
- function reducedMotion() {
359
- return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
360
- }
361
-
362
359
  function panBy(dx: number, dy: number) {
363
360
  mapCenter.value = unproject(
364
361
  origin.value.x + dx + viewWidth.value / 2,
@@ -8,7 +8,7 @@
8
8
  * `select`. Mirrors the kit convention (cf. LpSidebar): data in, events out,
9
9
  * no data fetching inside.
10
10
  */
11
- import { computed } from "vue"
11
+ import { computed, getCurrentInstance } from "vue"
12
12
  import LpBadge from "./LpBadge.vue"
13
13
  import LpButton from "./LpButton.vue"
14
14
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
@@ -24,6 +24,15 @@ export interface NotificationItem {
24
24
  body?: string
25
25
  /** Optional icon name (e.g. "lucide:check-circle"). */
26
26
  icon?: string
27
+ /**
28
+ * Colours the row's icon by what KIND of notification it is — a billing
29
+ * notice and a security one should not look alike at a glance.
30
+ *
31
+ * A named tone rather than a class, so the categories stay themeable:
32
+ * consumers were mapping their own types onto raw palette classes, which is
33
+ * the one place a theme cannot follow them.
34
+ */
35
+ tone?: "brand" | "action" | "danger" | "accent" | "muted"
27
36
  /** Optional deep link; the row becomes clickable when set. */
28
37
  link?: string
29
38
  /** ISO-8601 creation time; rendered as a relative label. */
@@ -40,6 +49,7 @@ export interface NotificationItem {
40
49
  export interface NotificationLabels {
41
50
  title?: string
42
51
  markAllRead?: string
52
+ clear?: string
43
53
  markAsRead?: string
44
54
  markAsUnread?: string
45
55
  open?: string
@@ -76,6 +86,7 @@ const props = withDefaults(
76
86
  const l = computed(() => ({
77
87
  title: props.title ?? props.labels?.title ?? "Notifications",
78
88
  markAllRead: props.labels?.markAllRead ?? "Mark all read",
89
+ clear: props.labels?.clear ?? "Clear",
79
90
  markAsRead: props.labels?.markAsRead ?? "Mark as read",
80
91
  markAsUnread: props.labels?.markAsUnread ?? "Mark as unread",
81
92
  open: props.labels?.open ?? "Open",
@@ -94,6 +105,8 @@ const emit = defineEmits<{
94
105
  (e: "markRead", id: string): void
95
106
  (e: "markUnread", id: string): void
96
107
  (e: "markAllRead"): void
108
+ /** Empty the whole feed. The header button only appears when this is bound. */
109
+ (e: "clear"): void
97
110
  (e: "dismiss", id: string): void
98
111
  (e: "select", item: NotificationItem): void
99
112
  }>()
@@ -106,21 +119,52 @@ const badgeLabel = computed(() =>
106
119
  )
107
120
  const hasUnread = computed(() => unread.value > 0)
108
121
 
122
+ /** Icon tints for an unread row, keyed by the item's category tone. */
123
+ const TONE_CLASS: Record<string, string> = {
124
+ brand: "bg-brand-soft text-brand",
125
+ action: "bg-action-soft text-action",
126
+ danger: "bg-danger-soft text-danger",
127
+ accent: "bg-accent-soft text-accent",
128
+ muted: "bg-surface-soft text-muted-strong",
129
+ }
130
+
131
+ /*
132
+ * Which optional actions the consumer actually handles. A button for an event
133
+ * nobody listens to is a button that does nothing when pressed.
134
+ *
135
+ * NOT `useAttrs()`: a listener for a DECLARED emit is removed from `$attrs`, so
136
+ * an `@clear` handler never appears there. The raw vnode props are where it
137
+ * survives.
138
+ */
139
+ const instance = getCurrentInstance()
140
+ const handlesClear = computed(() => Boolean(instance?.vnode.props?.onClear))
141
+
109
142
  // Right-click quick actions on the bell. "Open" is intentionally omitted — a
110
143
  // left-click already opens the feed, so the menu only carries actions you
111
144
  // can't get from the click itself. Because `open` is a defineModel we open on
112
145
  // left-click ourselves (the popover is anchor-only), which frees the trigger
113
146
  // button to also host LpContextMenu without the two fighting over the trigger.
114
- const contextMenu = computed<ContextMenuItemDef[]>(() =>
115
- props.menuItems ?? [
147
+ const contextMenu = computed<ContextMenuItemDef[]>(() => {
148
+ if (props.menuItems) return props.menuItems
149
+ const items: ContextMenuItemDef[] = [
116
150
  {
117
151
  label: l.value.markAllRead,
118
152
  icon: "lucide:check-check",
119
153
  disabled: !hasUnread.value,
120
154
  onSelect: () => emit("markAllRead"),
121
155
  },
122
- ],
123
- )
156
+ ]
157
+ // Same rule as the header button: offered only when the caller can act on it.
158
+ if (handlesClear.value) {
159
+ items.push({
160
+ label: l.value.clear,
161
+ icon: "lucide:trash-2",
162
+ disabled: !props.items.length,
163
+ onSelect: () => emit("clear"),
164
+ })
165
+ }
166
+ return items
167
+ })
124
168
 
125
169
  function onSelect(item: NotificationItem) {
126
170
  if (!item.read) emit("markRead", item.id)
@@ -216,14 +260,28 @@ function timeAgo(iso?: string): string {
216
260
 
217
261
  <header class="flex items-center justify-between gap-2 px-3 py-2.5">
218
262
  <span class="text-sm font-semibold">{{ l.title }}</span>
219
- <button
220
- v-if="hasUnread"
221
- type="button"
222
- class="rounded-md px-1.5 py-0.5 text-xs font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:bg-brand-soft"
223
- @click="emit('markAllRead')"
224
- >
225
- {{ l.markAllRead }}
226
- </button>
263
+ <span class="flex items-center gap-0.5">
264
+ <!-- Emptying the feed is a different act from reading it, and it is
265
+ destructive, so it is muted and sits away from the primary action.
266
+ Only offered when a listener is bound: a caller with no way to
267
+ clear its feed must not be given a button that does nothing. -->
268
+ <button
269
+ v-if="items.length && handlesClear"
270
+ type="button"
271
+ class="rounded-md px-1.5 py-0.5 text-xs font-medium text-muted outline-none transition-colors hover:bg-surface-soft hover:text-ink focus-visible:bg-surface-soft"
272
+ @click="emit('clear')"
273
+ >
274
+ {{ l.clear }}
275
+ </button>
276
+ <button
277
+ v-if="hasUnread"
278
+ type="button"
279
+ class="rounded-md px-1.5 py-0.5 text-xs font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:bg-brand-soft"
280
+ @click="emit('markAllRead')"
281
+ >
282
+ {{ l.markAllRead }}
283
+ </button>
284
+ </span>
227
285
  </header>
228
286
 
229
287
  <!-- Skeleton → empty → feed are three different heights, so the panel
@@ -250,13 +308,16 @@ function timeAgo(iso?: string): string {
250
308
  :is="item.link ? 'a' : 'button'"
251
309
  :href="item.link || undefined"
252
310
  :type="item.link ? undefined : 'button'"
253
- class="group/notif flex w-full items-start gap-2.5 rounded-control px-2 py-2 text-left outline-none transition-colors hover:bg-white/[0.05] focus-visible:bg-white/[0.05]"
311
+ class="group/notif flex w-full items-start gap-2.5 rounded-control px-2 py-2 text-left outline-none transition-colors hover:bg-surface-soft focus-visible:bg-surface-soft"
254
312
  :class="item.read ? '' : 'bg-brand-soft/35'"
255
313
  @click="onSelect(item)"
256
314
  >
315
+ <!-- A read notification is always muted: the tone says what kind of
316
+ thing it is, and that only needs to compete for attention while
317
+ it is still unread. -->
257
318
  <span
258
319
  class="grid size-7 shrink-0 place-items-center rounded-control"
259
- :class="item.read ? 'bg-surface-soft text-muted' : 'bg-brand-soft text-brand'"
320
+ :class="item.read ? 'bg-surface-soft text-muted' : TONE_CLASS[item.tone ?? 'brand']"
260
321
  >
261
322
  <LpIcon :name="item.icon || 'lucide:bell'" :size="15" />
262
323
  </span>