@cl-light-ds/ui 0.4.0 → 0.5.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 (91) hide show
  1. package/package.json +19 -17
  2. package/src/components/alert/Alert.astro +8 -2
  3. package/src/components/alert/CHANGELOG.md +9 -0
  4. package/src/components/button/Button.astro +78 -19
  5. package/src/components/button/CHANGELOG.md +22 -0
  6. package/src/components/button-group/ButtonGroup.astro +14 -3
  7. package/src/components/button-group/CHANGELOG.md +17 -0
  8. package/src/components/card/CHANGELOG.md +9 -0
  9. package/src/components/card/Card.astro +22 -0
  10. package/src/components/combobox/CHANGELOG.md +14 -0
  11. package/src/components/combobox/ComboboxTrigger.astro +31 -1
  12. package/src/components/command-search/CHANGELOG.md +27 -0
  13. package/src/components/command-search/CommandSearch.astro +515 -0
  14. package/src/components/command-search/CommandSearchGroup.astro +32 -0
  15. package/src/components/command-search/CommandSearchItem.astro +64 -0
  16. package/src/components/command-search/classes.ts +18 -0
  17. package/src/components/command-search/index.ts +12 -0
  18. package/src/components/command-search/nothing-matches.svg +42 -0
  19. package/src/components/dialog/CHANGELOG.md +13 -0
  20. package/src/components/dialog/DialogClose.astro +26 -12
  21. package/src/components/dropdown-menu/CHANGELOG.md +7 -0
  22. package/src/components/dropdown-menu/dropdown-menu-item-variants.ts +1 -1
  23. package/src/components/empty-state/CHANGELOG.md +18 -0
  24. package/src/components/empty-state/EmptyState.astro +157 -0
  25. package/src/components/empty-state/illustrations/AllCaughtUp.astro +52 -0
  26. package/src/components/empty-state/illustrations/EmptyWorkspaces.astro +56 -0
  27. package/src/components/empty-state/illustrations/LoadError.astro +61 -0
  28. package/src/components/empty-state/illustrations/NoResults.astro +60 -0
  29. package/src/components/empty-state/illustrations/NothingMatches.astro +66 -0
  30. package/src/components/empty-state/illustrations/index.ts +27 -0
  31. package/src/components/empty-state/index.ts +7 -0
  32. package/src/components/favorite-toggle/CHANGELOG.md +13 -0
  33. package/src/components/favorite-toggle/FavoriteToggle.astro +94 -0
  34. package/src/components/favorite-toggle/index.ts +1 -0
  35. package/src/components/input-group/CHANGELOG.md +7 -0
  36. package/src/components/input-group/InputGroup.astro +2 -0
  37. package/src/components/loading-overlay/CHANGELOG.md +25 -0
  38. package/src/components/loading-overlay/LoadingOverlay.astro +179 -0
  39. package/src/components/loading-overlay/index.ts +8 -0
  40. package/src/components/notifications-menu/CHANGELOG.md +22 -0
  41. package/src/components/notifications-menu/NotificationsMenu.astro +374 -0
  42. package/src/components/notifications-menu/NotificationsMenuItem.astro +129 -0
  43. package/src/components/notifications-menu/all-caught-up.svg +28 -0
  44. package/src/components/notifications-menu/index.ts +3 -0
  45. package/src/components/notifications-menu/types.ts +32 -0
  46. package/src/components/option-card/CHANGELOG.md +16 -0
  47. package/src/components/option-card/OptionCard.astro +135 -0
  48. package/src/components/option-card/index.ts +1 -0
  49. package/src/components/organisms/top-bar/CHANGELOG.md +21 -0
  50. package/src/components/organisms/top-bar/TopBar.astro +264 -0
  51. package/src/components/organisms/top-bar/index.ts +2 -0
  52. package/src/components/organisms/top-bar/types.ts +30 -0
  53. package/src/components/organisms/workspace/CHANGELOG.md +73 -0
  54. package/src/components/organisms/workspace/OrganisationOverview.astro +62 -0
  55. package/src/components/organisms/workspace/RecentWorkspaceCard.astro +235 -0
  56. package/src/components/organisms/workspace/RecentWorkspaces.astro +93 -0
  57. package/src/components/organisms/workspace/WorkspaceAvatar.astro +144 -0
  58. package/src/components/organisms/workspace/WorkspaceCard.astro +172 -0
  59. package/src/components/organisms/workspace/WorkspaceGlyph.astro +62 -0
  60. package/src/components/organisms/workspace/WorkspaceIcon.astro +42 -0
  61. package/src/components/organisms/workspace/WorkspaceIconPicker.astro +288 -0
  62. package/src/components/organisms/workspace/WorkspaceIconPickerGlyph.astro +44 -0
  63. package/src/components/organisms/workspace/icons.ts +98 -0
  64. package/src/components/organisms/workspace/index.ts +35 -0
  65. package/src/components/organisms/workspace/labels.ts +54 -0
  66. package/src/components/organisms/workspace/recent-card-rings-strong.png +0 -0
  67. package/src/components/organisms/workspace/types.ts +30 -0
  68. package/src/components/profile-menu/CHANGELOG.md +22 -0
  69. package/src/components/profile-menu/ProfileMenu.astro +236 -0
  70. package/src/components/profile-menu/index.ts +2 -0
  71. package/src/components/profile-menu/types.ts +27 -0
  72. package/src/components/search-field/CHANGELOG.md +14 -0
  73. package/src/components/search-field/SearchField.astro +163 -0
  74. package/src/components/search-field/index.ts +1 -0
  75. package/src/components/table/CHANGELOG.md +9 -0
  76. package/src/components/table/TableCell.astro +6 -0
  77. package/src/components/table/TableHead.astro +5 -1
  78. package/src/components/table/TableRow.astro +3 -1
  79. package/src/components/table/table-cell-variants.ts +2 -0
  80. package/src/components/toast/CHANGELOG.md +32 -0
  81. package/src/components/toast/Toast.astro +145 -0
  82. package/src/components/toast/Toaster.astro +322 -0
  83. package/src/components/toast/index.ts +9 -0
  84. package/src/components/toggle/CHANGELOG.md +7 -0
  85. package/src/lib/command-search.ts +73 -0
  86. package/src/lib/loading-overlay.ts +174 -0
  87. package/src/lib/toast.ts +127 -0
  88. package/src/lib/toggle-shared.ts +4 -1
  89. package/src/lib/workspace-palette.ts +119 -0
  90. package/src/styles/CHANGELOG.md +22 -0
  91. package/src/styles/globals.css +87 -0
@@ -0,0 +1,374 @@
1
+ ---
2
+ /**
3
+ * @component NotificationsMenu
4
+ * @title Notifications Menu
5
+ * @description A bell button with a red dot while there are unseen notifications, opening a 360px popover: a header with Clear all, the notifications (dismiss one with ✕), and an empty state.
6
+ *
7
+ * @preview
8
+ *
9
+ * <NotificationsMenu
10
+ * notifications={[
11
+ * { id: "1", title: "Anna Lee commented", text: "“Step 4 needs a new screenshot.”", context: "Northwind Group", time: "5 min ago", initials: "AL", new: true },
12
+ * { id: "2", title: "Your recording got 120 views", text: "“Create a sales order” this week.", context: "Contoso", time: "Yesterday", icon: Eye },
13
+ * ]}
14
+ * />
15
+ *
16
+ * @usage
17
+ *
18
+ * ```astro nocollapse
19
+ * ---
20
+ * import { NotificationsMenu, NotificationsMenuItem } from '@cl-light-ds/ui/components/notifications-menu';
21
+ * import { CircleCheck, Eye } from '@lucide/astro';
22
+ * ---
23
+ *
24
+ * <!-- From data -->
25
+ * <NotificationsMenu
26
+ * notifications={[
27
+ * { id: "1", title: "Anna Lee commented", text: "Step 4 needs a new screenshot.", context: "Northwind Group", time: "5 min ago", initials: "AL", new: true },
28
+ * { id: "2", title: "120 new views", context: "Contoso", time: "Yesterday", icon: Eye },
29
+ * ]}
30
+ * />
31
+ *
32
+ * <!-- Or composed, e.g. for an item with an action -->
33
+ * <NotificationsMenu>
34
+ * <NotificationsMenuItem id="export-1" title="Your export is ready" icon={CircleCheck} tone="success" time="Just now" new>
35
+ * <Button as="a" href="/export.zip" download variant="outline" size="sm">Download</Button>
36
+ * </NotificationsMenuItem>
37
+ * </NotificationsMenu>
38
+ * ```
39
+ *
40
+ * @api
41
+ *
42
+ * ### Seen / new
43
+ *
44
+ * A notification with `new` shows a blue dot and keeps the bell's red dot on. It counts as **seen** once
45
+ * at least `seenThreshold` (60%) of it has been in view in the open list, or as soon as it is hovered or
46
+ * keyboard-focused. Hovered / focused ones lose their dot at once; ones seen by scrolling lose it when the
47
+ * popover closes. The bell's red dot and accessible name follow the unseen count live.
48
+ *
49
+ * ### Events (on the `[data-slot="notifications-menu"]` root, bubbling)
50
+ *
51
+ * | Event | Detail | Description |
52
+ * |-------|--------|-------------|
53
+ * | `notifications:dismiss` | `{ id }` | ✕ pressed. Cancelable: `preventDefault()` keeps the item. |
54
+ * | `notifications:clear` | `{ ids }` | Clear all pressed. Cancelable. |
55
+ * | `notifications:seen` | `{ ids }` | These notifications were just seen (in view, hovered or focused). |
56
+ * | `notifications:change` | `{ total, unseen }` | The count or unseen count changed. |
57
+ *
58
+ * Inbound: dispatch `notifications:add` with `{ item: HTMLElement, position?: "start" | "end" }` on the root
59
+ * to add a rendered `NotificationsMenuItem` (e.g. cloned from a `<template>`); it defaults to the top. Items
60
+ * added or removed in the list any other way are picked up too.
61
+ */
62
+
63
+ import type { HTMLAttributes } from "astro/types";
64
+ import { Bell } from "@lucide/astro";
65
+ import { cn } from "@cl-light-ds/ui/lib/utils";
66
+ import { Button } from "../button";
67
+ import {
68
+ Popover,
69
+ PopoverContent,
70
+ PopoverHeader,
71
+ PopoverTitle,
72
+ PopoverTrigger,
73
+ } from "../popover";
74
+ import { Separator } from "../separator";
75
+ import NotificationsMenuItem from "./NotificationsMenuItem.astro";
76
+ import type { NotificationsMenuItemData } from "./types";
77
+ import allCaughtUp from "./all-caught-up.svg?url";
78
+
79
+ interface Props extends HTMLAttributes<"div"> {
80
+ /** Notifications rendered before any slotted `NotificationsMenuItem`s. */
81
+ notifications?: NotificationsMenuItemData[];
82
+ /** Popover heading. */
83
+ title?: string;
84
+ /** The bell's accessible name with nothing unseen. */
85
+ label?: string;
86
+ /** The bell's accessible name with unseen ones; `{count}` is replaced. */
87
+ unseenLabel?: string;
88
+ clearLabel?: string;
89
+ /** Accessible name of each ✕; `{title}` is replaced. */
90
+ dismissLabel?: string;
91
+ /** Screen-reader text after a new notification. */
92
+ newLabel?: string;
93
+ emptyTitle?: string;
94
+ emptyDescription?: string;
95
+ /** Image URL for the empty state; `false` for none. Default: the "all caught up" illustration. */
96
+ emptyIllustration?: string | false;
97
+ /** Share of an item that must be in view to count as seen (0–1). */
98
+ seenThreshold?: number;
99
+ side?: "top" | "bottom" | "left" | "right";
100
+ align?: "start" | "center" | "end";
101
+ sideOffset?: number;
102
+ class?: string;
103
+ }
104
+
105
+ const {
106
+ notifications = [],
107
+ title = "Notifications",
108
+ label = "Notifications",
109
+ unseenLabel = "Notifications — {count} new",
110
+ clearLabel = "Clear all",
111
+ dismissLabel = "Dismiss: {title}",
112
+ newLabel = "(new)",
113
+ emptyTitle = "You're all caught up",
114
+ emptyDescription = "New notifications will show up here.",
115
+ emptyIllustration = allCaughtUp,
116
+ seenThreshold = 0.6,
117
+ side = "bottom",
118
+ align = "end",
119
+ sideOffset = 6,
120
+ class: className = "",
121
+ ...rest
122
+ } = Astro.props as Props;
123
+
124
+ const hasSlotItems = Astro.slots.has("default");
125
+ const hasEmptySlot = Astro.slots.has("empty");
126
+ const empty = notifications.length === 0 && !hasSlotItems;
127
+ // Server-side guess from the data; the script recounts slotted items on load.
128
+ const unseen = notifications.filter((n) => n.new).length;
129
+ ---
130
+
131
+ <div
132
+ {...rest}
133
+ data-slot="notifications-menu"
134
+ data-label={label}
135
+ data-unseen-label={unseenLabel}
136
+ data-seen-threshold={seenThreshold}
137
+ class={cn("inline-flex", className)}
138
+ >
139
+ <Popover class="flex">
140
+ <PopoverTrigger
141
+ variant="ghost"
142
+ size="icon-sm"
143
+ class="group/bell relative"
144
+ aria-label={unseen ? unseenLabel.replace("{count}", String(unseen)) : label}
145
+ data-unseen={unseen ? "true" : "false"}
146
+ data-notifications-bell
147
+ >
148
+ <Bell />
149
+ {/* The red dot, while any notification is unseen (D141). */}
150
+ <span
151
+ data-slot="notifications-menu-badge"
152
+ class="bg-destructive ring-background absolute top-1 right-1 hidden size-2 rounded-full ring-2 group-data-[unseen=true]/bell:block"
153
+ aria-hidden="true"
154
+ ></span>
155
+ </PopoverTrigger>
156
+
157
+ <PopoverContent
158
+ side={side}
159
+ align={align}
160
+ sideOffset={sideOffset}
161
+ class="group/notifications w-90 max-w-[calc(100vw-1rem)] gap-0 p-0"
162
+ data-empty={empty ? "true" : "false"}
163
+ data-notifications-content
164
+ >
165
+ <PopoverHeader class="flex-row items-center justify-between gap-2 p-3">
166
+ <PopoverTitle>{title}</PopoverTitle>
167
+ <Button
168
+ variant="link"
169
+ size="sm"
170
+ class="group-data-[empty=true]/notifications:hidden"
171
+ data-notifications-clear
172
+ >
173
+ {clearLabel}
174
+ </Button>
175
+ </PopoverHeader>
176
+
177
+ <Separator />
178
+
179
+ <ul
180
+ data-slot="notifications-menu-list"
181
+ aria-label={title}
182
+ class="flex max-h-80 flex-col overflow-y-auto group-data-[empty=true]/notifications:hidden"
183
+ >
184
+ {
185
+ notifications.map((n) => (
186
+ <NotificationsMenuItem {...n} dismissLabel={dismissLabel} newLabel={newLabel} />
187
+ ))
188
+ }
189
+ <slot />
190
+ </ul>
191
+
192
+ <div
193
+ data-slot="notifications-menu-empty"
194
+ class="hidden flex-col items-center gap-2 px-6 py-10 text-center group-data-[empty=true]/notifications:flex"
195
+ >
196
+ {
197
+ hasEmptySlot ? (
198
+ <slot name="empty" />
199
+ ) : (
200
+ <>
201
+ {emptyIllustration && (
202
+ <img src={emptyIllustration} alt="" width="144" height="104" class="-my-2" />
203
+ )}
204
+ <p class="text-sm font-medium">{emptyTitle}</p>
205
+ {emptyDescription && <p class="text-muted-foreground text-xs">{emptyDescription}</p>}
206
+ </>
207
+ )
208
+ }
209
+ </div>
210
+ </PopoverContent>
211
+ </Popover>
212
+ </div>
213
+
214
+ <script>
215
+ const wired = new WeakSet<Element>();
216
+
217
+ function wire(root: HTMLElement) {
218
+ if (wired.has(root)) return;
219
+ wired.add(root);
220
+
221
+ // Hold references now: the popover portals its content to <body> while open.
222
+ const popover = root.querySelector<HTMLElement>('[data-slot="popover"]');
223
+ const bell = root.querySelector<HTMLElement>("[data-notifications-bell]");
224
+ const content = root.querySelector<HTMLElement>("[data-notifications-content]");
225
+ const list = root.querySelector<HTMLElement>('[data-slot="notifications-menu-list"]');
226
+ const clear = root.querySelector<HTMLElement>("[data-notifications-clear]");
227
+ if (!popover || !bell || !content || !list) return;
228
+
229
+ const label = root.dataset.label ?? "Notifications";
230
+ const unseenLabel = root.dataset.unseenLabel ?? "{count}";
231
+ const threshold = Number(root.dataset.seenThreshold ?? 0.6);
232
+ let open = false;
233
+ let last = "";
234
+
235
+ const items = () =>
236
+ Array.from(list.querySelectorAll<HTMLElement>('[data-slot="notifications-menu-item"]'));
237
+ const emit = (name: string, detail: unknown, cancelable = false) =>
238
+ root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, cancelable }));
239
+
240
+ const update = () => {
241
+ const all = items();
242
+ const unseen = all.filter(
243
+ (n) => n.dataset.new === "true" && !n.hasAttribute("data-seen"),
244
+ ).length;
245
+ bell.dataset.unseen = String(unseen > 0);
246
+ bell.setAttribute(
247
+ "aria-label",
248
+ unseen ? unseenLabel.replace("{count}", String(unseen)) : label,
249
+ );
250
+ content.dataset.empty = String(all.length === 0);
251
+ const key = `${all.length}:${unseen}`;
252
+ if (key !== last) {
253
+ last = key;
254
+ emit("notifications:change", { total: all.length, unseen });
255
+ }
256
+ };
257
+
258
+ /** Mark as seen. `now` also drops the blue dot (hover / focus); otherwise it goes on close. */
259
+ const see = (notes: HTMLElement[], now: boolean) => {
260
+ const fresh = notes.filter(
261
+ (n) => n.dataset.new === "true" && !n.hasAttribute("data-seen"),
262
+ );
263
+ for (const n of notes) {
264
+ if (n.dataset.new !== "true") continue;
265
+ if (now) {
266
+ n.dataset.new = "false";
267
+ n.removeAttribute("data-seen");
268
+ } else n.setAttribute("data-seen", "");
269
+ }
270
+ if (fresh.length) emit("notifications:seen", { ids: fresh.map((n) => n.dataset.notification) });
271
+ update();
272
+ };
273
+
274
+ const observer = new IntersectionObserver(
275
+ (entries) => {
276
+ if (!open) return;
277
+ see(
278
+ entries.filter((e) => e.isIntersecting).map((e) => e.target as HTMLElement),
279
+ false,
280
+ );
281
+ },
282
+ { root: list, threshold },
283
+ );
284
+ const watch = () => {
285
+ observer.disconnect();
286
+ for (const n of items()) observer.observe(n);
287
+ };
288
+
289
+ popover.addEventListener("popover:change", (event) => {
290
+ open = (event as CustomEvent<{ open: boolean }>).detail.open;
291
+ if (open) {
292
+ // Let it lay out before anything counts as in view.
293
+ requestAnimationFrame(watch);
294
+ } else {
295
+ observer.disconnect();
296
+ for (const n of items()) {
297
+ if (n.hasAttribute("data-seen")) {
298
+ n.dataset.new = "false";
299
+ n.removeAttribute("data-seen");
300
+ }
301
+ }
302
+ update();
303
+ }
304
+ });
305
+
306
+ // Hovering or keyboard-focusing a notification is seeing it (D165).
307
+ const noteOf = (event: Event) =>
308
+ (event.target as HTMLElement).closest<HTMLElement>('[data-slot="notifications-menu-item"]');
309
+ list.addEventListener("mouseover", (event) => {
310
+ const note = noteOf(event);
311
+ if (note) see([note], true);
312
+ });
313
+ list.addEventListener("focusin", (event) => {
314
+ // Only keyboard focus: the popover may focus the first item on open.
315
+ const note = noteOf(event);
316
+ if (note && (event.target as HTMLElement).matches(":focus-visible")) see([note], true);
317
+ });
318
+
319
+ const focusAfterRemoval = (next: HTMLElement | null) => {
320
+ const target = next?.querySelector<HTMLElement>("[data-notifications-dismiss]");
321
+ if (target) target.focus();
322
+ else {
323
+ if (!content.hasAttribute("tabindex")) content.tabIndex = -1;
324
+ content.focus();
325
+ }
326
+ };
327
+
328
+ list.addEventListener("click", (event) => {
329
+ const dismiss = (event.target as HTMLElement).closest("[data-notifications-dismiss]");
330
+ const note = dismiss?.closest<HTMLElement>('[data-slot="notifications-menu-item"]');
331
+ if (!note) return;
332
+ if (!emit("notifications:dismiss", { id: note.dataset.notification }, true)) return;
333
+ const next =
334
+ (note.nextElementSibling as HTMLElement | null) ??
335
+ (note.previousElementSibling as HTMLElement | null);
336
+ note.remove();
337
+ update();
338
+ focusAfterRemoval(next?.matches('[data-slot="notifications-menu-item"]') ? next : null);
339
+ });
340
+
341
+ clear?.addEventListener("click", () => {
342
+ const all = items();
343
+ if (!emit("notifications:clear", { ids: all.map((n) => n.dataset.notification) }, true)) return;
344
+ for (const n of all) n.remove();
345
+ update();
346
+ focusAfterRemoval(null);
347
+ });
348
+
349
+ root.addEventListener("notifications:add", (event) => {
350
+ const { item, position = "start" } =
351
+ (event as CustomEvent<{ item?: HTMLElement; position?: "start" | "end" }>).detail ?? {};
352
+ if (!item) return;
353
+ if (position === "end") list.append(item);
354
+ else list.prepend(item);
355
+ });
356
+
357
+ // Items added / removed / re-flagged by app code stay in sync.
358
+ new MutationObserver((records) => {
359
+ if (open) {
360
+ for (const r of records)
361
+ for (const node of r.addedNodes)
362
+ if (node instanceof HTMLElement && node.matches('[data-slot="notifications-menu-item"]'))
363
+ observer.observe(node);
364
+ }
365
+ update();
366
+ }).observe(list, { childList: true, subtree: true, attributeFilter: ["data-new"] });
367
+
368
+ update();
369
+ }
370
+
371
+ document
372
+ .querySelectorAll<HTMLElement>('[data-slot="notifications-menu"]')
373
+ .forEach(wire);
374
+ </script>
@@ -0,0 +1,129 @@
1
+ ---
2
+ /**
3
+ * @component NotificationsMenuItem
4
+ * @title Notifications Menu Item
5
+ * @description One row of a NotificationsMenu: avatar or icon, title, text, a "context · time" line, a blue "new" dot and a ✕ to dismiss it.
6
+ *
7
+ * @usage
8
+ *
9
+ * ```astro nocollapse
10
+ * <NotificationsMenuItem id="n1" title="Anna commented" text="Looks good to me" context="Northwind" time="2 min ago" initials="AL" new />
11
+ *
12
+ * <NotificationsMenuItem id="n2" title="Your export is ready" icon={CircleCheck} tone="success" time="Just now" new>
13
+ * <Button as="a" href="/export.zip" download variant="outline" size="sm">Download</Button>
14
+ * </NotificationsMenuItem>
15
+ * ```
16
+ *
17
+ * Use it inside `NotificationsMenu`'s default slot, or let `NotificationsMenu` render it from its `notifications` prop.
18
+ * The default slot holds extra content under the text (e.g. an action button); the `media` slot replaces the avatar / icon.
19
+ */
20
+
21
+ import type { HTMLAttributes } from "astro/types";
22
+ import { X } from "@lucide/astro";
23
+ import { cva } from "class-variance-authority";
24
+ import { cn } from "@cl-light-ds/ui/lib/utils";
25
+ import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
26
+ import { Button } from "../button";
27
+ import type { NotificationsMenuItemData } from "./types";
28
+
29
+ type Props = Omit<HTMLAttributes<"li">, "id" | "title"> &
30
+ NotificationsMenuItemData & {
31
+ /** Accessible name of the ✕. `{title}` is replaced by the notification's title. */
32
+ dismissLabel?: string;
33
+ /** Screen-reader text after a new notification. */
34
+ newLabel?: string;
35
+ class?: string;
36
+ };
37
+
38
+ const {
39
+ id,
40
+ title,
41
+ text,
42
+ context,
43
+ time,
44
+ datetime,
45
+ initials,
46
+ avatarSrc,
47
+ icon: Icon,
48
+ tone = "default",
49
+ new: isNew = false,
50
+ dismissLabel = "Dismiss: {title}",
51
+ newLabel = "(new)",
52
+ class: className = "",
53
+ ...rest
54
+ } = Astro.props as Props;
55
+
56
+ const hasMedia = Astro.slots.has("media");
57
+ const hasExtra = Astro.slots.has("default");
58
+
59
+ const iconChipVariants = cva(
60
+ "bg-muted flex size-6 shrink-0 items-center justify-center rounded-full [&_svg]:size-3.5",
61
+ {
62
+ variants: {
63
+ tone: {
64
+ default: "text-muted-foreground",
65
+ success: "text-success",
66
+ warning: "text-warning",
67
+ destructive: "text-destructive",
68
+ },
69
+ },
70
+ defaultVariants: { tone: "default" },
71
+ },
72
+ );
73
+ ---
74
+
75
+ <li
76
+ {...rest}
77
+ data-slot="notifications-menu-item"
78
+ data-notification={id}
79
+ data-new={isNew ? "true" : "false"}
80
+ class={cn(
81
+ "group/note relative flex items-start gap-3 border-b border-border p-3 pe-9 transition-colors last:border-b-0",
82
+ className,
83
+ )}
84
+ >
85
+ {
86
+ hasMedia ? (
87
+ <slot name="media" />
88
+ ) : initials || avatarSrc ? (
89
+ <Avatar size="sm" variant={avatarSrc ? "image" : "initials"}>
90
+ {avatarSrc && <AvatarImage src={avatarSrc} alt="" />}
91
+ <AvatarFallback>{initials}</AvatarFallback>
92
+ </Avatar>
93
+ ) : Icon ? (
94
+ <span class={iconChipVariants({ tone })} aria-hidden="true">
95
+ <Icon />
96
+ </span>
97
+ ) : null
98
+ }
99
+ <span class={cn("flex min-w-0 flex-1 flex-col", hasExtra ? "gap-1" : "gap-0.5")}>
100
+ <span data-slot="notifications-menu-item-title" class="text-foreground text-sm font-medium">{title}</span>
101
+ {text && <span class="text-muted-foreground text-xs">{text}</span>}
102
+ {hasExtra && <span data-slot="notifications-menu-item-extra"><slot /></span>}
103
+ {
104
+ (context || time) && (
105
+ <span class="text-muted-foreground/70 text-xs">
106
+ {context}
107
+ {context && time && " · "}
108
+ {time && (datetime ? <time datetime={datetime}>{time}</time> : time)}
109
+ </span>
110
+ )
111
+ }
112
+ </span>
113
+ {/* The "new" dot: centred vertically, clear of the ✕ (D154). Never alone: sr-only text too. */}
114
+ <span
115
+ data-slot="notifications-menu-item-dot"
116
+ class="bg-primary absolute top-1/2 right-3.5 hidden size-2 -translate-y-1/2 rounded-full group-focus-within/note:hidden group-hover/note:hidden group-data-[new=true]/note:block"
117
+ aria-hidden="true"
118
+ ></span>
119
+ <span class="sr-only group-data-[new=false]/note:hidden">{newLabel}</span>
120
+ <Button
121
+ variant="ghost"
122
+ size="icon-xs"
123
+ class="absolute top-2 right-2 opacity-0 group-focus-within/note:opacity-100 group-hover/note:opacity-100 pointer-coarse:opacity-100"
124
+ aria-label={dismissLabel.replace("{title}", title)}
125
+ data-notifications-dismiss
126
+ >
127
+ <X />
128
+ </Button>
129
+ </li>
@@ -0,0 +1,28 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="144" height="104" viewBox="-12 -12 144 104" fill="none" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo1ZTFjODE4MC05NTYxLTQ3YWYtOGYwMy0zNWJmODU4YjViN2EAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaC+NPxB7N/p9adnc2qsfPxgAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpjYjNhMTNlZS1kN2NlLTQyNGYtODBiYi1mM2E3MjEwNjRhZmZscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNotRHSjZb1m13eqMxg3CrdWwAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggaByci08iEbo8a7fyckB98mnsuZspV6glutU6CRYHq7ikZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaCE4XFoYfQdK2oaIn4KqoaMAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCBwyJmeK42jGvzuOwLqXoDrEDyRkMXdC7jb6T0G7aNtl2RuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBimZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjVlMWM4MTgwLTk1NjEtNDdhZi04ZjAzLTM1YmY4NThiNWI3YS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjQwNzRmMDY1LWM3ZGEtNDE5My04MmJkLTE5MTI2NjA1M2NkMnJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCBoHJyLTyIRujxrt/JyQH3yaey5mylXqCW61ToJFgeruKJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggDoC/w970Rz1Igqx1EVS7RMxcJhZaAy+qJS5QZcSbvbqiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggNp2qc1mvgSvJEClX2ZNnwW8h6YEISoyFzEQlrAgN7hl0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQOsPa7KlN7A+bxSZMbiwEHyIQO5N1z1/LriHBGTnTLVR2U8DysV/pK+fSfYixl2JUMqvt8cAN/cU5VG46RwGmuI=</c2pa:manifest></metadata>
2
+ <defs>
3
+ <filter id="sBack" x="-30%" y="-40%" width="160%" height="190%" color-interpolation-filters="sRGB">
4
+ <feDropShadow dx="0" dy="1.5" stdDeviation="2" flood-color="#051729" flood-opacity="0.05"></feDropShadow>
5
+ </filter>
6
+ <filter id="sFront" x="-40%" y="-40%" width="180%" height="190%" color-interpolation-filters="sRGB">
7
+ <feDropShadow dx="0" dy="5" stdDeviation="6" flood-color="#051729" flood-opacity="0.10"></feDropShadow>
8
+ </filter>
9
+ <filter id="sCheck" x="-60%" y="-60%" width="220%" height="220%" color-interpolation-filters="sRGB">
10
+ <feDropShadow dx="0" dy="3" stdDeviation="3" flood-color="#3785E0" flood-opacity="0.32"></feDropShadow>
11
+ </filter>
12
+ </defs>
13
+ <circle cx="60" cy="40" r="35" stroke="#F5F9FE" stroke-width="10"></circle>
14
+ <circle cx="60" cy="40" r="19" stroke="#EBF3FC" stroke-width="8"></circle>
15
+ <g filter="url(#sBack)"><rect x="22" y="12" width="76" height="18" rx="6" fill="#FFFFFF"></rect></g>
16
+ <rect x="22.5" y="12.5" width="75" height="17" rx="5.5" stroke="#09090B" stroke-opacity="0.06"></rect>
17
+ <circle cx="31" cy="21" r="4" fill="#F4F4F5"></circle>
18
+ <rect x="39" y="19" width="30" height="4" rx="2" fill="#F4F4F5"></rect>
19
+ <g filter="url(#sFront)"><rect x="16" y="34" width="88" height="24" rx="7" fill="#FFFFFF"></rect></g>
20
+ <rect x="16.5" y="34.5" width="87" height="23" rx="6.5" stroke="#09090B" stroke-opacity="0.06"></rect>
21
+ <circle cx="28" cy="46" r="6" fill="#EBF3FC"></circle>
22
+ <path d="M25.5 45.5 a2.5 2.5 0 0 1 5 0 c0 2.2 1 3 1 3 h-7 s1 -0.8 1 -3 Z M27.2 50.2 a1 1 0 0 0 1.6 0" stroke="#3785E0" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"></path>
23
+ <rect x="39" y="41" width="40" height="4" rx="2" fill="#E4E4E7"></rect>
24
+ <rect x="39" y="48.5" width="26" height="3.5" rx="1.75" fill="#F4F4F5"></rect>
25
+ <g filter="url(#sCheck)"><circle cx="100" cy="58" r="11" fill="#3785E0"></circle></g>
26
+ <circle cx="100" cy="58" r="11" stroke="#FFFFFF" stroke-width="2.5"></circle>
27
+ <path d="M95.5 58.2 l3 3 l6 -6" stroke="#FFFFFF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path>
28
+ </svg>
@@ -0,0 +1,3 @@
1
+ export { default as NotificationsMenu } from "./NotificationsMenu.astro";
2
+ export { default as NotificationsMenuItem } from "./NotificationsMenuItem.astro";
3
+ export * from "./types";
@@ -0,0 +1,32 @@
1
+ import type { AstroComponent } from "@lucide/astro";
2
+
3
+ /** Colour of a notification's icon chip. */
4
+ export type NotificationTone = "default" | "success" | "warning" | "destructive";
5
+
6
+ /**
7
+ * One notification. Everything except `icon` is JSON-serialisable, so a page
8
+ * can pass an API response straight into `<NotificationsMenu notifications={…} />`.
9
+ */
10
+ export interface NotificationsMenuItemData {
11
+ /** Stable id. Sent back in `notifications:dismiss` / `notifications:seen`. */
12
+ id: string;
13
+ title: string;
14
+ /** Second line, muted. */
15
+ text?: string;
16
+ /** Where it happened (a workspace, project, team…). Shown as "context · time". */
17
+ context?: string;
18
+ /** Display time, e.g. "2 min ago". */
19
+ time?: string;
20
+ /** Machine-readable time for the `<time>` element (ISO 8601). */
21
+ datetime?: string;
22
+ /** Initials for an Avatar. Takes precedence over `icon`. */
23
+ initials?: string;
24
+ /** Avatar image (with `initials` as its fallback). */
25
+ avatarSrc?: string;
26
+ /** A Lucide icon, shown in a round chip when there is no avatar. */
27
+ icon?: AstroComponent;
28
+ /** Colour of the icon chip. Default `"default"` (muted). */
29
+ tone?: NotificationTone;
30
+ /** Unseen: shows the blue dot and counts towards the bell's red dot. */
31
+ new?: boolean;
32
+ }
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `OptionCard` component will be documented in this file.
4
+
5
+ ## Date 15th Oct 2026
6
+ ## Workspace selection handover (N18)
7
+
8
+ ### Added
9
+
10
+ - `OptionCard`: a radio choice as a card, for use inside `RadioGroup` (which owns the value, roving focus, arrow keys, `role="radio"`/`aria-checked` and the hidden form input).
11
+ - Radio, bold title, optional `badge` slot beside it, muted description. `title`/`description` props, or `title`/`description` slots for rich text.
12
+ - Selected (`data-checked` on its radio): `border-primary` on `badge-accent` at 50%. No JavaScript of its own; the state is read with `has-[]` from the runtime's attributes.
13
+ - Optional nested content (default slot), shown only while the card is chosen, outside the `<label>` so it can hold its own controls. `content="inset"` (notes under the description after a divider, as Duplicate's link details), `"panel"` (white bordered panel, as Export's checklist) or `"plain"`.
14
+ - `disabled` dims the card and disables its radio; `id` goes on the radio item.
15
+ - Accent badges on a chosen card blend into its tint; give them `class="border-chart-1"` (C19). Not applied automatically, since `Badge` exposes no variant hook.
16
+ - Storybook: "Option Card" under Forms.