@w5-ui/svelte 0.2.1 → 0.4.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 (139) hide show
  1. package/README.md +116 -4
  2. package/package.json +2 -2
  3. package/src/lib/actions/portal.ts +55 -0
  4. package/src/lib/components/Accordion.svelte +1 -1
  5. package/src/lib/components/AccordionItem.svelte +6 -7
  6. package/src/lib/components/ActionMenu.svelte +17 -9
  7. package/src/lib/components/ActivityFeed.svelte +20 -14
  8. package/src/lib/components/Alert.svelte +22 -4
  9. package/src/lib/components/AppLayout.svelte +34 -0
  10. package/src/lib/components/Avatar.svelte +28 -11
  11. package/src/lib/components/AvatarGroup.svelte +21 -4
  12. package/src/lib/components/Badge.svelte +22 -6
  13. package/src/lib/components/Banner.svelte +36 -7
  14. package/src/lib/components/Body.svelte +29 -0
  15. package/src/lib/components/BodyLg.svelte +26 -0
  16. package/src/lib/components/Breadcrumb.svelte +19 -12
  17. package/src/lib/components/Button.svelte +30 -1
  18. package/src/lib/components/CIDRInput.svelte +17 -9
  19. package/src/lib/components/Callout.svelte +29 -8
  20. package/src/lib/components/Caption.svelte +26 -0
  21. package/src/lib/components/Card.svelte +11 -3
  22. package/src/lib/components/CardMore.svelte +20 -0
  23. package/src/lib/components/CardTitle.svelte +20 -0
  24. package/src/lib/components/Carousel.svelte +27 -20
  25. package/src/lib/components/Checkbox.svelte +24 -3
  26. package/src/lib/components/Code.svelte +24 -0
  27. package/src/lib/components/CodeBlock.svelte +8 -7
  28. package/src/lib/components/ColorPicker.svelte +13 -13
  29. package/src/lib/components/ColumnToggle.svelte +10 -9
  30. package/src/lib/components/Combobox.svelte +31 -17
  31. package/src/lib/components/CommandPalette.svelte +42 -26
  32. package/src/lib/components/ConfirmDialog.svelte +21 -15
  33. package/src/lib/components/ContextMenu.svelte +12 -12
  34. package/src/lib/components/ContextualHelp.svelte +11 -6
  35. package/src/lib/components/CopyButton.svelte +2 -1
  36. package/src/lib/components/DatePicker.svelte +27 -23
  37. package/src/lib/components/DateRangePicker.svelte +46 -39
  38. package/src/lib/components/Delta.svelte +25 -0
  39. package/src/lib/components/Display1.svelte +29 -0
  40. package/src/lib/components/Display2.svelte +29 -0
  41. package/src/lib/components/Drawer.svelte +6 -5
  42. package/src/lib/components/DurationInput.svelte +16 -8
  43. package/src/lib/components/EmptyState.svelte +16 -7
  44. package/src/lib/components/ExpandableRow.svelte +8 -15
  45. package/src/lib/components/Eyebrow.svelte +25 -0
  46. package/src/lib/components/Field.svelte +7 -7
  47. package/src/lib/components/FieldStack.svelte +18 -0
  48. package/src/lib/components/FileUpload.svelte +8 -10
  49. package/src/lib/components/FilterBuilder.svelte +52 -28
  50. package/src/lib/components/FormActions.svelte +18 -0
  51. package/src/lib/components/FormRow.svelte +17 -0
  52. package/src/lib/components/Grid.svelte +17 -0
  53. package/src/lib/components/GroupedList.svelte +35 -21
  54. package/src/lib/components/H1.svelte +29 -0
  55. package/src/lib/components/H2.svelte +29 -0
  56. package/src/lib/components/H3.svelte +29 -0
  57. package/src/lib/components/H4.svelte +29 -0
  58. package/src/lib/components/HealthBar.svelte +6 -3
  59. package/src/lib/components/HoverCard.svelte +16 -5
  60. package/src/lib/components/IPInput.svelte +14 -8
  61. package/src/lib/components/IconButton.svelte +1 -2
  62. package/src/lib/components/InlineEdit.svelte +25 -14
  63. package/src/lib/components/Input.svelte +13 -4
  64. package/src/lib/components/InputGroup.svelte +14 -3
  65. package/src/lib/components/JsonViewer.svelte +7 -2
  66. package/src/lib/components/JsonViewerNode.svelte +38 -32
  67. package/src/lib/components/KVTable.svelte +10 -4
  68. package/src/lib/components/KanbanBoard.svelte +29 -27
  69. package/src/lib/components/Kbd.svelte +8 -5
  70. package/src/lib/components/Label.svelte +28 -0
  71. package/src/lib/components/LogViewer.svelte +24 -20
  72. package/src/lib/components/MACInput.svelte +14 -8
  73. package/src/lib/components/Mac.svelte +23 -0
  74. package/src/lib/components/Menubar.svelte +30 -13
  75. package/src/lib/components/Modal.svelte +15 -6
  76. package/src/lib/components/Mono.svelte +28 -0
  77. package/src/lib/components/MultiSelect.svelte +43 -23
  78. package/src/lib/components/Muted.svelte +24 -0
  79. package/src/lib/components/NameCell.svelte +35 -0
  80. package/src/lib/components/NotifDot.svelte +23 -0
  81. package/src/lib/components/NotificationPanel.svelte +44 -31
  82. package/src/lib/components/NumberInput.svelte +13 -12
  83. package/src/lib/components/OTPInput.svelte +14 -8
  84. package/src/lib/components/PageHeader.svelte +25 -0
  85. package/src/lib/components/Pagination.svelte +16 -12
  86. package/src/lib/components/PasswordInput.svelte +8 -7
  87. package/src/lib/components/Pill.svelte +34 -6
  88. package/src/lib/components/Popover.svelte +101 -17
  89. package/src/lib/components/ProgressBar.svelte +10 -7
  90. package/src/lib/components/RadioGroup.svelte +22 -7
  91. package/src/lib/components/RangeSlider.svelte +17 -12
  92. package/src/lib/components/RankedList.svelte +12 -18
  93. package/src/lib/components/ResizablePanel.svelte +25 -14
  94. package/src/lib/components/RowToggle.svelte +6 -4
  95. package/src/lib/components/SearchBox.svelte +6 -2
  96. package/src/lib/components/SegmentedControl.svelte +16 -2
  97. package/src/lib/components/Select.svelte +20 -8
  98. package/src/lib/components/SelectionToolbar.svelte +13 -9
  99. package/src/lib/components/Sidebar.svelte +32 -11
  100. package/src/lib/components/Signal.svelte +3 -2
  101. package/src/lib/components/Skeleton.svelte +21 -9
  102. package/src/lib/components/SkipLink.svelte +5 -2
  103. package/src/lib/components/Slider.svelte +16 -9
  104. package/src/lib/components/SortHeader.svelte +13 -4
  105. package/src/lib/components/SortableList.svelte +17 -18
  106. package/src/lib/components/Sparkline.svelte +20 -14
  107. package/src/lib/components/Spinner.svelte +16 -1
  108. package/src/lib/components/SplitButton.svelte +27 -8
  109. package/src/lib/components/Spoiler.svelte +8 -6
  110. package/src/lib/components/SrOnly.svelte +23 -0
  111. package/src/lib/components/StackedProgress.svelte +17 -9
  112. package/src/lib/components/StarRating.svelte +35 -10
  113. package/src/lib/components/Stat.svelte +19 -5
  114. package/src/lib/components/StatNumber.svelte +22 -0
  115. package/src/lib/components/StatusIndicator.svelte +9 -2
  116. package/src/lib/components/StatusRing.svelte +36 -0
  117. package/src/lib/components/Stepper.svelte +30 -8
  118. package/src/lib/components/Submeta.svelte +19 -0
  119. package/src/lib/components/SwitchPortGrid.svelte +20 -7
  120. package/src/lib/components/TablePager.svelte +22 -0
  121. package/src/lib/components/TableShell.svelte +19 -0
  122. package/src/lib/components/Tabs.svelte +16 -5
  123. package/src/lib/components/Tag.svelte +5 -3
  124. package/src/lib/components/TagInput.svelte +9 -10
  125. package/src/lib/components/Textarea.svelte +7 -1
  126. package/src/lib/components/TimePicker.svelte +14 -8
  127. package/src/lib/components/TimeRange.svelte +20 -22
  128. package/src/lib/components/Timeline.svelte +20 -9
  129. package/src/lib/components/Toast.svelte +24 -5
  130. package/src/lib/components/Toggle.svelte +10 -4
  131. package/src/lib/components/ToggleGroup.svelte +15 -5
  132. package/src/lib/components/Tooltip.svelte +20 -5
  133. package/src/lib/components/Topbar.svelte +161 -28
  134. package/src/lib/components/TopbarActions.svelte +52 -0
  135. package/src/lib/components/TransferList.svelte +30 -22
  136. package/src/lib/components/TreeItem.svelte +11 -13
  137. package/src/lib/components/Unit.svelte +22 -0
  138. package/src/lib/components/VirtualList.svelte +12 -9
  139. package/src/lib/index.ts +37 -0
package/README.md CHANGED
@@ -1,8 +1,120 @@
1
1
  # @w5-ui/svelte
2
2
 
3
- Svelte component library for the Dash UI design system. Mirrors `@w5-ui/react` 1:1.
3
+ Svelte component library for the Dash UI design system. Mirrors `@w5-ui/react` 1:1. Pairs with
4
+ `@w5-ui/tokens` for design tokens and `@w5-ui/assets` for brand SVGs.
4
5
 
5
- ```ts
6
- import '@w5-ui/svelte/styles.css'
7
- import { Card, Pill, Button, Topbar, Sidebar } from '@w5-ui/svelte'
6
+ ```bash
7
+ yarn add @w5-ui/svelte
8
8
  ```
9
+
10
+ ## Quick start
11
+
12
+ ```svelte
13
+ <script lang="ts">
14
+ import '@w5-ui/svelte/styles.css' // ships @w5-ui/tokens/dashboard.css
15
+ import { AppLayout, Topbar, Sidebar, PageHeader, Card, Stat, Pill } from '@w5-ui/svelte'
16
+ </script>
17
+
18
+ <AppLayout pageLabel="Dashboard">
19
+ <Topbar slot="topbar" siteName="HQ" activeApp="system" />
20
+ <Sidebar slot="sidebar" sections={SECTIONS} activeId={page} on:change={(e) => (page = e.detail)} />
21
+
22
+ <PageHeader title="Dashboard" />
23
+ <div class="grid">
24
+ <Stat span={4} label="Internet" value="847" unit="Mbps" />
25
+ <Stat span={4} label="Clients" value="124" />
26
+ <Stat span={4} label="Throughput" value="12.4" unit="Gbps" />
27
+ <Card span={8}>
28
+ <h3>
29
+ Traffic <Pill variant="success">healthy</Pill>
30
+ </h3>
31
+ </Card>
32
+ <Card span={4}>
33
+ <h3>Top clients</h3>
34
+ </Card>
35
+ </div>
36
+ </AppLayout>
37
+ ```
38
+
39
+ That single skeleton (`AppLayout` then `PageHeader` then `<div class="grid">` then
40
+ `<Card span={n}>`) carries every padding, gap, and column value the reference dashboards use.
41
+ Reproduce it verbatim and spacing falls into place automatically.
42
+
43
+ ## The contract (read this before styling anything)
44
+
45
+ The reference dashboards (`apps/dashboard-svelte`, rendered in Storybook under **Dashboard**) all
46
+ share one skeleton. Userland looking different almost always comes down to one of these:
47
+
48
+ | Don't | Do |
49
+ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
50
+ | Wrap `.grid` in your own `<div style="padding: ...">` | Render `.grid` as a direct child of `<main>`. It already pads 16px x 24px. |
51
+ | `display: flex; gap: ...` on the page root | Render `<Banner>`, `<PageHeader>`, `.grid` as siblings. They own their spacing. |
52
+ | Re-implement `.card` with a `<div>` and a custom border-radius | Use `<Card>` or `<Stat>`. The radius, padding, and `<h3>` rhythm ship together. |
53
+ | `<h3>` outside a `<Card>` | Keep titles inside `<Card>`. The `.card h3` selector sizes and colours them. |
54
+ | `<span style="color: green">online</span>` | Use `<Pill variant="success">online</Pill>` (also `<Badge>`, `<StatusIndicator>`). |
55
+ | Mix `span={5}` and `span={7}` | Use 4 / 6 / 8 / 12 so rows divide cleanly into the 12-column grid. |
56
+ | Skip `AppLayout` and roll your own `.app` grid | Use `AppLayout` once at the root. `.app`, `.workspace`, and `<main>` line up. |
57
+ | Hand-pick hex colours | Use tokens via the `data-motif` CSS variables. See `@w5-ui/tokens`. |
58
+
59
+ ## Grid math
60
+
61
+ `.grid` is `repeat(12, 1fr)` with a 12px gap and 16px x 24px outer padding.
62
+
63
+ | Tile width | `span` | Typical use |
64
+ | ---------- | ------ | ------------------------------------ |
65
+ | Third | `4` | Single-stat card, donut |
66
+ | Half | `6` | Two-column comparison |
67
+ | Two-thirds | `8` | Wide table, line chart |
68
+ | Full | `12` | Time series, sankey, page-wide table |
69
+
70
+ 4+4+4, 8+4, 6+6, and 12 all snap cleanly. Avoid 5 and 7. They leave dead pixels and force the next
71
+ row to wrap unevenly.
72
+
73
+ ## What lives where
74
+
75
+ | Package | Description |
76
+ | --------------- | -------------------------------------------------------- |
77
+ | `@w5-ui/svelte` | ~170 Svelte components (this package) |
78
+ | `@w5-ui/react` | The same component surface for React |
79
+ | `@w5-ui/tokens` | CSS variables (`data-motif="dark"` / `"light"`) + JS map |
80
+ | `@w5-ui/assets` | Brand mark, wordmark, app-rail SVGs |
81
+
82
+ `@w5-ui/svelte/styles.css` re-exports `@w5-ui/tokens/dashboard.css`, so importing it once is enough
83
+ for tokens, the `.app` / `.workspace` / `.grid` / `.card` classes, and component styles.
84
+
85
+ ## Themes
86
+
87
+ Two motifs ship in tokens:
88
+
89
+ ```html
90
+ <html data-motif="dark">
91
+ <!-- the default -->
92
+ </html>
93
+ <html data-motif="light"></html>
94
+ ```
95
+
96
+ Set the attribute on `<html>` (or any ancestor of the app root) to switch.
97
+
98
+ ## CSS hooks you can rely on
99
+
100
+ Stable class names that ship from `@w5-ui/tokens/dashboard.css`:
101
+
102
+ - `.app`, `.workspace`, `.content`: outer chrome, wired up by `AppLayout`
103
+ - `.grid`: 12-column page grid with the canonical gutter
104
+ - `.card`, `.card h3`: tile shell and title rhythm. Use through `<Card>` or `<Stat>`.
105
+ - `.ph-bar`, `.ph-title`, `.ph-actions`: page header primitives. Use through `<PageHeader>`.
106
+ - `.field-stack`, `.form-row`, `.form-actions`: form rhythm primitives
107
+ - `.mac`, `.name-cell`: monospace and avatar-style cell layouts
108
+
109
+ Anything else (raw colours, ad-hoc paddings) should come from tokens, not bespoke values.
110
+
111
+ ## Reference
112
+
113
+ - **Storybook (Svelte)**: `/svelte/` in the published site
114
+ - **Foundations / Page layout**: the canonical recipe in full, with anatomy and pitfalls
115
+ - **Foundations / Spacing**: the 12-token spacing scale and rhythm rules
116
+ - **Foundations / Colours**, **Type**, **Icons**, **Forms**, **Tables**, **Charts**, **Status &
117
+ feedback**, **Navigation**, **Overlays**, **Actions**: pick-by-question tables for every primitive
118
+
119
+ The Storybook **Dashboard** section renders the reference pages 1:1. When your page looks different,
120
+ open the matching story and diff the markup.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@w5-ui/svelte",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Svelte 5 components for the Dash UI design system - mirrors the @w5-ui/react API 1:1. Part of Webber's design systems.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -48,7 +48,7 @@
48
48
  "@w5-ui/tokens": "^0.2.1"
49
49
  },
50
50
  "devDependencies": {
51
- "svelte": "^5.43.5",
51
+ "svelte": "^5.55.8",
52
52
  "typescript": "^5.5.0"
53
53
  },
54
54
  "files": [
@@ -0,0 +1,55 @@
1
+ import type { Action } from 'svelte/action'
2
+
3
+ /**
4
+ * Move a DOM node into `target` (default: `document.body`) for the
5
+ * lifetime of the action. Used by floating UI primitives so
6
+ * `position: fixed` + `z-index` always escape:
7
+ *
8
+ * - ancestor `overflow: hidden` / `overflow: clip` (would clip
9
+ * a non-portalled child even at `position: fixed` if the
10
+ * ancestor establishes a containing block)
11
+ * - ancestor `transform`, `filter`, `perspective`, `contain: paint`,
12
+ * `will-change: transform` (any of which create a containing
13
+ * block for `position: fixed` descendants, defeating viewport
14
+ * positioning)
15
+ * - ancestor stacking contexts with a lower `z-index` than the
16
+ * popover's `z-index`, which would otherwise trap it underneath
17
+ * sibling chrome
18
+ *
19
+ * The node still belongs to the Svelte component that owns it: props,
20
+ * reactivity, lifecycle, and event handlers continue to work; only
21
+ * the DOM position changes. The action removes the node on destroy
22
+ * so cleanup is automatic.
23
+ *
24
+ * SSR-safe: actions don't run during server rendering.
25
+ */
26
+ export const portal: Action<HTMLElement, HTMLElement | string | undefined> = (
27
+ node,
28
+ target = 'body',
29
+ ) => {
30
+ const resolveTarget = (value: HTMLElement | string | undefined): HTMLElement | null => {
31
+ if (value === undefined) return document.body
32
+ if (typeof value === 'string') {
33
+ return document.querySelector<HTMLElement>(value)
34
+ }
35
+ return value
36
+ }
37
+
38
+ const move = (value: HTMLElement | string | undefined) => {
39
+ const parent = resolveTarget(value)
40
+ if (parent !== null && parent !== node.parentNode) {
41
+ parent.appendChild(node)
42
+ }
43
+ }
44
+
45
+ move(target)
46
+
47
+ return {
48
+ update(next: HTMLElement | string | undefined) {
49
+ move(next)
50
+ },
51
+ destroy() {
52
+ node.remove()
53
+ },
54
+ }
55
+ }
@@ -6,6 +6,6 @@
6
6
  let { children }: Props = $props();
7
7
  </script>
8
8
 
9
- <div class="accordion">
9
+ <div class="flex flex-col">
10
10
  {@render children?.()}
11
11
  </div>
@@ -14,31 +14,30 @@
14
14
  const panelId = `acc-panel-${uid}`;
15
15
  </script>
16
16
 
17
- <div class="accordion-item">
18
- <h3 class="accordion-header">
17
+ <div class="border-b border-border-1 last:border-b-0">
18
+ <h3 class="m-0">
19
19
  <button
20
20
  id={btnId}
21
21
  type="button"
22
- class="accordion-trigger"
23
22
  aria-expanded={open}
24
23
  aria-controls={panelId}
24
+ class="flex w-full cursor-pointer items-center justify-between gap-2.5 border-0 bg-transparent py-2.5 text-left text-13 font-medium leading-[1.4] text-text-1 transition-colors duration-100 hover:text-text-link focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-05"
25
25
  onclick={() => (open = !open)}
26
26
  >
27
27
  <span>{title}</span>
28
28
  <svg
29
- class="accordion-chevron"
30
- class:open
31
29
  viewBox="0 0 16 16"
32
30
  fill="none"
33
31
  aria-hidden="true"
32
+ class="h-3.5 w-3.5 shrink-0 text-text-4 transition-transform duration-200 motion-reduce:transition-none {open ? 'rotate-180' : ''}"
34
33
  >
35
34
  <path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
36
35
  </svg>
37
36
  </button>
38
37
  </h3>
39
38
  {#if open}
40
- <div id={panelId} role="region" aria-labelledby={btnId} class="accordion-panel">
41
- <div class="accordion-content">
39
+ <div id={panelId} role="region" aria-labelledby={btnId}>
40
+ <div class="pb-2.5">
42
41
  {@render children?.()}
43
42
  </div>
44
43
  </div>
@@ -1,6 +1,11 @@
1
1
  <script module lang="ts">
2
2
  let counter = 0;
3
- export interface ActionMenuItem { id: string; label: string; danger?: boolean; disabled?: boolean; }
3
+ export interface ActionMenuItem {
4
+ id: string;
5
+ label: string;
6
+ danger?: boolean;
7
+ disabled?: boolean;
8
+ }
4
9
  </script>
5
10
 
6
11
  <script lang="ts">
@@ -12,9 +17,7 @@
12
17
  onaction?: (payload: string) => void;
13
18
  }
14
19
 
15
- let { items = [], label = 'Actions',
16
- onaction,
17
- }: Props = $props();
20
+ let { items = [], label = 'Actions', onaction }: Props = $props();
18
21
  const uid = `dash-ui-am-${++counter}`;
19
22
  const menuId = `${uid}-menu`;
20
23
 
@@ -64,15 +67,15 @@
64
67
  onDestroy(() => document.removeEventListener('mousedown', handleOutside));
65
68
  </script>
66
69
 
67
- <div class="action-menu-root" bind:this={wrapperEl}>
70
+ <div class="group/am relative inline-block" bind:this={wrapperEl}>
68
71
  <button
69
72
  bind:this={triggerEl}
70
73
  type="button"
71
- class="icon-btn action-menu-trigger"
72
74
  aria-label={label}
73
75
  aria-haspopup="menu"
74
76
  aria-expanded={open}
75
77
  aria-controls={open ? menuId : undefined}
78
+ class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-md bg-transparent text-text-3 opacity-0 transition-opacity duration-100 hover:bg-row-hover hover:text-text-1 focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-05 aria-expanded:opacity-100 group-hover/am:opacity-100 motion-reduce:transition-none"
76
79
  onclick={toggle}
77
80
  onkeydown={onKeyDown}
78
81
  >
@@ -83,15 +86,20 @@
83
86
  </svg>
84
87
  </button>
85
88
  {#if open}
86
- <ul id={menuId} role="menu" aria-label={label} class="action-menu">
89
+ <ul
90
+ id={menuId}
91
+ role="menu"
92
+ aria-label={label}
93
+ class="absolute right-0 top-[calc(100%+4px)] z-[9100] m-0 min-w-[148px] list-none rounded-lg border border-border-3 bg-bg-2 p-1 shadow-[0_8px_24px_rgba(0,0,0,0.5)]"
94
+ >
87
95
  {#each items as item, idx (item.id)}
88
96
  <li
89
97
  role="menuitem"
90
- tabindex="-1"
98
+ tabindex={-1}
91
99
  aria-disabled={item.disabled}
92
100
  data-active={idx === activeIdx ? 'true' : undefined}
93
101
  data-danger={item.danger ? 'true' : undefined}
94
- class="action-menu-item"
102
+ class="flex cursor-pointer items-center whitespace-nowrap rounded-[5px] px-2.5 py-1.5 text-13 text-text-2 outline-none hover:bg-row-active hover:text-text-1 data-[active=true]:bg-row-active data-[active=true]:text-text-1 data-[danger=true]:text-status-danger data-[danger=true]:hover:bg-status-danger/10 aria-disabled:cursor-not-allowed aria-disabled:opacity-40"
95
103
  onmouseenter={() => { activeIdx = idx; }}
96
104
  onmousedown={(e) => { e.preventDefault(); (() => { if (!item.disabled) activate(item.id); })(); }}
97
105
  >{item.label}</li>
@@ -28,46 +28,52 @@
28
28
 
29
29
  let endEl = $state<HTMLDivElement | undefined>(undefined);
30
30
 
31
- // Replaces `afterUpdate`. Reading `items.length` makes the effect depend
32
- // on the visible list so we re-scroll whenever an item is appended.
33
31
  $effect(() => {
34
32
  void items.length;
35
33
  if (autoScroll && endEl) endEl.scrollIntoView({ block: 'nearest' });
36
34
  });
35
+
36
+ const SEVERITY_DOT: Record<ActivitySeverity, string> = {
37
+ info: 'bg-brand-05',
38
+ success: 'bg-status-success',
39
+ warn: 'bg-status-warning',
40
+ error: 'bg-status-danger',
41
+ neutral: 'bg-status-neutral',
42
+ };
37
43
  </script>
38
44
 
39
45
  <div
40
46
  role="feed"
41
47
  aria-label={label}
42
48
  aria-busy={busy}
43
- class="activity-feed"
49
+ class="flex flex-col gap-1 overflow-y-auto"
44
50
  style:max-height="{maxHeight}px"
45
- style:overflow-y="auto"
46
51
  >
47
52
  {#each items as item, i (item.id)}
48
- <!-- svelte-ignore a11y_no_noninteractive_tabindex - navigable feed item for keyboard users -->
53
+ {@const sev = item.severity ?? 'neutral'}
54
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
49
55
  <article
50
- class="af-item af-item--{item.severity ?? 'neutral'}"
51
56
  aria-labelledby="af-title-{item.id}"
52
57
  aria-describedby={item.description ? `af-desc-${item.id}` : undefined}
53
58
  aria-posinset={i + 1}
54
59
  aria-setsize={items.length}
55
- tabindex="0"
60
+ tabindex={0}
61
+ class="flex items-start gap-2 rounded-md px-2 py-1.5 text-13 hover:bg-divider focus-visible:bg-row-hover focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-brand-05"
56
62
  >
57
- <div class="af-dot" aria-hidden="true"></div>
58
- <div class="af-body">
59
- <div class="af-header">
60
- <span id="af-title-{item.id}" class="af-title">{item.title}</span>
61
- <span class="af-time">{item.time}</span>
63
+ <div class="mt-1.5 h-2 w-2 shrink-0 rounded-full {SEVERITY_DOT[sev]}" aria-hidden="true"></div>
64
+ <div class="min-w-0 flex-1">
65
+ <div class="flex items-baseline justify-between gap-2">
66
+ <span id="af-title-{item.id}" class="truncate font-medium text-text-1">{item.title}</span>
67
+ <span class="shrink-0 text-11 text-text-4 tabular-nums">{item.time}</span>
62
68
  </div>
63
69
  {#if item.description}
64
- <p id="af-desc-{item.id}" class="af-desc">{item.description}</p>
70
+ <p id="af-desc-{item.id}" class="m-0 mt-0.5 text-12 text-text-3">{item.description}</p>
65
71
  {/if}
66
72
  </div>
67
73
  </article>
68
74
  {/each}
69
75
  {#if items.length === 0}
70
- <div class="af-empty" role="status">No activity</div>
76
+ <div class="py-6 text-center text-12 text-text-4" role="status">No activity</div>
71
77
  {/if}
72
78
  <div bind:this={endEl}></div>
73
79
  </div>
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
2
  import CloseIcon from '../icons/CloseIcon.svelte';
3
+ import IconButton from './IconButton.svelte';
4
+
3
5
  interface Props {
4
6
  variant?: 'success' | 'warn' | 'danger' | 'info';
5
7
  onDismiss?: (() => void) | undefined;
@@ -7,18 +9,34 @@
7
9
  }
8
10
 
9
11
  let { variant = 'info', onDismiss = undefined, children }: Props = $props();
12
+
13
+ // Variant strings statically composed so Tailwind's scanner picks them
14
+ // up. Values match dashboard.css exactly: 10% bg tint + left-border in
15
+ // the variant base colour + hand-tuned readable text colour.
16
+ const VARIANT: Record<NonNullable<Props['variant']>, string> = {
17
+ success: 'bg-status-success/10 border-l-status-success text-status-success',
18
+ warn: 'bg-status-warning/10 border-l-status-warning text-status-warning',
19
+ danger: 'bg-status-danger/10 border-l-status-danger text-status-danger',
20
+ info: 'bg-status-info/10 border-l-brand-05 text-status-info',
21
+ };
10
22
  </script>
11
23
 
12
24
  <div
13
- class="alert alert-{variant}"
25
+ class="flex items-center gap-2.5 rounded-lg border-l-[3px] px-3.5 py-2.5 text-13 leading-[1.4]
26
+ {VARIANT[variant]}"
14
27
  role={variant === 'danger' ? 'alert' : 'status'}
15
28
  aria-live={variant === 'danger' ? 'assertive' : 'polite'}
16
29
  aria-atomic="true"
17
30
  >
18
- <span class="alert-body">{@render children?.()}</span>
31
+ <span class="flex-1">{@render children?.()}</span>
19
32
  {#if onDismiss}
20
- <button type="button" class="alert-dismiss icon-btn" onclick={onDismiss} aria-label="Dismiss alert">
33
+ <IconButton
34
+ title="Dismiss alert"
35
+ onclick={onDismiss}
36
+ class="ml-auto h-6 w-6 shrink-0"
37
+ aria-label="Dismiss alert"
38
+ >
21
39
  <CloseIcon />
22
- </button>
40
+ </IconButton>
23
41
  {/if}
24
42
  </div>
@@ -0,0 +1,34 @@
1
+ <script lang="ts">
2
+ import SkipLink from './SkipLink.svelte';
3
+
4
+ interface Props {
5
+ /** Page title for the visually-hidden h1 that labels main (WCAG 2.4.6). */
6
+ pageLabel: string;
7
+ class?: string;
8
+ /** Topbar snippet, typically a Topbar component. */
9
+ topbar?: import('svelte').Snippet;
10
+ /** Sidebar snippet, typically a Sidebar component. */
11
+ sidebar?: import('svelte').Snippet;
12
+ children?: import('svelte').Snippet;
13
+ }
14
+
15
+ let {
16
+ pageLabel,
17
+ class: className = '',
18
+ topbar,
19
+ sidebar,
20
+ children,
21
+ }: Props = $props();
22
+ </script>
23
+
24
+ <div class="app {className}">
25
+ <SkipLink />
26
+ {@render topbar?.()}
27
+ <div class="workspace">
28
+ {@render sidebar?.()}
29
+ <main class="content" id="main-content" tabindex="-1" aria-labelledby="page-title">
30
+ <h1 id="page-title" class="sr-only">{pageLabel}</h1>
31
+ {@render children?.()}
32
+ </main>
33
+ </div>
34
+ </div>
@@ -10,7 +10,8 @@
10
10
  alt?: string | undefined;
11
11
  size?: AvatarSize;
12
12
  status?: AvatarStatus | undefined;
13
- className?: string;
13
+ /** Extra Tailwind utilities to compose with the avatar root. */
14
+ class?: string;
14
15
  style?: string;
15
16
  }
16
17
 
@@ -20,23 +21,39 @@
20
21
  alt = undefined,
21
22
  size = 'md',
22
23
  status = undefined,
23
- className = '',
24
- style = ''
24
+ class: className = '',
25
+ style = '',
25
26
  }: Props = $props();
27
+
28
+ // Pre-compose class strings so Tailwind's static scanner picks them up.
29
+ const SIZE: Record<AvatarSize, string> = {
30
+ sm: 'h-6 w-6 text-[9px]',
31
+ md: 'h-8 w-8 text-[12px]',
32
+ lg: 'h-10 w-10 text-[16px]',
33
+ };
34
+ const STATUS_BG: Record<AvatarStatus, string> = {
35
+ online: 'bg-status-success',
36
+ offline: 'bg-status-neutral',
37
+ away: 'bg-status-warning',
38
+ };
39
+
40
+ // Hardcoded neutral chrome to match dashboard.css's literal #4a4b53 / #fff,
41
+ // independent of motif. See the comment in Topbar.svelte for rationale.
42
+ let rootClass = $derived(
43
+ `relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-neutral-06 font-semibold text-white ${SIZE[size]} ${className}`,
44
+ );
26
45
  </script>
27
46
 
28
- <span
29
- class="avatar avatar-{size}{className ? ` ${className}` : ''}"
30
- role="img"
31
- aria-label={alt ?? (src ? '' : initials)}
32
- {style}
33
- >
47
+ <span class={rootClass} role="img" aria-label={alt ?? (src ? '' : initials)} {style}>
34
48
  {#if src}
35
- <img {src} alt="" />
49
+ <img {src} alt="" class="block h-full w-full object-cover" />
36
50
  {:else}
37
51
  {initials}
38
52
  {/if}
39
53
  {#if status}
40
- <span class="avatar-status avatar-status-{status}" aria-hidden="true"></span>
54
+ <span
55
+ class="absolute bottom-0 right-0 h-2 w-2 rounded-full border-[1.5px] border-bg-page {STATUS_BG[status]}"
56
+ aria-hidden="true"
57
+ ></span>
41
58
  {/if}
42
59
  </span>
@@ -21,23 +21,40 @@
21
21
  avatars = [],
22
22
  max = 5,
23
23
  size = 'md',
24
- ariaLabel = 'Avatar group'
24
+ ariaLabel = 'Avatar group',
25
25
  }: Props = $props();
26
26
 
27
27
  let visible = $derived(avatars.slice(0, max));
28
28
  let overflow = $derived(avatars.length - visible.length);
29
+
30
+ const SIZE: Record<AvatarSize, string> = {
31
+ sm: 'h-6 w-6 text-[9px]',
32
+ md: 'h-8 w-8 text-[12px]',
33
+ lg: 'h-10 w-10 text-[16px]',
34
+ };
35
+ // Group avatars overlap by 8px to the left and add a 2px bg-coloured ring
36
+ // to read clearly when stacked. Ring uses the chrome's literal
37
+ // #0a0a0b (neutral-10) to match dashboard.css.
38
+ const OVERLAP =
39
+ '[&_>*:not(:last-child)>span]:-ml-2 [&_>*>span]:border-2 [&_>*>span]:border-bg-page';
29
40
  </script>
30
41
 
31
- <span class="avatar-group" role="list" aria-label={ariaLabel}>
42
+ <span
43
+ class="inline-flex flex-row-reverse items-center {OVERLAP}"
44
+ role="list"
45
+ aria-label={ariaLabel}
46
+ >
32
47
  {#each visible as av, i (i)}
33
- <span role="listitem" class="avatar-group-item">
48
+ <span role="listitem" class="contents">
34
49
  <Avatar initials={av.initials} src={av.src} alt={av.alt} {size} />
35
50
  </span>
36
51
  {/each}
37
52
  {#if overflow > 0}
38
53
  <span
39
54
  role="listitem"
40
- class="avatar avatar-{size} avatar-overflow"
55
+ class="relative inline-flex shrink-0 select-none items-center justify-center overflow-visible rounded-full border-2 border-bg-page bg-neutral-07 font-bold text-text-3 {SIZE[
56
+ size
57
+ ]}"
41
58
  aria-label="{overflow} more"
42
59
  >+{overflow}</span>
43
60
  {/if}
@@ -20,19 +20,35 @@
20
20
  showZero = false,
21
21
  color = 'danger',
22
22
  class: klass = '',
23
- children
23
+ children,
24
24
  }: Props = $props();
25
-
26
25
 
27
26
  let show = $derived(dot || (count !== undefined && (showZero || count > 0)));
28
27
  let label = $derived(count !== undefined ? (count > max ? `${max}+` : String(count)) : '');
28
+
29
+ // Variant strings pre-composed for Tailwind's static scanner. Warn keeps
30
+ // dashboard.css's `color: #111` (legible on the orange chip).
31
+ const COLOR: Record<BadgeColor, string> = {
32
+ danger: 'bg-status-danger text-white',
33
+ // Status-warning is yellow in both motifs; near-black text stays
34
+ // legible against it. `text-neutral-10` resolves to `#0a0a0b`.
35
+ warn: 'bg-status-warning text-neutral-10',
36
+ info: 'bg-brand-05 text-white',
37
+ success: 'bg-status-success text-white',
38
+ neutral: 'bg-status-neutral text-white',
39
+ };
29
40
  </script>
30
41
 
31
- <span class="badge-wrapper{klass ? ` ${klass}` : ''}">
42
+ <span class="relative inline-flex {klass}">
32
43
  {@render children?.()}
33
44
  {#if show}
34
- <span class="badge badge-{color}{dot ? ' badge-dot' : ''}" aria-hidden="true">
35
- {#if !dot}{label}{/if}
36
- </span>
45
+ <span
46
+ class="pointer-events-none absolute z-10 flex items-center justify-center whitespace-nowrap rounded-lg border-[1.5px] border-bg-page text-[10px] font-bold leading-none
47
+ {COLOR[color]}
48
+ {dot
49
+ ? '-right-0.5 -top-0.5 h-2 w-2 min-w-2 p-0'
50
+ : '-right-1.5 -top-1 h-4 min-w-4 px-1'}"
51
+ aria-hidden="true"
52
+ >{#if !dot}{label}{/if}</span>
37
53
  {/if}
38
54
  </span>