@heyramzi/ui 2.23.0 → 2.24.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 (123) hide show
  1. package/COMPONENTS.md +3 -6
  2. package/dist/ai/confirmation/index.js +3 -6
  3. package/dist/ai/index.js +1 -5
  4. package/dist/charts/dashboard/index.js +1 -3
  5. package/dist/charts/primitives/chart-utils.js +14 -0
  6. package/dist/components/chip/index.js +1 -0
  7. package/dist/components/language-flag/flags.js +5 -0
  8. package/dist/components/language-flag/index.js +1 -0
  9. package/dist/components/list-page/index.js +1 -3
  10. package/dist/components/work-parts/index.js +1 -1
  11. package/dist/editor/doc-blocks.js +2 -1
  12. package/dist/effects/animated-beam/path-calculator.svelte.js +13 -6
  13. package/dist/lib/dates.js +41 -0
  14. package/dist/lib/panel.js +2 -2
  15. package/dist/lib/tone.js +13 -0
  16. package/dist/record/format.js +2 -18
  17. package/dist/record/logic.js +3 -6
  18. package/dist/status/index.js +1 -1
  19. package/dist/work/dates.js +2 -3
  20. package/dist/work/detail/detail-types.js +0 -8
  21. package/dist/work/docs/edited.js +8 -7
  22. package/dist/work/inbox/inbox-logic.js +5 -20
  23. package/dist/work/index.js +0 -2
  24. package/dist/work/time/duration.js +2 -3
  25. package/package.json +1 -21
  26. package/src/ai/confirmation/Confirmation.svelte +2 -2
  27. package/src/ai/confirmation/{ConfirmationRequest.svelte → ConfirmationState.svelte} +6 -3
  28. package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
  29. package/src/ai/confirmation/confirmation-parts.test.svelte +17 -0
  30. package/src/ai/confirmation/confirmation-parts.test.ts +23 -0
  31. package/src/ai/confirmation/index.ts +6 -21
  32. package/src/ai/index.ts +3 -13
  33. package/src/ai/typing-dots/TypingDots.svelte +32 -12
  34. package/src/ai/typing-dots/typing-dots.test.ts +36 -0
  35. package/src/canvas/ui/canvas-ui.css +12 -0
  36. package/src/charts/README.md +2 -2
  37. package/src/charts/area-chart.svelte +3 -10
  38. package/src/charts/bar-chart.svelte +4 -12
  39. package/src/charts/dashboard/data-table.svelte +21 -21
  40. package/src/charts/dashboard/index.ts +1 -3
  41. package/src/charts/dashboard/rating-chip.svelte +9 -11
  42. package/src/charts/dashboard/stat-tile.svelte +3 -4
  43. package/src/charts/line-chart.svelte +3 -10
  44. package/src/charts/primitives/chart-utils.test.ts +17 -0
  45. package/src/charts/primitives/chart-utils.ts +16 -0
  46. package/src/charts/time-series-chart.svelte +8 -4
  47. package/src/components/chip/count-chip.svelte +35 -0
  48. package/src/components/chip/index.ts +1 -0
  49. package/src/components/data-table/data-table.ts +1 -1
  50. package/src/components/data-table/row-context-menu.svelte +3 -4
  51. package/src/components/data-table/sortable-head.svelte +45 -10
  52. package/src/components/language-flag/flags.ts +5 -0
  53. package/src/components/language-flag/index.ts +1 -0
  54. package/src/components/language-flag/language-flag.svelte +3 -11
  55. package/src/components/list-page/empty-state.svelte +90 -12
  56. package/src/components/list-page/filter-popover.svelte +2 -5
  57. package/src/components/list-page/grouped-list.svelte +2 -4
  58. package/src/components/list-page/index.ts +1 -3
  59. package/src/components/section-header/section-header.svelte +2 -6
  60. package/src/components/segmented/segment.svelte +13 -19
  61. package/src/components/segmented/segmented-filter.svelte +1 -2
  62. package/src/components/table-view/table-view.svelte +31 -45
  63. package/src/components/table-view/table-view.test.ts +6 -3
  64. package/src/components/work-parts/group-head.svelte +1 -1
  65. package/src/components/work-parts/index.ts +1 -1
  66. package/src/editor/doc-blocks.ts +2 -1
  67. package/src/effects/animated-beam/path-calculator.svelte.ts +19 -5
  68. package/src/effects/marquee/Marquee.css +6 -3
  69. package/src/effects/marquee/Marquee.svelte +6 -2
  70. package/src/inbox/thread-inbox.svelte +6 -4
  71. package/src/lib/dates.test.ts +63 -0
  72. package/src/lib/dates.ts +63 -0
  73. package/src/lib/panel.svelte +116 -0
  74. package/src/lib/panel.ts +2 -2
  75. package/src/lib/tone.ts +15 -0
  76. package/src/panel/side-panel.svelte +4 -4
  77. package/src/record/format.ts +3 -15
  78. package/src/record/logic.ts +2 -6
  79. package/src/record/record-stats.svelte +4 -3
  80. package/src/record/steps-rail.svelte +7 -4
  81. package/src/status/index.ts +1 -1
  82. package/src/status/status-pill.svelte +2 -8
  83. package/src/thread/thread.svelte +3 -6
  84. package/src/work/README.md +0 -1
  85. package/src/work/dates.ts +3 -3
  86. package/src/work/demo.svelte +9 -1
  87. package/src/work/detail/detail-demo.svelte +30 -27
  88. package/src/work/detail/detail-types.ts +1 -29
  89. package/src/work/detail/task-detail.svelte +18 -17
  90. package/src/work/docs/doc-menu.svelte +2 -1
  91. package/src/work/docs/edited.ts +4 -7
  92. package/src/work/inbox/inbox-card.svelte +4 -9
  93. package/src/work/inbox/inbox-logic.ts +4 -16
  94. package/src/work/index.ts +1 -3
  95. package/src/work/time/duration.ts +2 -3
  96. package/dist/ai/thinking-dots/index.js +0 -3
  97. package/dist/components/icon/index.js +0 -1
  98. package/dist/components/locale-switch/index.js +0 -1
  99. package/dist/icons/trades.js +0 -49
  100. package/dist/status/pill-tint.js +0 -13
  101. package/dist/work/presets/sales.js +0 -39
  102. package/src/ai/confirmation/ConfirmationAccepted.svelte +0 -23
  103. package/src/ai/confirmation/ConfirmationFailed.svelte +0 -23
  104. package/src/ai/confirmation/ConfirmationRejected.svelte +0 -23
  105. package/src/ai/thinking-dots/ThinkingDots.svelte +0 -42
  106. package/src/ai/thinking-dots/index.ts +0 -5
  107. package/src/charts/dashboard/dashboard-empty.svelte +0 -55
  108. package/src/charts/dashboard/data-panel.svelte +0 -81
  109. package/src/charts/dashboard/data-sort-head.svelte +0 -102
  110. package/src/components/icon/icon.svelte +0 -23
  111. package/src/components/icon/index.ts +0 -1
  112. package/src/components/list-page/detail-empty.svelte +0 -45
  113. package/src/components/list-page/detail-panel.svelte +0 -82
  114. package/src/components/list-page/rail-empty.svelte +0 -44
  115. package/src/components/locale-switch/index.ts +0 -1
  116. package/src/components/locale-switch/locale-switch.svelte +0 -59
  117. package/src/components/work-parts/count-chip.svelte +0 -20
  118. package/src/icons/TradeIcon.svelte +0 -16
  119. package/src/icons/trades.ts +0 -89
  120. package/src/status/pill-tint.ts +0 -16
  121. package/src/work/detail/task-property.svelte +0 -17
  122. package/src/work/detail/task-thread.svelte +0 -221
  123. package/src/work/presets/sales.ts +0 -16
@@ -1,50 +1,35 @@
1
1
  import Confirmation from "./Confirmation.svelte";
2
2
  import ConfirmationTitle from "./ConfirmationTitle.svelte";
3
- import ConfirmationRequest from "./ConfirmationRequest.svelte";
4
- import ConfirmationAccepted from "./ConfirmationAccepted.svelte";
5
- import ConfirmationRejected from "./ConfirmationRejected.svelte";
6
- import ConfirmationFailed from "./ConfirmationFailed.svelte";
3
+ import ConfirmationState from "./ConfirmationState.svelte";
7
4
  import ConfirmationActions from "./ConfirmationActions.svelte";
8
5
  import ConfirmationAction from "./ConfirmationAction.svelte";
9
6
 
10
7
  import type { ConfirmationProps } from "./Confirmation.svelte";
11
8
  import type { ConfirmationTitleProps } from "./ConfirmationTitle.svelte";
12
- import type { ConfirmationRequestProps } from "./ConfirmationRequest.svelte";
13
- import type { ConfirmationAcceptedProps } from "./ConfirmationAccepted.svelte";
14
- import type { ConfirmationRejectedProps } from "./ConfirmationRejected.svelte";
15
- import type { ConfirmationFailedProps } from "./ConfirmationFailed.svelte";
9
+ import type { ConfirmationStateProps } from "./ConfirmationState.svelte";
16
10
  import type { ConfirmationActionsProps } from "./ConfirmationActions.svelte";
17
11
  import type { ConfirmationActionProps } from "./ConfirmationAction.svelte";
18
12
 
19
13
  export type {
20
14
  ConfirmationContextValue,
21
- ConfirmationState,
15
+ ConfirmationKind,
22
16
  } from "./confirmation-context.svelte.js";
23
17
 
24
18
  export {
25
19
  Confirmation,
26
20
  ConfirmationTitle,
27
- ConfirmationRequest,
28
- ConfirmationAccepted,
29
- ConfirmationRejected,
30
- ConfirmationFailed,
21
+ ConfirmationState,
31
22
  ConfirmationActions,
32
23
  ConfirmationAction,
33
24
  type ConfirmationProps,
34
25
  type ConfirmationTitleProps,
35
- type ConfirmationRequestProps,
36
- type ConfirmationAcceptedProps,
37
- type ConfirmationRejectedProps,
38
- type ConfirmationFailedProps,
26
+ type ConfirmationStateProps,
39
27
  type ConfirmationActionsProps,
40
28
  type ConfirmationActionProps,
41
29
  //
42
30
  Confirmation as Root,
43
31
  ConfirmationTitle as Title,
44
- ConfirmationRequest as Request,
45
- ConfirmationAccepted as Accepted,
46
- ConfirmationRejected as Rejected,
47
- ConfirmationFailed as Failed,
32
+ ConfirmationState as State,
48
33
  ConfirmationActions as Actions,
49
34
  ConfirmationAction as Action,
50
35
  };
package/src/ai/index.ts CHANGED
@@ -46,28 +46,18 @@ export {
46
46
  export {
47
47
  Confirmation,
48
48
  ConfirmationTitle,
49
- ConfirmationRequest,
50
- ConfirmationAccepted,
51
- ConfirmationRejected,
49
+ ConfirmationState,
52
50
  ConfirmationActions,
53
51
  ConfirmationAction,
54
52
  type ConfirmationProps,
55
53
  type ConfirmationTitleProps,
56
- type ConfirmationRequestProps,
57
- type ConfirmationAcceptedProps,
58
- type ConfirmationRejectedProps,
54
+ type ConfirmationStateProps,
59
55
  type ConfirmationActionsProps,
60
56
  type ConfirmationActionProps,
61
57
  type ConfirmationContextValue,
62
- type ConfirmationState,
58
+ type ConfirmationKind,
63
59
  } from "./confirmation/index.js";
64
60
 
65
- // =========================
66
- // THINKING DOTS
67
- // =========================
68
-
69
- export { ThinkingDots, type ThinkingDotsProps } from "./thinking-dots/index.js";
70
-
71
61
  // =========================
72
62
  // VOICE WAVEFORM
73
63
  // =========================
@@ -1,9 +1,11 @@
1
1
  <script lang="ts" module>
2
2
  import type { HTMLAttributes } from "svelte/elements";
3
3
 
4
- export interface TypingDotsProps extends HTMLAttributes<HTMLSpanElement> {
4
+ export interface TypingDotsProps extends HTMLAttributes<HTMLElement> {
5
5
  /** Dot diameter in px. */
6
6
  size?: number;
7
+ /** Set, even to "", the dots become a `role="status"` line; the text shows when non-empty. */
8
+ label?: string;
7
9
  class?: string;
8
10
  }
9
11
  </script>
@@ -12,16 +14,34 @@
12
14
  import "./TypingDots.css";
13
15
  import { cn } from "../../lib/utils.js";
14
16
 
15
- let { size = 4, class: className, ...rest }: TypingDotsProps = $props();
17
+ let { size = 4, label, class: className, ...rest }: TypingDotsProps = $props();
16
18
  </script>
17
19
 
18
- <span
19
- class={cn("typing-dots", className)}
20
- style:--typing-dot-size="{size}px"
21
- aria-hidden="true"
22
- {...rest}
23
- >
24
- <span class="typing-dot"></span>
25
- <span class="typing-dot"></span>
26
- <span class="typing-dot"></span>
27
- </span>
20
+ {#snippet dots(extra?: HTMLAttributes<HTMLElement>, dotsClass?: string)}
21
+ <span
22
+ class={cn("typing-dots", dotsClass)}
23
+ style:--typing-dot-size="{size}px"
24
+ aria-hidden="true"
25
+ {...extra}
26
+ >
27
+ <span class="typing-dot"></span>
28
+ <span class="typing-dot"></span>
29
+ <span class="typing-dot"></span>
30
+ </span>
31
+ {/snippet}
32
+
33
+ {#if label === undefined}
34
+ {@render dots(rest, className)}
35
+ {:else}
36
+ <div
37
+ class={cn(
38
+ "inline-flex items-center gap-2 text-sm text-[color:var(--ai-thinking-fg,inherit)]",
39
+ className,
40
+ )}
41
+ role="status"
42
+ {...rest}
43
+ >
44
+ {#if label}<span>{label}</span>{/if}
45
+ {@render dots()}
46
+ </div>
47
+ {/if}
@@ -0,0 +1,36 @@
1
+ import { flushSync, mount, unmount } from "svelte";
2
+ import { describe, expect, it } from "vite-plus/test";
3
+ import TypingDots, { type TypingDotsProps } from "./TypingDots.svelte";
4
+
5
+ function render(props: TypingDotsProps): HTMLElement {
6
+ const target = document.createElement("div");
7
+ const app = mount(TypingDots, { target, props });
8
+ flushSync();
9
+ const root = target.firstElementChild as HTMLElement;
10
+ void unmount(app);
11
+ return root;
12
+ }
13
+
14
+ describe("TypingDots", () => {
15
+ it("is three hidden dots with no label", () => {
16
+ const root = render({});
17
+ expect(root.getAttribute("aria-hidden")).toBe("true");
18
+ expect(root.getAttribute("role")).toBeNull();
19
+ expect(root.querySelectorAll(".typing-dot")).toHaveLength(3);
20
+ });
21
+
22
+ it("becomes a status line with its text when labelled", () => {
23
+ const root = render({ label: "Thinking", class: "extra" });
24
+ expect(root.getAttribute("role")).toBe("status");
25
+ expect(root.textContent?.trim()).toBe("Thinking");
26
+ expect(root.classList.contains("extra")).toBe(true);
27
+ expect(root.querySelector(".typing-dots")?.getAttribute("aria-hidden")).toBe("true");
28
+ });
29
+
30
+ it("keeps the status role and the caller's aria-label for an empty label", () => {
31
+ const root = render({ label: "", "aria-label": "Thinking" });
32
+ expect(root.getAttribute("role")).toBe("status");
33
+ expect(root.getAttribute("aria-label")).toBe("Thinking");
34
+ expect(root.textContent?.trim()).toBe("");
35
+ });
36
+ });
@@ -70,6 +70,18 @@
70
70
  background: var(--cv-surface);
71
71
  }
72
72
 
73
+ /* On a phone the rail runs along the bottom edge and the credit would sit under
74
+ it, in the frame's rounded corner. It moves to the top right, inset off the
75
+ corner's curve. */
76
+ @media (max-width: 639px) {
77
+ .svelte-flow .svelte-flow__panel.svelte-flow__attribution {
78
+ top: 0;
79
+ bottom: auto;
80
+ margin: 6px 8px;
81
+ border-radius: var(--radius-sm, 4px);
82
+ }
83
+ }
84
+
73
85
  .svelte-flow .svelte-flow__attribution a {
74
86
  color: var(--cv-ink-muted);
75
87
  }
@@ -71,8 +71,8 @@ The `@source` line matters: the chart markup carries Tailwind classes, and Tailw
71
71
  | --- | --- |
72
72
  | `DashboardSection`, `PanelGrid`, `StatBand` | A named area, a row of panels side by side, a row of number cards |
73
73
  | `StatTile` | A number card on `NumberChart`: a delta, a share track, a trend shape behind the figure, an optional link |
74
- | `DataPanel`, `DashboardEmpty` | A titled panel for a table or chart, and the dashed empty state |
75
- | `DataTable`, `DataSortHead`, `RatingChip` | The one data table: sortable heads, a row that opens, columns that hide on a phone, share bars, loading and empty states. An A to E grade chip |
74
+ | `DataPanel` | A titled panel for a table or chart. The dashed empty state is `EmptyState surface="dashed"` from `@heyramzi/ui/components/list-page` |
75
+ | `DataTable`, `RatingChip` | The one data table: sortable heads (bind `sort`, the `SortState` TableView takes; a new column starts descending), a row that opens, columns that hide on a phone, share bars, loading and empty states. An A to E grade chip |
76
76
  | `PeriodFilter`, `DAY_WINDOWS` | The 7, 30 and 90 day window, as a filter or as links |
77
77
  | `CHART_HEIGHT`, `dataTableClass` and friends | The one chart height and the table skin |
78
78
 
@@ -12,6 +12,7 @@
12
12
  import Figure from "./figure.svelte";
13
13
  import HoverFocus from "./hover-focus.svelte";
14
14
  import { DOMAIN_MOTION, TOOLTIP_GAP, TOOLTIP_TOP } from "./motion.js";
15
+ import { valueTick } from "./primitives/chart-utils.js";
15
16
  import Reveal from "./reveal.svelte";
16
17
  import ReferenceLine, { type ChartReference } from "./reference-line.svelte";
17
18
  import {
@@ -153,15 +154,7 @@
153
154
  const fallback = $derived(data.length > 0 ? data[data.length - 1] : null);
154
155
  const display = $derived(active ?? fallback);
155
156
 
156
- // A long tick label ("1,000,000") falls back to "1M" so the left gutter holds it.
157
- const compact = new Intl.NumberFormat(undefined, {
158
- notation: "compact",
159
- maximumFractionDigits: 1,
160
- });
161
- function valueTick(v: number): string {
162
- const full = formatValue(v);
163
- return full.length > 6 && Number.isFinite(v) ? compact.format(v) : full;
164
- }
157
+ const tick = $derived(valueTick(formatValue));
165
158
  const figureRows = $derived(
166
159
  display
167
160
  ? series.map((s) => ({
@@ -212,7 +205,7 @@
212
205
  motion={DOMAIN_MOTION}
213
206
  >
214
207
  <Svg>
215
- <Axis placement="left" format={(v) => valueTick(Number(v))} ticks={4} />
208
+ <Axis placement="left" format={(v) => tick(Number(v))} ticks={4} />
216
209
  <Axis placement="bottom" format={(v) => formatX(v)} ticks={ticksForWidth(xTicks)} />
217
210
  <g class="hr-area-grid">
218
211
  <Axis placement="left" grid rule={false} ticks={4} format={() => ""} />
@@ -8,6 +8,7 @@
8
8
  import { scaleBand } from "d3-scale";
9
9
  import Figure from "./figure.svelte";
10
10
  import { TOOLTIP_GAP, TOOLTIP_TOP } from "./motion.js";
11
+ import { valueTick } from "./primitives/chart-utils.js";
11
12
 
12
13
  // =========================
13
14
  // TYPES
@@ -134,16 +135,7 @@
134
135
  });
135
136
  const activeColor = $derived(active && colorOf ? colorOf(active) : color);
136
137
 
137
- // WHY: "1,000,000" overflows the 48px gutter and the gap between ticks, so a long label
138
- // falls back to "1M"; the tooltip keeps the full value.
139
- const compact = new Intl.NumberFormat(undefined, {
140
- notation: "compact",
141
- maximumFractionDigits: 1,
142
- });
143
- function valueTick(v: number): string {
144
- const full = formatValue(v);
145
- return full.length > 6 && Number.isFinite(v) ? compact.format(v) : full;
146
- }
138
+ const tick = $derived(valueTick(formatValue));
147
139
  </script>
148
140
 
149
141
  <!-- =========================== -->
@@ -188,12 +180,12 @@
188
180
  <Svg>
189
181
  {#if horizontal}
190
182
  <Axis placement="left" format={(v) => (formatTick ?? formatX)(v)} />
191
- <Axis placement="bottom" format={(v) => valueTick(Number(v))} ticks={4} />
183
+ <Axis placement="bottom" format={(v) => tick(Number(v))} ticks={4} />
192
184
  <g class="hr-bar-grid">
193
185
  <Axis placement="bottom" grid rule={false} ticks={4} format={() => ""} />
194
186
  </g>
195
187
  {:else}
196
- <Axis placement="left" format={(v) => valueTick(Number(v))} ticks={4} />
188
+ <Axis placement="left" format={(v) => tick(Number(v))} ticks={4} />
197
189
  <Axis placement="bottom" format={(v) => (formatTick ?? formatX)(v)} />
198
190
  <g class="hr-bar-grid">
199
191
  <Axis placement="left" grid rule={false} ticks={4} format={() => ""} />
@@ -42,9 +42,14 @@
42
42
  * optional pager. A row opens by `rowHref` or `onRowClick`, stretched over the row from the lead.
43
43
  */
44
44
  import { cn } from "../../lib/utils.js";
45
- import DataSortHead from "./data-sort-head.svelte";
46
45
  import RatingChip from "./rating-chip.svelte";
47
- import { Pagination, paginateRows, totalPages } from "../../components/data-table/index.js";
46
+ import {
47
+ Pagination,
48
+ SortableHead,
49
+ paginateRows,
50
+ totalPages,
51
+ type SortState,
52
+ } from "../../components/data-table/index.js";
48
53
  import {
49
54
  dataTableClass,
50
55
  dataTdClass,
@@ -69,8 +74,8 @@
69
74
  minWidth?: string;
70
75
  /** Omit for every row on one page. */
71
76
  pageSize?: number;
72
- sortKey?: string | null;
73
- descending?: boolean;
77
+ /** The ranked column and its direction. Bind it; a column with `sort` sets it. */
78
+ sort?: SortState;
74
79
  page?: number;
75
80
  /** The row opens a page. The lead becomes the link. */
76
81
  rowHref?: (row: T) => string;
@@ -92,8 +97,7 @@
92
97
  leadWidth,
93
98
  minWidth = "min-w-[48rem]",
94
99
  pageSize,
95
- sortKey = $bindable(null),
96
- descending = $bindable(true),
100
+ sort = $bindable(null),
97
101
  page = $bindable(1),
98
102
  rowHref,
99
103
  onRowClick,
@@ -116,9 +120,9 @@
116
120
  "text-left outline-none after:absolute after:inset-0 after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-ring";
117
121
 
118
122
  const sorted = $derived.by(() => {
119
- const by = columns.find((column) => column.key === sortKey)?.sort;
120
- if (!by) return rows;
121
- const direction = descending ? -1 : 1;
123
+ const by = columns.find((column) => column.key === sort?.key)?.sort;
124
+ if (!by || !sort) return rows;
125
+ const direction = sort.direction === "desc" ? -1 : 1;
122
126
  return [...rows].sort((left, right) => (by(left) - by(right)) * direction);
123
127
  });
124
128
 
@@ -134,12 +138,8 @@
134
138
 
135
139
  function rankBy(key: string) {
136
140
  page = 1;
137
- if (sortKey === key) {
138
- descending = !descending;
139
- return;
140
- }
141
- sortKey = key;
142
- descending = true;
141
+ const flip = sort?.key === key && sort.direction === "desc";
142
+ sort = { key, direction: flip ? "asc" : "desc" };
143
143
  }
144
144
 
145
145
  function isMuted(column: DataColumn<T>, row: T) {
@@ -168,16 +168,16 @@
168
168
  <th scope="col" class={dataThClass}>{leadLabel}</th>
169
169
  {#each columns as column (column.key)}
170
170
  {#if column.sort}
171
- <DataSortHead
171
+ <SortableHead
172
+ key={column.key}
173
+ {sort}
174
+ onSort={rankBy}
172
175
  numeric={column.align !== "start"}
173
- class={columnClass(column)}
174
- active={sortKey === column.key}
175
- {descending}
176
- onSort={() => rankBy(column.key)}
176
+ class={cn(column.align === "start" ? dataThClass : dataThNumClass, columnClass(column))}
177
177
  title={column.hint}
178
178
  >
179
179
  {column.label}
180
- </DataSortHead>
180
+ </SortableHead>
181
181
  {:else}
182
182
  <th
183
183
  scope="col"
@@ -1,12 +1,10 @@
1
- export { default as DataPanel } from "./data-panel.svelte";
2
- export { default as DataSortHead } from "./data-sort-head.svelte";
1
+ export { default as DataPanel } from "../../lib/panel.svelte";
3
2
  export {
4
3
  default as DataTable,
5
4
  type DataColumn,
6
5
  type DataColumnBreakpoint,
7
6
  } from "./data-table.svelte";
8
7
  export { default as RatingChip } from "./rating-chip.svelte";
9
- export { default as DashboardEmpty } from "./dashboard-empty.svelte";
10
8
  export { default as DashboardSection } from "./dashboard-section.svelte";
11
9
  export { default as PanelGrid } from "./panel-grid.svelte";
12
10
  export { default as PeriodFilter } from "./period-filter.svelte";
@@ -1,8 +1,6 @@
1
1
  <script lang="ts">
2
- /*
3
- * One A to E grade on one colour scale, so a C means one thing everywhere.
4
- * Light mode takes the 800 shades, since amber on cream fails contrast.
5
- */
2
+ // One A to E grade on the shared tone scale, so a C means one thing everywhere.
3
+ import { TONE_TINT, type Tone } from "../../lib/tone.js";
6
4
  import { cn } from "../../lib/utils.js";
7
5
 
8
6
  interface Props {
@@ -14,19 +12,19 @@
14
12
 
15
13
  let { grade, hint, class: className }: Props = $props();
16
14
 
17
- const TONE: Record<string, string> = {
18
- A: "bg-success/12 text-success-800 dark:text-success",
19
- B: "bg-success/12 text-success-800 dark:text-success",
20
- C: "bg-muted text-foreground",
21
- D: "bg-warning/18 text-warning-800 dark:text-warning",
22
- E: "bg-destructive/12 text-destructive",
15
+ const GRADE_TONE: Record<string, Tone> = {
16
+ A: "success",
17
+ B: "success",
18
+ C: "neutral",
19
+ D: "warning",
20
+ E: "destructive",
23
21
  };
24
22
  </script>
25
23
 
26
24
  <span
27
25
  class={cn(
28
26
  "inline-flex h-6 min-w-6 items-center justify-center rounded-md px-1.5 text-xs font-semibold tabular-nums",
29
- TONE[grade] ?? TONE.C,
27
+ TONE_TINT[GRADE_TONE[grade] ?? "neutral"],
30
28
  className,
31
29
  )}
32
30
  title={hint}
@@ -14,7 +14,6 @@
14
14
  import Sparkline from "../sparkline.svelte";
15
15
  import type { Snippet } from "svelte";
16
16
  import { cn } from "../../lib/utils.js";
17
- import { Icon } from "../../components/icon/index.js";
18
17
  import type { PhosphorIcon } from "../../lib/icon-types.js";
19
18
 
20
19
  // =========================
@@ -87,7 +86,7 @@
87
86
  shareLabel,
88
87
  color,
89
88
  series,
90
- icon,
89
+ icon: Icon,
91
90
  href,
92
91
  hrefLabel,
93
92
  target = "_blank",
@@ -124,7 +123,7 @@
124
123
  )}
125
124
  >
126
125
  {#snippet tileIcon()}
127
- {#if icon}<Icon {icon} size={14} class="shrink-0" />{/if}
126
+ {#if Icon}<Icon size={14} class="shrink-0" />{/if}
128
127
  {/snippet}
129
128
 
130
129
  <NumberChart
@@ -135,7 +134,7 @@
135
134
  {share}
136
135
  {shareLabel}
137
136
  {color}
138
- icon={icon ? tileIcon : undefined}
137
+ icon={Icon ? tileIcon : undefined}
139
138
  size={lead ? "lg" : "md"}
140
139
  caption={hint}
141
140
  class={cn(
@@ -22,6 +22,7 @@
22
22
  import Figure from "./figure.svelte";
23
23
  import HoverFocus from "./hover-focus.svelte";
24
24
  import { DOMAIN_MOTION, TOOLTIP_GAP, TOOLTIP_TOP } from "./motion.js";
25
+ import { valueTick } from "./primitives/chart-utils.js";
25
26
  import Reveal from "./reveal.svelte";
26
27
 
27
28
  // =========================
@@ -119,15 +120,7 @@
119
120
  const fallback = $derived(data.length > 0 ? data[data.length - 1] : null);
120
121
  const display = $derived(active ?? fallback);
121
122
 
122
- // A long tick label ("1,000,000") falls back to "1M" so the left gutter holds it.
123
- const compact = new Intl.NumberFormat(undefined, {
124
- notation: "compact",
125
- maximumFractionDigits: 1,
126
- });
127
- function valueTick(v: number): string {
128
- const full = formatValue(v);
129
- return full.length > 6 && Number.isFinite(v) ? compact.format(v) : full;
130
- }
123
+ const tick = $derived(valueTick(formatValue));
131
124
  // WHY: a dashed tail is two splines sharing the boundary point so the solid
132
125
  // and dashed segments meet without a gap.
133
126
  const hasTail = $derived(
@@ -200,7 +193,7 @@
200
193
  motion={DOMAIN_MOTION}
201
194
  >
202
195
  <Svg>
203
- <Axis placement="left" format={(v) => valueTick(Number(v))} ticks={4} />
196
+ <Axis placement="left" format={(v) => tick(Number(v))} ticks={4} />
204
197
  <Axis placement="bottom" format={(v) => formatX(v)} />
205
198
  <g class="hr-line-grid">
206
199
  <Axis placement="left" grid rule={false} ticks={4} format={() => ""} />
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { valueTick } from "./chart-utils.js";
3
+
4
+ describe("valueTick", () => {
5
+ const tick = valueTick((v) => v.toLocaleString("en-US"));
6
+
7
+ it("keeps a label of 6 characters or fewer as the formatter wrote it", () => {
8
+ expect(tick(1234)).toBe("1,234");
9
+ expect(tick(12345)).toBe("12,345");
10
+ });
11
+
12
+ it("shortens a longer label to compact notation", () => {
13
+ expect(tick(1_000_000)).toBe("1M");
14
+ expect(tick(1_234_567)).toBe("1.2M");
15
+ expect(tick(-12345)).toBe("-12.3K");
16
+ });
17
+ });
@@ -39,6 +39,22 @@ export function getPayloadConfigFromPayload(
39
39
  return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
40
40
  }
41
41
 
42
+ const compact = new Intl.NumberFormat(undefined, {
43
+ notation: "compact",
44
+ maximumFractionDigits: 1,
45
+ });
46
+
47
+ /**
48
+ * An axis tick formatter. "1,000,000" overflows the 48px gutter and the gap between ticks, so a
49
+ * label over 6 characters falls back to "1M"; the tooltip keeps the full value.
50
+ */
51
+ export function valueTick(format: (v: number) => string): (v: number) => string {
52
+ return (v) => {
53
+ const full = format(v);
54
+ return full.length > 6 && Number.isFinite(v) ? compact.format(v) : full;
55
+ };
56
+ }
57
+
42
58
  type ChartContextValue = {
43
59
  config: ChartConfig;
44
60
  };
@@ -63,7 +63,7 @@
63
63
  label = "Value",
64
64
  label2,
65
65
  color = "var(--chart-series-1)",
66
- color2 = "var(--muted-foreground)",
66
+ color2 = "var(--chart-series-2)",
67
67
  formatValue = (v) => v.toLocaleString(),
68
68
  formatValue2,
69
69
  height = 280,
@@ -152,7 +152,7 @@
152
152
  <Spline
153
153
  y="value2"
154
154
  curve={curveMonotoneX}
155
- style="stroke: {color2}; stroke-dasharray: 4 4; stroke-width: 1; fill: none"
155
+ style="stroke: {color2}; stroke-dasharray: 4 4; stroke-width: 1.5; fill: none"
156
156
  />
157
157
  {/if}
158
158
  </Reveal>
@@ -168,9 +168,13 @@
168
168
  })}
169
169
  />
170
170
  <Tooltip.List>
171
- <Tooltip.Item {label} value={formatValue(d.value)} />
171
+ <Tooltip.Item {label} {color} value={formatValue(d.value)} />
172
172
  {#if label2 && d.value2 !== undefined}
173
- <Tooltip.Item label={label2} value={(formatValue2 ?? formatValue)(d.value2)} />
173
+ <Tooltip.Item
174
+ label={label2}
175
+ color={color2}
176
+ value={(formatValue2 ?? formatValue)(d.value2)}
177
+ />
174
178
  {/if}
175
179
  </Tooltip.List>
176
180
  {/snippet}
@@ -0,0 +1,35 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import { cn } from "../../lib/utils.js";
4
+
5
+ interface Props extends Omit<HTMLAttributes<HTMLSpanElement>, "class" | "children"> {
6
+ count: number;
7
+ /** `sm` is 16px, for a count inside a control or on a button; `md` is 20px, beside a heading. */
8
+ size?: "sm" | "md";
9
+ /** `brand` marks a count that is on: an applied filter, the selected segment. */
10
+ tone?: "neutral" | "brand";
11
+ class?: string;
12
+ }
13
+
14
+ let { count, size = "md", tone = "neutral", class: className, ...restProps }: Props = $props();
15
+
16
+ const SIZE = { sm: "h-4 min-w-4", md: "h-5 min-w-5" };
17
+ const TONE = {
18
+ neutral: "bg-foreground/6 text-muted-foreground",
19
+ brand: "bg-brand/12 text-brand dark:text-brand-light",
20
+ };
21
+ </script>
22
+
23
+ <!-- A count is a pill (DESIGN.md rule 15). Min width equals height with 4px padding, so one digit
24
+ draws a circle, not an ellipse. -->
25
+ <span
26
+ class={cn(
27
+ "inline-grid shrink-0 place-items-center rounded-full px-1 text-xs leading-none font-medium tabular-nums",
28
+ SIZE[size],
29
+ TONE[tone],
30
+ className,
31
+ )}
32
+ {...restProps}
33
+ >
34
+ {count.toLocaleString()}
35
+ </span>
@@ -1 +1,2 @@
1
+ export { default as CountChip } from "./count-chip.svelte";
1
2
  export { default as TagChip } from "./tag-chip.svelte";
@@ -26,7 +26,7 @@ export function anyContainsQuery(values: Array<string | null | undefined>, query
26
26
  // ========================= TYPES
27
27
  export type SortDirection = "asc" | "desc";
28
28
 
29
- export type SortState<T> = {
29
+ export type SortState<T = Record<string, unknown>> = {
30
30
  key: keyof T & string;
31
31
  direction: SortDirection;
32
32
  } | null;
@@ -5,6 +5,7 @@
5
5
  import * as ContextMenu from "../../shadcn/context-menu/index.js";
6
6
  import { HoldToConfirm } from "../hold-to-confirm/index.js";
7
7
  import { ArrowUpRight, Copy, LinkSimple, Trash } from "phosphor-svelte";
8
+ import { copyText } from "../copy/clipboard.js";
8
9
  import type { Snippet } from "svelte";
9
10
 
10
11
  // =========================
@@ -47,14 +48,12 @@
47
48
  // message, a doc or an agent prompt, none of which can resolve "/clients/42".
48
49
  async function handleCopyLink() {
49
50
  if (!href) return;
50
- await navigator.clipboard.writeText(new URL(href, window.location.origin).href);
51
- onCopied?.();
51
+ if (await copyText(new URL(href, window.location.origin).href)) onCopied?.();
52
52
  }
53
53
 
54
54
  async function handleCopyId() {
55
55
  if (!id) return;
56
- await navigator.clipboard.writeText(id);
57
- onCopied?.();
56
+ if (await copyText(id)) onCopied?.();
58
57
  }
59
58
  </script>
60
59