@heyramzi/ui 2.22.1 → 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 (163) 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/canvas/editor/rebase.js +26 -2
  5. package/dist/charts/dashboard/index.js +1 -3
  6. package/dist/charts/primitives/chart-utils.js +14 -0
  7. package/dist/components/chip/index.js +1 -0
  8. package/dist/components/language-flag/flags.js +5 -0
  9. package/dist/components/language-flag/index.js +1 -0
  10. package/dist/components/list-page/index.js +1 -3
  11. package/dist/components/work-parts/index.js +1 -1
  12. package/dist/editor/doc-blocks.js +2 -1
  13. package/dist/editor/safe-url.js +26 -0
  14. package/dist/editor/serializer.js +10 -4
  15. package/dist/editor/slash-query.js +10 -0
  16. package/dist/effects/animated-beam/path-calculator.svelte.js +13 -6
  17. package/dist/lib/dates.js +41 -0
  18. package/dist/lib/panel.js +2 -2
  19. package/dist/lib/tone.js +13 -0
  20. package/dist/record/format.js +2 -18
  21. package/dist/record/logic.js +3 -6
  22. package/dist/status/index.js +1 -1
  23. package/dist/triage/index.js +4 -0
  24. package/dist/triage/types.js +12 -0
  25. package/dist/work/dates.js +2 -3
  26. package/dist/work/detail/detail-types.js +0 -8
  27. package/dist/work/docs/edited.js +8 -7
  28. package/dist/work/inbox/inbox-logic.js +5 -20
  29. package/dist/work/index.js +0 -2
  30. package/dist/work/time/duration.js +2 -3
  31. package/package.json +7 -21
  32. package/scripts/sync-shadcn.mjs +4 -6
  33. package/src/ai/confirmation/Confirmation.svelte +2 -2
  34. package/src/ai/confirmation/{ConfirmationRequest.svelte → ConfirmationState.svelte} +6 -3
  35. package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
  36. package/src/ai/confirmation/confirmation-parts.test.svelte +17 -0
  37. package/src/ai/confirmation/confirmation-parts.test.ts +23 -0
  38. package/src/ai/confirmation/index.ts +6 -21
  39. package/src/ai/index.ts +3 -13
  40. package/src/ai/typing-dots/TypingDots.svelte +32 -12
  41. package/src/ai/typing-dots/typing-dots.test.ts +36 -0
  42. package/src/animations/modal.css +4 -1
  43. package/src/canvas/editor/rebase.test.ts +37 -0
  44. package/src/canvas/editor/rebase.ts +33 -1
  45. package/src/canvas/ui/canvas-ui.css +44 -1
  46. package/src/charts/README.md +2 -2
  47. package/src/charts/area-chart.svelte +5 -12
  48. package/src/charts/bar-chart.svelte +6 -14
  49. package/src/charts/chart-tokens.css +19 -0
  50. package/src/charts/dashboard/data-table.svelte +21 -21
  51. package/src/charts/dashboard/index.ts +1 -3
  52. package/src/charts/dashboard/rating-chip.svelte +9 -11
  53. package/src/charts/dashboard/stat-tile.svelte +4 -5
  54. package/src/charts/donut-chart.svelte +1 -1
  55. package/src/charts/gauge-chart.svelte +1 -1
  56. package/src/charts/line-chart.svelte +5 -12
  57. package/src/charts/number-chart.svelte +1 -1
  58. package/src/charts/primitives/chart-utils.test.ts +17 -0
  59. package/src/charts/primitives/chart-utils.ts +16 -0
  60. package/src/charts/ring-chart.svelte +2 -2
  61. package/src/charts/sparkline.svelte +1 -1
  62. package/src/charts/stat-card.svelte +1 -1
  63. package/src/charts/time-series-chart.svelte +9 -5
  64. package/src/components/chip/count-chip.svelte +35 -0
  65. package/src/components/chip/index.ts +1 -0
  66. package/src/components/data-table/data-table.ts +1 -1
  67. package/src/components/data-table/row-context-menu.svelte +3 -4
  68. package/src/components/data-table/sortable-head.svelte +45 -10
  69. package/src/components/language-flag/flags.ts +5 -0
  70. package/src/components/language-flag/index.ts +1 -0
  71. package/src/components/language-flag/language-flag.svelte +3 -11
  72. package/src/components/list-page/empty-state.svelte +90 -12
  73. package/src/components/list-page/filter-popover.svelte +2 -5
  74. package/src/components/list-page/grouped-list.svelte +2 -4
  75. package/src/components/list-page/index.ts +1 -3
  76. package/src/components/section-header/section-header.svelte +2 -6
  77. package/src/components/segmented/segment.svelte +13 -19
  78. package/src/components/segmented/segmented-filter.svelte +1 -2
  79. package/src/components/table-view/table-view.svelte +31 -45
  80. package/src/components/table-view/table-view.test.ts +6 -3
  81. package/src/components/work-parts/group-head.svelte +1 -1
  82. package/src/components/work-parts/index.ts +1 -1
  83. package/src/editor/doc-blocks.ts +2 -1
  84. package/src/editor/editor.svelte +7 -5
  85. package/src/editor/safe-url.ts +32 -0
  86. package/src/editor/serializer.test.ts +58 -0
  87. package/src/editor/serializer.ts +9 -6
  88. package/src/editor/slash-query.test.ts +19 -0
  89. package/src/editor/slash-query.ts +11 -0
  90. package/src/effects/animated-beam/path-calculator.svelte.ts +19 -5
  91. package/src/effects/marquee/Marquee.css +6 -3
  92. package/src/effects/marquee/Marquee.svelte +6 -2
  93. package/src/inbox/thread-inbox.svelte +6 -4
  94. package/src/lib/dates.test.ts +63 -0
  95. package/src/lib/dates.ts +63 -0
  96. package/src/lib/panel.svelte +116 -0
  97. package/src/lib/panel.ts +2 -2
  98. package/src/lib/tone.ts +15 -0
  99. package/src/modules.test.ts +13 -1
  100. package/src/panel/side-panel.svelte +4 -4
  101. package/src/record/format.ts +3 -15
  102. package/src/record/logic.ts +2 -6
  103. package/src/record/record-stats.svelte +4 -3
  104. package/src/record/steps-rail.svelte +7 -4
  105. package/src/shadcn/alert-dialog/alert-dialog-overlay.svelte +1 -1
  106. package/src/shadcn/button/button.svelte +1 -1
  107. package/src/shadcn/button/button.test.ts +2 -2
  108. package/src/shadcn/command/command-dialog.svelte +5 -10
  109. package/src/shadcn/dialog/dialog-overlay.svelte +1 -1
  110. package/src/shadcn/input/input.svelte +2 -2
  111. package/src/shadcn/sheet/sheet-overlay.svelte +1 -1
  112. package/src/status/index.ts +1 -1
  113. package/src/status/status-pill.svelte +2 -8
  114. package/src/thread/thread.svelte +3 -6
  115. package/src/tokens/craft.css +30 -0
  116. package/src/triage/README.md +28 -0
  117. package/src/triage/index.ts +10 -0
  118. package/src/triage/triage-plan.svelte +132 -0
  119. package/src/triage/triage-queue.svelte +48 -0
  120. package/src/triage/triage-task.svelte +95 -0
  121. package/src/triage/types.ts +35 -0
  122. package/src/work/README.md +0 -1
  123. package/src/work/dates.ts +3 -3
  124. package/src/work/demo.svelte +9 -1
  125. package/src/work/detail/detail-demo.svelte +30 -27
  126. package/src/work/detail/detail-types.ts +1 -29
  127. package/src/work/detail/task-detail.svelte +18 -17
  128. package/src/work/docs/doc-menu.svelte +2 -1
  129. package/src/work/docs/edited.ts +4 -7
  130. package/src/work/inbox/inbox-card.svelte +4 -9
  131. package/src/work/inbox/inbox-logic.ts +4 -16
  132. package/src/work/index.ts +1 -3
  133. package/src/work/kanban-column.svelte +5 -3
  134. package/src/work/time/duration.ts +2 -3
  135. package/src/work/time/time-chip.svelte +1 -3
  136. package/dist/ai/thinking-dots/index.js +0 -3
  137. package/dist/components/icon/index.js +0 -1
  138. package/dist/components/locale-switch/index.js +0 -1
  139. package/dist/icons/trades.js +0 -49
  140. package/dist/status/pill-tint.js +0 -13
  141. package/dist/work/presets/sales.js +0 -39
  142. package/src/ai/confirmation/ConfirmationAccepted.svelte +0 -23
  143. package/src/ai/confirmation/ConfirmationFailed.svelte +0 -23
  144. package/src/ai/confirmation/ConfirmationRejected.svelte +0 -23
  145. package/src/ai/thinking-dots/ThinkingDots.svelte +0 -42
  146. package/src/ai/thinking-dots/index.ts +0 -5
  147. package/src/charts/dashboard/dashboard-empty.svelte +0 -55
  148. package/src/charts/dashboard/data-panel.svelte +0 -81
  149. package/src/charts/dashboard/data-sort-head.svelte +0 -102
  150. package/src/components/icon/icon.svelte +0 -23
  151. package/src/components/icon/index.ts +0 -1
  152. package/src/components/list-page/detail-empty.svelte +0 -45
  153. package/src/components/list-page/detail-panel.svelte +0 -82
  154. package/src/components/list-page/rail-empty.svelte +0 -44
  155. package/src/components/locale-switch/index.ts +0 -1
  156. package/src/components/locale-switch/locale-switch.svelte +0 -59
  157. package/src/components/work-parts/count-chip.svelte +0 -20
  158. package/src/icons/TradeIcon.svelte +0 -16
  159. package/src/icons/trades.ts +0 -89
  160. package/src/status/pill-tint.ts +0 -16
  161. package/src/work/detail/task-property.svelte +0 -17
  162. package/src/work/detail/task-thread.svelte +0 -221
  163. package/src/work/presets/sales.ts +0 -16
@@ -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
@@ -55,7 +56,7 @@
55
56
  xKey,
56
57
  yKey,
57
58
  series,
58
- color = "var(--chart-1)",
59
+ color = "var(--chart-series-1)",
59
60
  colorOf,
60
61
  height = 240,
61
62
  orientation = "vertical",
@@ -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
  <!-- =========================== -->
@@ -161,7 +153,7 @@
161
153
  {#if showFigure}
162
154
  <Figure
163
155
  value={active ? valueOf(active) : total}
164
- color="var(--chart-1)"
156
+ color="var(--chart-series-1)"
165
157
  eyebrow={active ? formatX(active[xKey]) : "Total"}
166
158
  format={formatValue}
167
159
  size="md"
@@ -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={() => ""} />
@@ -16,6 +16,25 @@
16
16
  .hr-ring,
17
17
  .hr-gauge
18
18
  ) {
19
+ /* The categorical series palette. The brand leads, then hues spaced round the
20
+ wheel so neighbours never share a family (blue and orange first, the pair that
21
+ survives colour blindness). Each hue is a cat token pulled 15% toward the text
22
+ colour: darker on a light card, lighter on a dark one, so every series clears
23
+ 3:1 against the card in both themes (lowest: teal, 3.6:1 light, 6.2:1 dark). */
24
+ --chart-series-1: var(--color-brand, oklch(0.28 0 0));
25
+ --chart-series-2: color-mix(in oklab, var(--color-cat-blue) 85%, var(--color-foreground));
26
+ --chart-series-3: color-mix(in oklab, var(--color-cat-orange) 85%, var(--color-foreground));
27
+ --chart-series-4: color-mix(in oklab, var(--color-cat-green) 85%, var(--color-foreground));
28
+ --chart-series-5: color-mix(in oklab, var(--color-cat-pink) 85%, var(--color-foreground));
29
+ --chart-series-6: color-mix(in oklab, var(--color-cat-teal) 85%, var(--color-foreground));
30
+ /* The palette's `--chart-N` names point here inside a chart, so a series passed as
31
+ `var(--chart-3)` gets a hue, not the brand's grey highlight. */
32
+ --chart-1: var(--chart-series-1);
33
+ --chart-2: var(--chart-series-2);
34
+ --chart-3: var(--chart-series-3);
35
+ --chart-4: var(--chart-series-4);
36
+ --chart-5: var(--chart-series-5);
37
+ --chart-6: var(--chart-series-6);
19
38
  --chart-label: var(--muted-foreground, oklch(0.5 0.01 286));
20
39
  --chart-grid: color-mix(in srgb, var(--border, oklch(0.5 0.01 286)) 50%, transparent);
21
40
  --chart-ease-out: var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
@@ -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(
@@ -159,7 +158,7 @@
159
158
  data={points}
160
159
  xKey="i"
161
160
  yKey="v"
162
- color={color ?? "var(--chart-1)"}
161
+ color={color ?? "var(--chart-series-1)"}
163
162
  height={48}
164
163
  bare
165
164
  />
@@ -130,7 +130,7 @@
130
130
  cornerRadius={3}
131
131
  fill={colorKey
132
132
  ? String((arc.data as TData)[colorKey])
133
- : `var(--chart-${(i % 5) + 1})`}
133
+ : `var(--chart-series-${(i % 6) + 1})`}
134
134
  class="hr-donut-arc"
135
135
  data-hovered={isHovered ? "" : undefined}
136
136
  style="opacity: {dimmed ? 0.4 : 1}; translate: {dx}px {dy}px"
@@ -90,7 +90,7 @@
90
90
  endAngle = 405,
91
91
  useGradient = false,
92
92
  activeGradient,
93
- activeFill = "var(--chart-1)",
93
+ activeFill = "var(--chart-series-1)",
94
94
  inactiveFill,
95
95
  activeFillOpacity = 1,
96
96
  inactiveFillOpacity = 0.12,
@@ -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
  // =========================
@@ -68,7 +69,7 @@
68
69
  xKey,
69
70
  yKey,
70
71
  series: seriesInput,
71
- color = "var(--chart-1)",
72
+ color = "var(--chart-series-1)",
72
73
  height = 240,
73
74
  smooth = true,
74
75
  edgeFade = true,
@@ -104,7 +105,7 @@
104
105
  ? seriesInput.map((s, i) => ({
105
106
  key: s.key,
106
107
  label: s.label ?? s.key,
107
- color: s.color ?? `var(--chart-${(i % 5) + 1})`,
108
+ color: s.color ?? `var(--chart-series-${(i % 6) + 1})`,
108
109
  }))
109
110
  : yKey
110
111
  ? [{ key: yKey, label: yKey, color }]
@@ -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={() => ""} />
@@ -97,7 +97,7 @@
97
97
  deltaThreshold = 0.5,
98
98
  share,
99
99
  shareLabel,
100
- color = "var(--chart-1)",
100
+ color = "var(--chart-series-1)",
101
101
  size = "md",
102
102
  caption,
103
103
  negative = false,
@@ -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
  };
@@ -16,7 +16,7 @@
16
16
  value: number;
17
17
  /** Denominator for the progress arc. */
18
18
  maxValue: number;
19
- /** CSS color or var. Falls back to --chart-1..5. */
19
+ /** CSS color or var. Falls back to --chart-series-1..6. */
20
20
  color?: string;
21
21
  };
22
22
 
@@ -82,7 +82,7 @@
82
82
  index: i,
83
83
  radius,
84
84
  progress,
85
- color: d.color ?? `var(--chart-${(i % 5) + 1})`,
85
+ color: d.color ?? `var(--chart-series-${(i % 6) + 1})`,
86
86
  };
87
87
  }),
88
88
  );
@@ -54,7 +54,7 @@
54
54
  data: input,
55
55
  xKey,
56
56
  yKey,
57
- color = "var(--chart-1)",
57
+ color = "var(--chart-series-1)",
58
58
  height = 44,
59
59
  smooth = true,
60
60
  endCap = true,
@@ -81,7 +81,7 @@
81
81
  trend,
82
82
  caption,
83
83
  figureOnly = false,
84
- color = "var(--chart-1)",
84
+ color = "var(--chart-series-1)",
85
85
  height = 150,
86
86
  smooth = true,
87
87
  edgeFade = true,
@@ -62,8 +62,8 @@
62
62
  data,
63
63
  label = "Value",
64
64
  label2,
65
- color = "var(--chart-1)",
66
- color2 = "var(--muted-foreground)",
65
+ color = "var(--chart-series-1)",
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
 
@@ -1,4 +1,10 @@
1
1
  <script lang="ts" generics="T">
2
+ /*
3
+ * The one sortable column head, for TableView, the dashboard DataTable and hand-built
4
+ * tables. Every sortable head shows its caret: hover-only makes people hunt.
5
+ */
6
+
7
+ // ========================= IMPORTS
2
8
  import type { Snippet } from "svelte";
3
9
  import { CaretDown, CaretUp, CaretUpDown } from "phosphor-svelte";
4
10
  import { cn } from "../../lib/utils.js";
@@ -8,21 +14,36 @@
8
14
  interface Props {
9
15
  key: keyof T & string;
10
16
  sort: SortState<T>;
17
+ /** Called on click and on Enter/Space. Choosing the next direction is the caller's. */
11
18
  onSort: (key: keyof T & string) => void;
19
+ /** Numbers right-align to their digits, so their caret leads the label. */
20
+ numeric?: boolean;
21
+ /** The long form of an abbreviated head, as the browser tooltip. */
22
+ title?: string;
12
23
  class?: string;
24
+ style?: string;
13
25
  children: Snippet;
14
26
  }
15
27
 
16
28
  // ========================= PROPS
17
- let { key, sort, onSort, class: className, children }: Props = $props();
29
+ let {
30
+ key,
31
+ sort,
32
+ onSort,
33
+ numeric = false,
34
+ title,
35
+ class: className,
36
+ style,
37
+ children,
38
+ }: Props = $props();
18
39
 
19
40
  // ========================= DERIVED
20
- let active = $derived(sort?.key === key);
21
- let direction = $derived<SortDirection | null>(sort && active ? sort.direction : null);
41
+ let direction = $derived<SortDirection | null>(sort?.key === key ? sort.direction : null);
22
42
  let ariaSort = $derived<"ascending" | "descending" | "none">(
23
43
  direction === "asc" ? "ascending" : direction === "desc" ? "descending" : "none",
24
44
  );
25
45
 
46
+ // ========================= FUNCTIONS
26
47
  function handleKeydown(e: KeyboardEvent) {
27
48
  if (e.key === "Enter" || e.key === " ") {
28
49
  e.preventDefault();
@@ -33,9 +54,14 @@
33
54
 
34
55
  <th
35
56
  data-slot="table-head"
57
+ scope="col"
58
+ {title}
59
+ {style}
36
60
  class={cn(
37
61
  "text-muted-foreground h-8 px-2 text-left align-middle text-xs font-medium whitespace-nowrap select-none",
38
62
  "cursor-pointer transition-colors duration-quick ease-smooth-out hover:bg-foreground/4 motion-reduce:transition-none",
63
+ "outline-none focus-visible:shadow-(--ring-focus-inset)",
64
+ numeric && "text-right",
39
65
  className,
40
66
  )}
41
67
  tabindex="0"
@@ -43,14 +69,23 @@
43
69
  onclick={() => onSort(key)}
44
70
  onkeydown={handleKeydown}
45
71
  >
46
- <span class="inline-flex items-center gap-1">
47
- {@render children()}
48
- {#if direction === "asc"}
49
- <CaretUp class="size-3.5 text-foreground" weight="bold" />
50
- {:else if direction === "desc"}
51
- <CaretDown class="size-3.5 text-foreground" weight="bold" />
72
+ <!-- Pinned to the line's top, not its baseline: a leading caret gave the span the icon's foot
73
+ as its baseline and lifted a numeric label 1.5px above the plain heads beside it. -->
74
+ <span class="inline-flex items-center gap-1 align-top">
75
+ {#if numeric}
76
+ {@render caret()}{@render children()}
52
77
  {:else}
53
- <CaretUpDown class="size-3.5 text-muted-foreground" />
78
+ {@render children()}{@render caret()}
54
79
  {/if}
55
80
  </span>
56
81
  </th>
82
+
83
+ {#snippet caret()}
84
+ {#if direction === "asc"}
85
+ <CaretUp class="size-3.5 shrink-0 text-foreground" weight="bold" aria-hidden="true" />
86
+ {:else if direction === "desc"}
87
+ <CaretDown class="size-3.5 shrink-0 text-foreground" weight="bold" aria-hidden="true" />
88
+ {:else}
89
+ <CaretUpDown class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
90
+ {/if}
91
+ {/snippet}
@@ -0,0 +1,5 @@
1
+ /** Language code to flag image. The host serves these files from /flags. */
2
+ export const LANGUAGE_FLAGS: Record<string, string> = {
3
+ en: "/flags/US.svg",
4
+ fr: "/flags/FR.svg",
5
+ };
@@ -1 +1,2 @@
1
1
  export { default as LanguageFlag } from "./language-flag.svelte";
2
+ export { LANGUAGE_FLAGS } from "./flags.js";