@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
@@ -40,9 +40,7 @@
40
40
  "rounded-2xl! top-[max(0.5rem,env(safe-area-inset-top))] right-2 bottom-auto left-2 w-auto max-h-[70svh] translate-y-0 gap-0 overflow-hidden p-0 sm:p-0",
41
41
  // WHY no border and no card background here: the palette is a floating
42
42
 
43
- // object, so its edge is a lit rim and a cast shadow rather than a drawn line, and the surface itself belongs to the Command root beneath so its translucency
44
-
45
- // can read the dimmed page through it.
43
+ // object, so its edge is a lit rim and a cast shadow rather than a drawn line, and the surface belongs to the Command root beneath.
46
44
  "border-0 bg-transparent shadow-edge-overlay",
47
45
  // WHY a third of the way down rather than centred: the eye is already at
48
46
 
@@ -55,12 +53,9 @@
55
53
  {showCloseButton}
56
54
  {portalProps}
57
55
  >
58
- <Command
59
- {...restProps}
60
- bind:value
61
- bind:ref
62
- class="bg-popover supports-backdrop-filter:bg-popover/94 supports-backdrop-filter:backdrop-blur-2xl"
63
- {children}
64
- />
56
+ <!-- WHY the root's opaque `bg-popover`, no glass: `.t-modal` on the content sets `will-change:
57
+ opacity`, which makes the content the backdrop root, so a blur here read only the empty content
58
+ box, never the page. Its 94% surface let page text show through the rows (light, 390px). -->
59
+ <Command {...restProps} bind:value bind:ref {children} />
65
60
  </Dialog.Content>
66
61
  </Dialog.Root>
@@ -13,7 +13,7 @@
13
13
  bind:ref
14
14
  data-slot="dialog-overlay"
15
15
  class={cn(
16
- "t-modal-backdrop supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
16
+ "t-modal-backdrop fixed inset-0 isolate z-50",
17
17
  className,
18
18
  )}
19
19
  {...restProps}
@@ -25,7 +25,7 @@
25
25
  bind:this={ref}
26
26
  data-slot={dataSlot}
27
27
  class={cn(
28
- "dark:bg-input/30 border-input enabled:hover:border-foreground/20 focus-visible:border-ring focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 any-pointer-coarse:h-11 rounded-lg border bg-transparent px-2.5 py-1 text-base shadow-field transition-[color,background-color,border-color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] file:h-6 file:text-sm file:font-medium aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
28
+ "dark:bg-input/30 border-input enabled:hover:border-foreground/20 focus-visible:border-ring focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-(--control-height) rounded-lg border bg-transparent px-2.5 py-1 text-base shadow-field transition-[color,background-color,border-color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] file:h-6 file:text-sm file:font-medium aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
29
29
  className,
30
30
  )}
31
31
  type="file"
@@ -38,7 +38,7 @@
38
38
  bind:this={ref}
39
39
  data-slot={dataSlot}
40
40
  class={cn(
41
- "dark:bg-input/30 border-input enabled:hover:border-foreground/20 focus-visible:border-ring focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 any-pointer-coarse:h-11 rounded-lg border bg-transparent px-2.5 py-1 text-base shadow-field transition-[color,background-color,border-color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] file:h-6 file:text-sm file:font-medium aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
41
+ "dark:bg-input/30 border-input enabled:hover:border-foreground/20 focus-visible:border-ring focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-(--control-height) rounded-lg border bg-transparent px-2.5 py-1 text-base shadow-field transition-[color,background-color,border-color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] file:h-6 file:text-sm file:font-medium aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
42
42
  className,
43
43
  )}
44
44
  {type}
@@ -13,7 +13,7 @@
13
13
  bind:ref
14
14
  data-slot="sheet-overlay"
15
15
  class={cn(
16
- "t-modal-backdrop bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
16
+ "t-modal-backdrop fixed inset-0 z-50",
17
17
  className,
18
18
  )}
19
19
  {...restProps}
@@ -10,4 +10,4 @@ export type {
10
10
  Statuses,
11
11
  } from "./status-types.js";
12
12
  export { default as StatusPill } from "./status-pill.svelte";
13
- export { PILL_TINT } from "./pill-tint.js";
13
+ export { TONE_TINT as PILL_TINT } from "../lib/tone.js";
@@ -1,13 +1,7 @@
1
- <script lang="ts" module>
2
- import type { Tone } from "../lib/tone.js";
3
- import { PILL_TINT } from "./pill-tint.js";
4
-
5
- export { PILL_TINT };
6
- </script>
7
-
8
1
  <script lang="ts">
9
2
  import type { Snippet } from "svelte";
10
3
  import type { HTMLAttributes } from "svelte/elements";
4
+ import { TONE_TINT, type Tone } from "../lib/tone.js";
11
5
  import { cn } from "../lib/utils.js";
12
6
 
13
7
  interface Props extends Omit<HTMLAttributes<HTMLSpanElement>, "class" | "children"> {
@@ -30,7 +24,7 @@
30
24
  "transition-[background-color,color] duration-quick ease-smooth-out motion-reduce:transition-none",
31
25
  "[&>svg]:size-3 [&>svg]:shrink-0",
32
26
  icon && "pl-1.5",
33
- PILL_TINT[tone],
27
+ TONE_TINT[tone],
34
28
  className,
35
29
  )}
36
30
  {...restProps}
@@ -2,6 +2,7 @@
2
2
  import type { Snippet } from "svelte";
3
3
  import { ArrowBendDownRight, Check, MapPin, PencilSimple, Trash } from "phosphor-svelte";
4
4
  import { cn } from "../lib/utils.js";
5
+ import { shortDate } from "../lib/dates.js";
5
6
  import { initial } from "../lib/initials.js";
6
7
  import { AgentAvatar } from "../agents/index.js";
7
8
  import RichRenderer from "../editor/renderer.svelte";
@@ -64,8 +65,8 @@
64
65
  avatar,
65
66
  attachments,
66
67
  anchorChip,
67
- when = (comment) => shortDate(comment.createdAt),
68
- whenEvent = (event) => shortDate(event.at),
68
+ when = (comment) => shortDate(comment.createdAt, { locale }),
69
+ whenEvent = (event) => shortDate(event.at, { locale }),
69
70
  onPost,
70
71
  onEdit,
71
72
  onDelete,
@@ -84,10 +85,6 @@
84
85
  let shown = $derived(showResolved ? nodes : nodes.filter((node) => !node.comment.resolved));
85
86
  let items = $derived(interleave(shown, events));
86
87
 
87
- function shortDate(at: string): string {
88
- return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }).format(new Date(at));
89
- }
90
-
91
88
  let draft = $state("");
92
89
  let replyTo = $state<string | null>(null);
93
90
  let replyDraft = $state("");
@@ -23,6 +23,21 @@
23
23
  --text-2xs: 0.6875rem; /* a count, a kbd hint, a meta line under a tile; the floor */
24
24
  }
25
25
 
26
+ /* ── Control height ──────────────────────────────────────────
27
+ One height for every control that sits in a row together: an input, a
28
+ default button, a select trigger. 32px on a desktop row, 44px under a finger
29
+ (DESIGN.md rule 12), so a search field and its button always line up. Write
30
+ it `h-(--control-height)`. */
31
+ :root {
32
+ --control-height: 2rem;
33
+ }
34
+
35
+ @media (any-pointer: coarse) {
36
+ :root {
37
+ --control-height: 2.75rem;
38
+ }
39
+ }
40
+
26
41
  /* ── Elevation ───────────────────────────────────────────────
27
42
  `--elev-card` and its hover carry their own hairline, so a box that also
28
43
  draws `border` gets a 2px edge. The other steps carry none. */
@@ -88,6 +103,21 @@
88
103
  --elev-field: inset 0 1px 2px color-mix(in oklab, black 40%, transparent);
89
104
  }
90
105
 
106
+ /* ── Scrim ───────────────────────────────────────────────────
107
+ The dim behind a dialog, an alert dialog and a sheet. A dim with a 2px blur:
108
+ the page behind stays legible as the page you came from, so the overlay reads
109
+ as on top of it, not instead of it. Dark needs a deeper dim, since 32% black
110
+ on an already dark page barely moves it. */
111
+ :root {
112
+ --scrim: color-mix(in oklab, var(--elev-tint) 32%, transparent);
113
+ --scrim-blur: var(--blur-small);
114
+ }
115
+
116
+ /* After `:root`, since `.dark` sits on the same element at the same specificity. */
117
+ .dark {
118
+ --scrim: color-mix(in oklab, black 55%, transparent);
119
+ }
120
+
91
121
  /* ── Focus ───────────────────────────────────────────────────
92
122
  One variable, so every control focuses the same way: a solid accent edge,
93
123
  then a soft halo. */
@@ -0,0 +1,28 @@
1
+ # @heyramzi/ui/triage
2
+
3
+ The three pieces of an email triage screen. They never fetch: you pass the words and callbacks, and they hand back what the person did.
4
+
5
+ - `TriagePlan` is one email with the plan the model proposes ("Create a task in Clients") and three ways out: approve, type another task, dismiss.
6
+ - `TriageTask` is one settled email: what happened, the task it became, when, and Undo when the host passes `onUndo`.
7
+ - `TriageQueue` is the list around them, with the empty state. `kind="plans"` stacks cards, `kind="tasks"` draws one divided list.
8
+
9
+ ```svelte
10
+ <script lang="ts">
11
+ import { TriageQueue, TriagePlan } from "@heyramzi/ui/triage";
12
+ </script>
13
+
14
+ <TriageQueue items={emails} key={(e) => e.id} empty="Nothing to review.">
15
+ {#snippet row(e)}
16
+ <TriagePlan
17
+ sender={e.from}
18
+ subject={e.subject}
19
+ plan={e.plan}
20
+ onApprove={() => approve(e.id)}
21
+ onPick={(task) => approve(e.id, task)}
22
+ onDismiss={() => dismiss(e.id)}
23
+ />
24
+ {/snippet}
25
+ </TriageQueue>
26
+ ```
27
+
28
+ `labels` on each piece is a partial, so a host translates it. A callback may return a promise: the buttons stay disabled until it settles.
@@ -0,0 +1,10 @@
1
+ export { default as TriagePlan } from "./triage-plan.svelte";
2
+ export { default as TriageTask } from "./triage-task.svelte";
3
+ export { default as TriageQueue } from "./triage-queue.svelte";
4
+ export { DEFAULT_TRIAGE_PLAN_LABELS, DEFAULT_TRIAGE_TASK_LABELS } from "./types.js";
5
+ export type {
6
+ TriageOutcome,
7
+ TriagePlanLabels,
8
+ TriageTaskLabels,
9
+ TriageTaskLink,
10
+ } from "./types.js";
@@ -0,0 +1,132 @@
1
+ <script lang="ts">
2
+ import { Button } from "../shadcn/button/index.js";
3
+ import { Input } from "../shadcn/input/index.js";
4
+ import { cn } from "../lib/utils.js";
5
+ import { DEFAULT_TRIAGE_PLAN_LABELS, type TriagePlanLabels } from "./types.js";
6
+
7
+ interface Props {
8
+ /** Who sent the email. */
9
+ sender: string;
10
+ subject: string;
11
+ /** The first lines of the body. */
12
+ snippet?: string;
13
+ /** When it arrived, as text the host already formatted. */
14
+ received?: string | null;
15
+ /** The ISO time behind `received`, for the `time` element. */
16
+ receivedAt?: string | null;
17
+ /** What the plan will do, as one sentence. Without it the card only offers to dismiss. */
18
+ plan?: string | null;
19
+ /** Why the model wasn't sure. */
20
+ reason?: string | null;
21
+ /** A failed attempt, shown on the card. */
22
+ error?: string | null;
23
+ labels?: Partial<TriagePlanLabels>;
24
+ /** Run the plan. The buttons stay disabled until it settles. */
25
+ onApprove: () => void | Promise<void>;
26
+ /** Run a task the person typed instead of the plan. */
27
+ onPick: (task: string) => void | Promise<void>;
28
+ onDismiss: () => void | Promise<void>;
29
+ class?: string;
30
+ }
31
+
32
+ let {
33
+ sender,
34
+ subject,
35
+ snippet,
36
+ received,
37
+ receivedAt,
38
+ plan = null,
39
+ reason,
40
+ error,
41
+ labels,
42
+ onApprove,
43
+ onPick,
44
+ onDismiss,
45
+ class: className,
46
+ }: Props = $props();
47
+
48
+ const id = $props.id();
49
+ let words = $derived({ ...DEFAULT_TRIAGE_PLAN_LABELS, ...labels });
50
+ let picking = $state(false);
51
+ let busy = $state(false);
52
+ let task = $state("");
53
+
54
+ async function run(job: () => void | Promise<void>) {
55
+ busy = true;
56
+ try {
57
+ await job();
58
+ } finally {
59
+ busy = false;
60
+ }
61
+ }
62
+ </script>
63
+
64
+ <article class={cn("bg-card border-border rounded-xl border p-5", className)}>
65
+ <div class="flex items-start justify-between gap-3">
66
+ <h2 class="text-foreground min-w-0 text-sm font-semibold [overflow-wrap:anywhere]">
67
+ {sender}: “{subject}”
68
+ </h2>
69
+ {#if received}
70
+ <time class="text-muted-foreground shrink-0 text-xs" datetime={receivedAt ?? undefined}>
71
+ {received}
72
+ </time>
73
+ {/if}
74
+ </div>
75
+
76
+ {#if snippet}
77
+ <p class="text-muted-foreground mt-2 line-clamp-2 text-sm [overflow-wrap:anywhere]">
78
+ {snippet}
79
+ </p>
80
+ {/if}
81
+
82
+ {#if plan}
83
+ <p
84
+ class="bg-primary/10 text-primary mt-4 inline-block max-w-full rounded-md px-2.5 py-1 text-xs font-medium [overflow-wrap:anywhere]"
85
+ >
86
+ {plan}
87
+ </p>
88
+ {/if}
89
+ {#if reason}
90
+ <p class="text-muted-foreground mt-2 text-xs [overflow-wrap:anywhere]">{reason}</p>
91
+ {/if}
92
+
93
+ {#if error}
94
+ <p
95
+ class="bg-destructive/10 text-destructive mt-3 rounded-lg px-3 py-2 text-sm [overflow-wrap:anywhere]"
96
+ role="alert"
97
+ >
98
+ {error}
99
+ </p>
100
+ {/if}
101
+
102
+ {#if picking}
103
+ <form
104
+ class="mt-4 space-y-2"
105
+ onsubmit={(event) => {
106
+ event.preventDefault();
107
+ run(() => onPick(task.trim()));
108
+ }}
109
+ >
110
+ <label for="{id}-task" class="text-foreground block text-sm font-medium">
111
+ {words.taskLabel}
112
+ </label>
113
+ <Input id="{id}-task" bind:value={task} required autocomplete="off" />
114
+ <div class="flex flex-wrap gap-2">
115
+ <Button type="submit" disabled={busy}>{words.useTask}</Button>
116
+ <Button type="button" variant="outline" onclick={() => (picking = false)}>
117
+ {words.cancel}
118
+ </Button>
119
+ </div>
120
+ </form>
121
+ {:else}
122
+ <div class="mt-4 flex flex-wrap gap-2">
123
+ {#if plan}
124
+ <Button disabled={busy} onclick={() => run(onApprove)}>{words.approve}</Button>
125
+ <Button variant="outline" onclick={() => (picking = true)}>{words.pick}</Button>
126
+ {/if}
127
+ <Button variant="ghost" disabled={busy} onclick={() => run(onDismiss)}>
128
+ {words.dismiss}
129
+ </Button>
130
+ </div>
131
+ {/if}
132
+ </article>
@@ -0,0 +1,48 @@
1
+ <script lang="ts" generics="T">
2
+ import type { Snippet } from "svelte";
3
+ import { cn } from "../lib/utils.js";
4
+
5
+ interface Props {
6
+ items: readonly T[];
7
+ /** The key a row is tracked by. */
8
+ key: (item: T) => string;
9
+ /**
10
+ * `plans` stacks cards that need a decision. `tasks` is one divided list of what was
11
+ * decided.
12
+ */
13
+ kind?: "plans" | "tasks";
14
+ /** One row: a `TriagePlan` for `plans`, a `TriageTask` for `tasks`. */
15
+ row: Snippet<[T]>;
16
+ /** What the person reads when nothing is waiting. */
17
+ empty: string;
18
+ /** The glyph above the empty text. */
19
+ icon?: Snippet;
20
+ class?: string;
21
+ }
22
+
23
+ let { items, key, kind = "plans", row, empty, icon, class: className }: Props = $props();
24
+ </script>
25
+
26
+ {#if items.length === 0}
27
+ <div
28
+ class={cn(
29
+ "border-border flex flex-col items-center gap-3 rounded-xl border border-dashed px-6 py-14 text-center",
30
+ className,
31
+ )}
32
+ >
33
+ <span class="text-muted-foreground" aria-hidden="true">{@render icon?.()}</span>
34
+ <p class="text-muted-foreground text-sm">{empty}</p>
35
+ </div>
36
+ {:else if kind === "plans"}
37
+ <div class={cn("space-y-4", className)}>
38
+ {#each items as item (key(item))}
39
+ {@render row(item)}
40
+ {/each}
41
+ </div>
42
+ {:else}
43
+ <ul class={cn("bg-card border-border divide-border divide-y rounded-xl border", className)}>
44
+ {#each items as item (key(item))}
45
+ {@render row(item)}
46
+ {/each}
47
+ </ul>
48
+ {/if}
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import { Button } from "../shadcn/button/index.js";
3
+ import StatusBadge from "../status/status-badge.svelte";
4
+ import { cn } from "../lib/utils.js";
5
+ import {
6
+ DEFAULT_TRIAGE_TASK_LABELS,
7
+ type TriageOutcome,
8
+ type TriageTaskLabels,
9
+ type TriageTaskLink,
10
+ } from "./types.js";
11
+
12
+ interface Props {
13
+ sender: string;
14
+ subject: string;
15
+ /** What happened to the email. */
16
+ outcome: TriageOutcome;
17
+ /** The task it became. */
18
+ task?: TriageTaskLink | null;
19
+ /** When it settled, as text the host already formatted. */
20
+ when: string;
21
+ whenAt?: string | null;
22
+ /** Why it failed. */
23
+ detail?: string | null;
24
+ error?: string | null;
25
+ labels?: Partial<TriageTaskLabels>;
26
+ /** Takes the change back. The button shows only when this is passed. */
27
+ onUndo?: () => void | Promise<void>;
28
+ class?: string;
29
+ }
30
+
31
+ let {
32
+ sender,
33
+ subject,
34
+ outcome,
35
+ task,
36
+ when,
37
+ whenAt,
38
+ detail,
39
+ error,
40
+ labels,
41
+ onUndo,
42
+ class: className,
43
+ }: Props = $props();
44
+
45
+ let words = $derived({ ...DEFAULT_TRIAGE_TASK_LABELS, ...labels });
46
+ let busy = $state(false);
47
+
48
+ async function undo() {
49
+ busy = true;
50
+ try {
51
+ await onUndo?.();
52
+ } finally {
53
+ busy = false;
54
+ }
55
+ }
56
+ </script>
57
+
58
+ <li
59
+ class={cn(
60
+ "flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between",
61
+ className,
62
+ )}
63
+ >
64
+ <div class="min-w-0 space-y-1">
65
+ <p class="text-foreground text-sm font-medium [overflow-wrap:anywhere]">
66
+ {sender}: {subject}
67
+ </p>
68
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
69
+ <StatusBadge tone={outcome.tone} capitalize={false}>{outcome.label}</StatusBadge>
70
+ {#if task}
71
+ <a
72
+ href={task.url}
73
+ target="_blank"
74
+ rel="noreferrer"
75
+ class="text-primary min-w-0 text-xs [overflow-wrap:anywhere] hover:underline"
76
+ >
77
+ {task.name ?? words.openTask}
78
+ </a>
79
+ {/if}
80
+ <time class="text-muted-foreground text-xs" datetime={whenAt ?? undefined}>{when}</time>
81
+ </div>
82
+ {#if detail}
83
+ <p class="text-muted-foreground text-xs [overflow-wrap:anywhere]">{detail}</p>
84
+ {/if}
85
+ {#if error}
86
+ <p class="text-destructive text-xs [overflow-wrap:anywhere]" role="alert">{error}</p>
87
+ {/if}
88
+ </div>
89
+
90
+ {#if onUndo}
91
+ <Button variant="outline" class="shrink-0" disabled={busy} onclick={undo}>
92
+ {words.undo}
93
+ </Button>
94
+ {/if}
95
+ </li>
@@ -0,0 +1,35 @@
1
+ import type { Tone } from "../lib/tone.js";
2
+
3
+ /** The words a plan card shows. Pass a partial to translate or reword. */
4
+ export type TriagePlanLabels = {
5
+ approve: string;
6
+ pick: string;
7
+ dismiss: string;
8
+ /** The label over the field where the person types the task to use instead. */
9
+ taskLabel: string;
10
+ useTask: string;
11
+ cancel: string;
12
+ };
13
+
14
+ export const DEFAULT_TRIAGE_PLAN_LABELS: TriagePlanLabels = {
15
+ approve: "Approve",
16
+ pick: "Pick another task",
17
+ dismiss: "Dismiss",
18
+ taskLabel: "Task",
19
+ useTask: "Use this task",
20
+ cancel: "Cancel",
21
+ };
22
+
23
+ /** The words a task row shows. */
24
+ export type TriageTaskLabels = { undo: string; openTask: string };
25
+
26
+ export const DEFAULT_TRIAGE_TASK_LABELS: TriageTaskLabels = {
27
+ undo: "Undo",
28
+ openTask: "Open task",
29
+ };
30
+
31
+ /** What happened to a settled email: the chip's words and its tone. */
32
+ export type TriageOutcome = { label: string; tone: Tone };
33
+
34
+ /** The task the email ended up as, when there is one. */
35
+ export type TriageTaskLink = { name?: string | null; url: string };
@@ -286,7 +286,6 @@ exported helpers.
286
286
  A badge also takes its text as children, for a caller that already has the word.
287
287
  - `defineStatuses`: builds a status registry from a plain map.
288
288
  - `SidePanel`: a sheet for one record, above.
289
- - `salesStatuses`: an example pipeline map.
290
289
 
291
290
  ## Styling
292
291
 
package/src/work/dates.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // Dates are plain "YYYY-MM-DD" strings, counted as whole UTC days. A timeline
2
2
  // only needs days, and day numbers dodge every timezone and DST edge.
3
+ import { localDay } from "../lib/dates.js";
4
+
3
5
  const DAY_MS = 86_400_000;
4
6
 
5
7
  /** Days since 1970-01-01 for an ISO date. Anything after the date part is ignored. */
@@ -20,9 +22,7 @@ export function addDays(iso: string, days: number): string {
20
22
 
21
23
  /** Today in the viewer's own timezone, as an ISO date. */
22
24
  export function todayIso(): string {
23
- const now = new Date();
24
- const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
25
- return local.toISOString().slice(0, 10);
25
+ return localDay();
26
26
  }
27
27
 
28
28
  /** 0 is Sunday, as in `Date.getUTCDay`. */
@@ -20,7 +20,6 @@
20
20
  import StatusDot from "../status/status-dot.svelte";
21
21
  import { defineStatuses } from "../status/define-statuses.js";
22
22
  import { addDays, dayNumber, todayIso } from "./dates.js";
23
- import { salesStatuses } from "./presets/sales.js";
24
23
  import type { KanbanMove } from "./kanban-types.js";
25
24
  import type { GanttChange } from "./task-types.js";
26
25
  import TimeTracker from "./time/time-tracker.svelte";
@@ -48,6 +47,15 @@
48
47
  done: { label: "Done", tone: "success", variant: "completed" },
49
48
  });
50
49
 
50
+ const salesStatuses = defineStatuses({
51
+ new: { label: "New", tone: "accent", variant: "open" },
52
+ contacted: { label: "Contacted", tone: "info", variant: "open" },
53
+ proposal: { label: "Proposal", tone: "accent", variant: "progress", progress: 0.25 },
54
+ negotiation: { label: "Negotiation", tone: "warning", variant: "progress", progress: 0.75 },
55
+ won: { label: "Won", tone: "success", variant: "completed" },
56
+ lost: { label: "Lost", tone: "destructive", variant: "lost" },
57
+ });
58
+
51
59
  const columns = [
52
60
  { id: "todo", label: "To do", hue: "var(--color-muted-foreground)" },
53
61
  { id: "doing", label: "In progress", hue: "var(--color-info)" },