@heyramzi/ui 2.0.1 → 2.1.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 (68) hide show
  1. package/package.json +42 -17
  2. package/src/canvas/README.md +9 -12
  3. package/src/charts/dashboard/data-panel.svelte +1 -1
  4. package/src/components/list-page/detail-panel.svelte +1 -1
  5. package/src/components/list-page/filter-popover.svelte +1 -1
  6. package/src/components/list-page/index.ts +3 -3
  7. package/src/components/list-page/rail-head.svelte +1 -1
  8. package/src/components/section-header/index.ts +1 -0
  9. package/src/components/section-header/section-header.svelte +1 -1
  10. package/src/components/toolbar/index.ts +2 -0
  11. package/src/components/toolbar/toolbar.svelte +2 -2
  12. package/src/inbox/README.md +29 -0
  13. package/src/inbox/inbox.test.ts +171 -0
  14. package/src/inbox/index.ts +31 -0
  15. package/src/inbox/logic.ts +166 -0
  16. package/src/inbox/thread-inbox.svelte +363 -0
  17. package/src/inbox/types.ts +139 -0
  18. package/src/modules.test.ts +95 -0
  19. package/src/panel/README.md +5 -0
  20. package/src/panel/index.ts +2 -0
  21. package/src/{tasks → panel}/side-panel.svelte +1 -1
  22. package/src/record/README.md +3 -0
  23. package/src/{tasks/record → record}/field-section.svelte +1 -1
  24. package/src/{tasks/record → record}/info-list.svelte +9 -9
  25. package/src/{tasks/record → record}/next-up.svelte +4 -4
  26. package/src/{tasks/record → record}/project-list.svelte +5 -5
  27. package/src/{tasks/record → record}/record-demo.svelte +4 -4
  28. package/src/{tasks/record → record}/record-header.svelte +3 -3
  29. package/src/{tasks/record → record}/record-shell.svelte +4 -4
  30. package/src/{tasks/record → record}/record-stats.svelte +2 -2
  31. package/src/{tasks/record → record}/record-timeline.svelte +6 -6
  32. package/src/{tasks/record → record}/steps-rail.svelte +7 -7
  33. package/src/{tasks/record → record}/types.ts +2 -2
  34. package/src/{tasks/record → record}/where-we-are.svelte +4 -4
  35. package/src/status/README.md +18 -0
  36. package/src/status/index.ts +11 -0
  37. package/src/tasks/README.md +1 -1
  38. package/src/tasks/demo.svelte +3 -3
  39. package/src/tasks/detail/detail-demo.svelte +2 -2
  40. package/src/tasks/gantt/gantt.test.ts +1 -1
  41. package/src/tasks/inbox/inbox-card.svelte +2 -2
  42. package/src/tasks/inbox/inbox-statuses.ts +1 -1
  43. package/src/tasks/inbox/inbox.svelte +1 -1
  44. package/src/tasks/index.ts +9 -7
  45. package/src/tasks/presets/sales.ts +1 -1
  46. package/src/thread/README.md +31 -0
  47. package/src/thread/index.ts +12 -0
  48. package/src/thread/logic.ts +58 -0
  49. package/src/thread/thread.svelte +366 -0
  50. package/src/thread/thread.test.ts +62 -0
  51. package/src/thread/types.ts +93 -0
  52. /package/src/components/{list-page → section-header}/section-label.svelte +0 -0
  53. /package/src/components/{list-page → toolbar}/toolbar-search.svelte +0 -0
  54. /package/src/components/{list-page → toolbar}/toolbar-trigger.ts +0 -0
  55. /package/src/{tasks → panel}/side-panel-types.ts +0 -0
  56. /package/src/{tasks/record → record}/demo-data.ts +0 -0
  57. /package/src/{tasks/record → record}/fields.ts +0 -0
  58. /package/src/{tasks/record → record}/format.ts +0 -0
  59. /package/src/{tasks/record → record}/gutter.ts +0 -0
  60. /package/src/{tasks/record → record}/index.ts +0 -0
  61. /package/src/{tasks/record → record}/kinds.ts +0 -0
  62. /package/src/{tasks/record → record}/logic.ts +0 -0
  63. /package/src/{tasks/record → record}/record.test.ts +0 -0
  64. /package/src/{tasks → status}/define-statuses.ts +0 -0
  65. /package/src/{tasks → status}/status-badge.svelte +0 -0
  66. /package/src/{tasks → status}/status-dot.svelte +0 -0
  67. /package/src/{tasks → status}/status-icon.svelte +0 -0
  68. /package/src/{tasks → status}/status-types.ts +0 -0
@@ -0,0 +1,363 @@
1
+ <script lang="ts">
2
+ import { tick, type Snippet } from "svelte";
3
+ import { ArrowLeft, BellSlash, Circle, Envelope, EnvelopeOpen } from "phosphor-svelte";
4
+ import { AgentAvatar } from "../agents/index.js";
5
+ import { Badge } from "../shadcn/badge/index.js";
6
+ import { Button } from "../shadcn/button/index.js";
7
+ import * as Empty from "../shadcn/empty/index.js";
8
+ import { Kbd, KbdGroup } from "../shadcn/kbd/index.js";
9
+ import { ToggleGroup, ToggleGroupItem } from "../shadcn/toggle-group/index.js";
10
+ import { cn } from "../lib/utils.js";
11
+ import { TONE_CLASS } from "../lib/tone.js";
12
+ import InboxCard from "../tasks/inbox/inbox-card.svelte";
13
+ import type { InboxItem, InboxVerdict } from "../tasks/inbox/inbox-types.js";
14
+ import {
15
+ NO_FILTER,
16
+ actorNames,
17
+ entryLine,
18
+ filterThreads,
19
+ inboxKeyOf,
20
+ resolveInboxLabels,
21
+ snoozeChoices,
22
+ subjectTypes,
23
+ threadsOf,
24
+ } from "./logic.js";
25
+ import {
26
+ ALL,
27
+ type InboxActor,
28
+ type InboxEntry,
29
+ type InboxFilter,
30
+ type InboxLabels,
31
+ type InboxSnooze,
32
+ type InboxSubject,
33
+ type InboxThread,
34
+ } from "./types.js";
35
+
36
+ interface Props {
37
+ entries: InboxEntry[];
38
+ /** The key of the open thread. Without a match the first thread opens. */
39
+ selected?: string;
40
+ /** Threads with unread entries come first. */
41
+ unreadFirst?: boolean;
42
+ labels?: Partial<InboxLabels>;
43
+ /** Display names for subject types, such as `{ task: "Tasks" }`. A missing type shows as written. */
44
+ typeLabels?: Record<string, string>;
45
+ /** Replaces the default avatar for a person or an agent, drawn in a 24px slot. */
46
+ avatar?: Snippet<[InboxActor]>;
47
+ /** A badge for the subject's status, drawn by the host with its own status map. */
48
+ status?: Snippet<[InboxSubject]>;
49
+ /** Replaces a pinned proposal's summary lines. */
50
+ renderProposal?: Snippet<[InboxItem]>;
51
+ /** How a timestamp reads. Defaults to a short date and time. */
52
+ when?: (at: string) => string;
53
+ onSelect?: (thread: InboxThread) => void;
54
+ /** `read` is true when the thread had unread entries, so the host marks them all read. */
55
+ onRead?: (thread: InboxThread, read: boolean) => void;
56
+ onSnooze?: (request: InboxSnooze) => void;
57
+ onDecide?: (verdict: InboxVerdict, thread: InboxThread) => void;
58
+ class?: string;
59
+ }
60
+
61
+ let {
62
+ entries,
63
+ selected = $bindable(),
64
+ unreadFirst = false,
65
+ labels: labelOverrides,
66
+ typeLabels = {},
67
+ avatar,
68
+ status,
69
+ renderProposal,
70
+ when = (at) => {
71
+ const date = new Date(at);
72
+ return Number.isNaN(date.getTime())
73
+ ? ""
74
+ : date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
75
+ },
76
+ onSelect,
77
+ onRead,
78
+ onSnooze,
79
+ onDecide,
80
+ class: className,
81
+ }: Props = $props();
82
+
83
+ let labels = $derived(resolveInboxLabels(labelOverrides));
84
+ let filter = $state<InboxFilter>({ ...NO_FILTER });
85
+ let snoozing = $state(false);
86
+ let detailOpen = $state(false);
87
+ let cards = $state<Record<string, ReturnType<typeof InboxCard> | null>>({});
88
+
89
+ let all = $derived(threadsOf(entries, { unreadFirst }));
90
+ let visible = $derived(filterThreads(all, filter));
91
+ let types = $derived(subjectTypes(all));
92
+ let people = $derived(actorNames(all));
93
+ let current = $derived(visible.find((thread) => thread.key === selected) ?? visible[0]);
94
+ let needsYouCount = $derived(all.filter((thread) => thread.pending.length > 0).length);
95
+ let filtered = $derived(filter.type !== ALL || filter.who !== ALL || filter.needsYou);
96
+ let pinned = $derived(current?.pending[0]);
97
+ let timeline = $derived(current?.entries.filter((entry) => !current.pending.includes(entry)) ?? []);
98
+
99
+ function open(thread: InboxThread): void {
100
+ selected = thread.key;
101
+ snoozing = false;
102
+ detailOpen = true;
103
+ onSelect?.(thread);
104
+ }
105
+
106
+ async function move(step: number): Promise<void> {
107
+ if (!visible.length) return;
108
+ const from = current ? visible.indexOf(current) : -1;
109
+ const next = visible[Math.max(0, Math.min(visible.length - 1, from + step))];
110
+ open(next);
111
+ detailOpen = false;
112
+ await tick();
113
+ document.querySelector<HTMLElement>(`[data-thread-key="${CSS.escape(next.key)}"]`)?.focus();
114
+ }
115
+
116
+ function toggleRead(): void {
117
+ if (current) onRead?.(current, current.unread > 0);
118
+ }
119
+
120
+ function snooze(until: Date): void {
121
+ if (current) onSnooze?.({ thread: current, until });
122
+ snoozing = false;
123
+ }
124
+
125
+ function onkeydown(event: KeyboardEvent): void {
126
+ if (snoozing && !event.defaultPrevented && !event.metaKey && !event.ctrlKey && !event.altKey) {
127
+ const choice = snoozeChoices(new Date())[Number(event.key) - 1];
128
+ if (choice) {
129
+ event.preventDefault();
130
+ snooze(choice.until);
131
+ return;
132
+ }
133
+ if (event.key === "Escape") {
134
+ snoozing = false;
135
+ return;
136
+ }
137
+ }
138
+ const key = inboxKeyOf(event);
139
+ if (!key) return;
140
+ const card = pinned ? cards[pinned.id] : null;
141
+ const actions = {
142
+ next: () => move(1),
143
+ previous: () => move(-1),
144
+ toggleRead,
145
+ snooze: () => (snoozing = !snoozing),
146
+ approve: () => card?.approve(),
147
+ decline: () => card?.decline(),
148
+ steer: () => card?.steer(),
149
+ edit: () => card?.edit(),
150
+ };
151
+ event.preventDefault();
152
+ actions[key]();
153
+ }
154
+
155
+ function setType(next: string | undefined): void {
156
+ filter = { ...filter, type: next || ALL };
157
+ }
158
+
159
+ function snoozeLabel(id: string): string {
160
+ return id === "later" ? labels.snoozeLater : id === "tomorrow" ? labels.snoozeTomorrow : labels.snoozeNextWeek;
161
+ }
162
+
163
+ function initial(name: string): string {
164
+ return name.trim().charAt(0).toLocaleUpperCase() || "?";
165
+ }
166
+
167
+ function lastEntry(thread: InboxThread): InboxEntry {
168
+ return thread.entries[thread.entries.length - 1];
169
+ }
170
+ </script>
171
+
172
+ {#snippet face(actor: InboxActor)}
173
+ <span class="relative size-6 shrink-0">
174
+ {#if avatar}
175
+ {@render avatar(actor)}
176
+ {:else if actor.kind === "agent"}
177
+ <AgentAvatar name={actor.name} size={24} paused interactive={false} />
178
+ {:else if actor.avatar}
179
+ <img src={actor.avatar} alt="" class="size-6 rounded-full object-cover" />
180
+ {:else}
181
+ <span class="grid size-6 place-items-center rounded-full bg-muted text-xs font-medium text-muted-foreground" aria-hidden="true">
182
+ {initial(actor.name)}
183
+ </span>
184
+ {/if}
185
+ </span>
186
+ {/snippet}
187
+
188
+ <!-- WHY the key handler sits on the region: the shortcuts work wherever focus is inside the inbox and nowhere else on the page. -->
189
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
190
+ <div role="region" aria-label={labels.title} class={cn("flex h-full min-h-0 flex-col gap-3", className)} {onkeydown}>
191
+ <div class="flex flex-wrap items-center gap-2">
192
+ <ToggleGroup type="single" variant="outline" size="sm" value={filter.type} onValueChange={setType} aria-label={labels.filterType}>
193
+ <ToggleGroupItem value={ALL}>{labels.all}</ToggleGroupItem>
194
+ {#each types as type (type)}
195
+ <ToggleGroupItem value={type}>{typeLabels[type] ?? type}</ToggleGroupItem>
196
+ {/each}
197
+ </ToggleGroup>
198
+ <select
199
+ aria-label={labels.filterWho}
200
+ value={filter.who}
201
+ onchange={(event) => (filter = { ...filter, who: event.currentTarget.value })}
202
+ class="h-7 rounded-lg border border-border bg-background px-2 text-[0.8rem] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 any-pointer-coarse:h-10"
203
+ >
204
+ <option value={ALL}>{labels.anyone}</option>
205
+ {#each people as name (name)}
206
+ <option value={name}>{name}</option>
207
+ {/each}
208
+ </select>
209
+ <Button
210
+ size="sm"
211
+ variant={filter.needsYou ? "default" : "outline"}
212
+ aria-pressed={filter.needsYou}
213
+ onclick={() => (filter = { ...filter, needsYou: !filter.needsYou })}
214
+ >
215
+ {labels.needsYou}
216
+ <span class="tabular-nums opacity-70">{needsYouCount}</span>
217
+ </Button>
218
+ </div>
219
+
220
+ <div class="grid min-h-0 flex-1 gap-3 md:grid-cols-[20rem_minmax(0,1fr)]">
221
+ <ol class={cn("-mx-1 min-h-0 flex-col gap-1 overflow-y-auto px-1 pb-1", detailOpen ? "max-md:hidden md:flex" : "flex")} aria-label={labels.title}>
222
+ {#each visible as thread (thread.key)}
223
+ {@const last = lastEntry(thread)}
224
+ <li>
225
+ <button
226
+ type="button"
227
+ data-thread-key={thread.key}
228
+ aria-current={current?.key === thread.key}
229
+ onclick={() => open(thread)}
230
+ class={cn(
231
+ "flex w-full flex-col gap-1 rounded-lg border border-transparent p-2.5 text-start outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
232
+ current?.key === thread.key ? "border-border bg-muted" : "hover:bg-muted/50",
233
+ )}
234
+ >
235
+ <span class="flex items-center gap-2">
236
+ {#if thread.unread > 0}
237
+ <Circle weight="fill" class="size-2 shrink-0 text-primary" aria-hidden="true" />
238
+ {/if}
239
+ {#if thread.subject.ref}
240
+ <span class="text-xs text-muted-foreground tabular-nums">{thread.subject.ref}</span>
241
+ {/if}
242
+ <span class={cn("truncate text-sm", thread.unread > 0 ? "font-medium" : "")}>{thread.subject.title}</span>
243
+ <span class="ms-auto shrink-0 text-xs text-muted-foreground tabular-nums">{when(thread.latestAt)}</span>
244
+ </span>
245
+ <span class="flex items-center gap-2 ps-4 text-xs text-muted-foreground">
246
+ <span class="truncate">{last.actor.name} {entryLine(last, labels)}</span>
247
+ {#if thread.pending.length > 0}
248
+ <span class={cn("ms-auto inline-flex h-5 shrink-0 items-center rounded-sm border px-1.5 font-medium", TONE_CLASS.warning)}>
249
+ {labels.needsYou}
250
+ </span>
251
+ {:else if thread.unread > 0}
252
+ <Badge variant="outline" class="ms-auto tabular-nums" aria-label="{thread.unread} {labels.unread}">{thread.unread}</Badge>
253
+ {/if}
254
+ </span>
255
+ </button>
256
+ </li>
257
+ {:else}
258
+ <li class="h-full">
259
+ <Empty.Root class="h-full">
260
+ <Empty.Header>
261
+ <Empty.Title>
262
+ {filtered ? (filter.needsYou ? labels.emptyNeedsYou : labels.emptyFiltered) : labels.empty}
263
+ </Empty.Title>
264
+ </Empty.Header>
265
+ {#if filtered}
266
+ <Empty.Content>
267
+ <Button size="sm" variant="outline" onclick={() => (filter = { ...NO_FILTER })}>{labels.clearFilters}</Button>
268
+ </Empty.Content>
269
+ {/if}
270
+ </Empty.Root>
271
+ </li>
272
+ {/each}
273
+ </ol>
274
+
275
+ <section
276
+ class={cn("min-h-0 flex-col gap-4 overflow-y-auto rounded-xl border border-border p-4", detailOpen ? "flex" : "max-md:hidden md:flex")}
277
+ aria-label={current?.subject.title ?? labels.selectThread}
278
+ >
279
+ {#if current}
280
+ <header class="flex flex-wrap items-center gap-2">
281
+ <Button size="sm" variant="ghost" class="md:hidden" onclick={() => (detailOpen = false)} aria-label={labels.title}>
282
+ <ArrowLeft aria-hidden="true" />
283
+ </Button>
284
+ {#if current.subject.ref}
285
+ <span class="text-xs text-muted-foreground tabular-nums">{current.subject.ref}</span>
286
+ {/if}
287
+ <h2 class="min-w-0 truncate text-sm font-medium">
288
+ {#if current.subject.href}
289
+ <a href={current.subject.href} class="hover:underline">{current.subject.title}</a>
290
+ {:else}
291
+ {current.subject.title}
292
+ {/if}
293
+ </h2>
294
+ {@render status?.(current.subject)}
295
+ <span class="ms-auto flex items-center gap-1">
296
+ <Button size="sm" variant="ghost" onclick={toggleRead} aria-keyshortcuts="u">
297
+ {#if current.unread > 0}
298
+ <EnvelopeOpen aria-hidden="true" />{labels.markRead}
299
+ {:else}
300
+ <Envelope aria-hidden="true" />{labels.markUnread}
301
+ {/if}
302
+ </Button>
303
+ <Button size="sm" variant="ghost" onclick={() => (snoozing = !snoozing)} aria-expanded={snoozing} aria-keyshortcuts="h">
304
+ <BellSlash aria-hidden="true" />{labels.snooze}
305
+ </Button>
306
+ </span>
307
+ </header>
308
+
309
+ {#if snoozing}
310
+ <div class="flex flex-wrap gap-2" role="group" aria-label={labels.snooze}>
311
+ {#each snoozeChoices(new Date()) as choice, index (choice.id)}
312
+ <Button size="sm" variant="outline" onclick={() => snooze(choice.until)}>
313
+ <Kbd>{index + 1}</Kbd>{snoozeLabel(choice.id)}
314
+ </Button>
315
+ {/each}
316
+ </div>
317
+ {/if}
318
+
319
+ {#each current.pending as entry (entry.id)}
320
+ <InboxCard
321
+ bind:this={cards[entry.id]}
322
+ item={entry.proposal!}
323
+ selected={entry === pinned}
324
+ onverdict={(verdict) => onDecide?.(verdict, current)}
325
+ {renderProposal}
326
+ />
327
+ {/each}
328
+
329
+ <ol class="flex flex-col gap-4">
330
+ {#each timeline as entry (entry.id)}
331
+ <li class={cn("flex gap-3", !entry.read && "font-medium")}>
332
+ {@render face(entry.actor)}
333
+ <div class="min-w-0 flex-1">
334
+ <div class="flex flex-wrap items-center gap-x-2 text-[13px]">
335
+ <span class="truncate font-medium">{entry.actor.name}</span>
336
+ {#if entry.actor.kind === "agent"}
337
+ <span class={cn("inline-flex h-5 items-center rounded-sm border px-1.5 text-xs font-medium", TONE_CLASS.accent)}>
338
+ {labels.agent}
339
+ </span>
340
+ {/if}
341
+ <span class="font-normal text-muted-foreground">{entryLine(entry, labels)}</span>
342
+ <span class="ms-auto text-xs font-normal text-muted-foreground tabular-nums">{when(entry.at)}</span>
343
+ </div>
344
+ {#if entry.body}
345
+ <p class="mt-1 text-sm leading-relaxed font-normal whitespace-pre-wrap text-pretty">{entry.body}</p>
346
+ {/if}
347
+ </div>
348
+ </li>
349
+ {/each}
350
+ </ol>
351
+ {:else}
352
+ <p class="text-sm text-muted-foreground">{labels.selectThread}</p>
353
+ {/if}
354
+ </section>
355
+ </div>
356
+
357
+ <footer class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground max-sm:hidden">
358
+ <span class="inline-flex items-center gap-1.5"><KbdGroup><Kbd>j</Kbd><Kbd>k</Kbd></KbdGroup> {labels.hintMove}</span>
359
+ <span class="inline-flex items-center gap-1.5"><Kbd>u</Kbd> {labels.hintRead}</span>
360
+ <span class="inline-flex items-center gap-1.5"><Kbd>h</Kbd> {labels.hintSnooze}</span>
361
+ <span class="inline-flex items-center gap-1.5"><KbdGroup><Kbd>a</Kbd><Kbd>d</Kbd><Kbd>s</Kbd><Kbd>e</Kbd></KbdGroup> {labels.hintAct}</span>
362
+ </footer>
363
+ </div>
@@ -0,0 +1,139 @@
1
+ import type { InboxItem } from "../tasks/inbox/inbox-types.js";
2
+
3
+ export type InboxActorKind = "user" | "agent";
4
+
5
+ export type InboxActor = {
6
+ kind: InboxActorKind;
7
+ name: string;
8
+ /** Image URL for a person. The host's own portrait goes through the `avatar` snippet. */
9
+ avatar?: string;
10
+ };
11
+
12
+ export type InboxEntryKind = "comment" | "status" | "assigned" | "mention" | "agent_response" | "proposal";
13
+
14
+ /** What the thread is about: one task, project or doc. Entries with the same type and id share a thread. */
15
+ export type InboxSubject = {
16
+ /** "task", "project", "doc" or whatever the host calls it. */
17
+ type: string;
18
+ id: string;
19
+ /** The short reference, such as "TSK-7". */
20
+ ref?: string;
21
+ title: string;
22
+ /** A status id the host draws itself, for a badge in the thread header. */
23
+ status?: string;
24
+ href?: string;
25
+ };
26
+
27
+ export type InboxEntry = {
28
+ id: string;
29
+ kind: InboxEntryKind;
30
+ /** An entry with no subject id belongs to no thread and is dropped. */
31
+ subject?: InboxSubject;
32
+ actor: InboxActor;
33
+ /** ISO timestamp. A malformed one sorts last. */
34
+ at: string;
35
+ read?: boolean;
36
+ /** Text of a comment, a mention or an agent response. */
37
+ body?: string;
38
+ /** A `status` entry: where the subject moved. */
39
+ status?: { from?: string; to: string };
40
+ /** An `assigned` entry: who got the work. */
41
+ assignee?: string;
42
+ /** A `proposal` entry: the agent's ask, drawn as an InboxCard while it waits. */
43
+ proposal?: InboxItem;
44
+ };
45
+
46
+ export type InboxThread = {
47
+ /** `type:id`, unique across subjects. */
48
+ key: string;
49
+ subject: InboxSubject;
50
+ /** Oldest first. */
51
+ entries: InboxEntry[];
52
+ unread: number;
53
+ /** The newest valid timestamp, as written. The raw first one when none parses. */
54
+ latestAt: string;
55
+ /** Proposals still waiting for a verdict, newest first. */
56
+ pending: InboxEntry[];
57
+ };
58
+
59
+ export type InboxFilter = {
60
+ /** A subject type, or "all". */
61
+ type: string;
62
+ /** An actor name, or "all". */
63
+ who: string;
64
+ /** Only threads holding a pending proposal. */
65
+ needsYou: boolean;
66
+ };
67
+
68
+ export const ALL = "all";
69
+
70
+ export type InboxKey = "next" | "previous" | "toggleRead" | "snooze" | "approve" | "decline" | "steer" | "edit";
71
+
72
+ export type InboxSnooze = { thread: InboxThread; until: Date };
73
+
74
+ export type InboxLabels = {
75
+ title: string;
76
+ all: string;
77
+ anyone: string;
78
+ needsYou: string;
79
+ empty: string;
80
+ emptyNeedsYou: string;
81
+ emptyFiltered: string;
82
+ clearFilters: string;
83
+ unread: string;
84
+ markRead: string;
85
+ markUnread: string;
86
+ snooze: string;
87
+ snoozeLater: string;
88
+ snoozeTomorrow: string;
89
+ snoozeNextWeek: string;
90
+ selectThread: string;
91
+ filterType: string;
92
+ filterWho: string;
93
+ agent: string;
94
+ statusMoved: string;
95
+ statusSet: string;
96
+ assigned: string;
97
+ mentioned: string;
98
+ commented: string;
99
+ responded: string;
100
+ proposed: string;
101
+ /** Keyboard hint words. */
102
+ hintMove: string;
103
+ hintRead: string;
104
+ hintSnooze: string;
105
+ hintAct: string;
106
+ };
107
+
108
+ export const DEFAULT_INBOX_LABELS: InboxLabels = {
109
+ title: "Inbox",
110
+ all: "All",
111
+ anyone: "Anyone",
112
+ needsYou: "Needs you",
113
+ empty: "Nothing here",
114
+ emptyNeedsYou: "Nothing is waiting on you",
115
+ emptyFiltered: "No thread matches these filters",
116
+ clearFilters: "Clear filters",
117
+ unread: "unread",
118
+ markRead: "Mark read",
119
+ markUnread: "Mark unread",
120
+ snooze: "Snooze",
121
+ snoozeLater: "Later today",
122
+ snoozeTomorrow: "Tomorrow",
123
+ snoozeNextWeek: "Next week",
124
+ selectThread: "Select a thread",
125
+ filterType: "Filter by type",
126
+ filterWho: "Filter by person or agent",
127
+ agent: "AI",
128
+ statusMoved: "moved this from {from} to {to}",
129
+ statusSet: "set this to {to}",
130
+ assigned: "assigned this to {assignee}",
131
+ mentioned: "mentioned you",
132
+ commented: "commented",
133
+ responded: "replied",
134
+ proposed: "asks for your call",
135
+ hintMove: "move",
136
+ hintRead: "read",
137
+ hintSnooze: "snooze",
138
+ hintAct: "approve, decline, steer, edit",
139
+ };
@@ -0,0 +1,95 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it, vi } from "vite-plus/test";
5
+ import * as status from "./status/index.js";
6
+ import * as panel from "./panel/index.js";
7
+ import * as record from "./record/index.js";
8
+ import * as taskInbox from "./tasks/inbox/index.js";
9
+ import * as inbox from "./inbox/index.js";
10
+ import * as thread from "./thread/index.js";
11
+ import type {
12
+ PanelStat as FromTasksPanelStat,
13
+ SidePanelWidth as FromTasksWidth,
14
+ StatusDef as FromTasksStatusDef,
15
+ StatusDotVariant as FromTasksVariant,
16
+ StatusId as FromTasksId,
17
+ StatusMap as FromTasksMap,
18
+ Statuses as FromTasksStatuses,
19
+ } from "./tasks/index.js";
20
+ import type { PanelStat, SidePanelWidth } from "./panel/index.js";
21
+ import type { StatusDef, StatusDotVariant, StatusId, StatusMap, Statuses } from "./status/index.js";
22
+
23
+ // Both spellings of every moved type have to be the same type.
24
+ type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
25
+ const sameTypes: [
26
+ Same<PanelStat, FromTasksPanelStat>,
27
+ Same<SidePanelWidth, FromTasksWidth>,
28
+ Same<StatusDef, FromTasksStatusDef>,
29
+ Same<StatusDotVariant, FromTasksVariant>,
30
+ Same<StatusId<{ a: StatusDef }>, FromTasksId<{ a: StatusDef }>>,
31
+ Same<StatusMap, FromTasksMap>,
32
+ Same<Statuses, FromTasksStatuses>,
33
+ ] = [true, true, true, true, true, true, true];
34
+
35
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")) as {
36
+ exports: Record<string, string | Record<string, string>>;
37
+ };
38
+
39
+ describe("module split", () => {
40
+ it("keeps every moved name on tasks, the same object as on its new module", async () => {
41
+ // The kanban board reads matchMedia on import, and jsdom has none.
42
+ vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
43
+ const tasks = await import("./tasks/index.js");
44
+ expect(sameTypes).toHaveLength(7);
45
+ expect(tasks.StatusBadge).toBe(status.StatusBadge);
46
+ expect(tasks.StatusDot).toBe(status.StatusDot);
47
+ expect(tasks.StatusIcon).toBe(status.StatusIcon);
48
+ expect(tasks.defineStatuses).toBe(status.defineStatuses);
49
+ expect(tasks.StatusBadge).toBeDefined();
50
+ expect(tasks.SidePanel).toBe(panel.SidePanel);
51
+ expect(Object.keys(taskInbox)).toContain("Inbox");
52
+ });
53
+
54
+ it("holds the record page's names", () => {
55
+ expect(record.RecordShell).toBeDefined();
56
+ expect(record.DEFAULT_RECORD_LABELS).toBeDefined();
57
+ });
58
+
59
+ it("names what the new modules export", () => {
60
+ expect(thread.Thread).toBeDefined();
61
+ expect(inbox.ThreadInbox).toBeDefined();
62
+ expect(inbox.InboxCard).toBeDefined();
63
+ expect(inbox.threadsOf).toBeTypeOf("function");
64
+ });
65
+
66
+ it("resolves every export path to a file", () => {
67
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
68
+ const missing: string[] = [];
69
+ for (const [name, target] of Object.entries(pkg.exports)) {
70
+ const path = typeof target === "string" ? target : (target.default ?? target.svelte);
71
+ if (path.includes("*") || path.endsWith(".css")) continue;
72
+ if (!existsSync(join(root, path))) missing.push(`${name} -> ${path}`);
73
+ }
74
+ expect(missing).toEqual([]);
75
+ });
76
+
77
+ it("keeps the old subpaths and adds the new ones", () => {
78
+ for (const name of [
79
+ "./tasks",
80
+ "./tasks/status",
81
+ "./tasks/status-dot",
82
+ "./tasks/statuses",
83
+ "./tasks/side-panel",
84
+ "./tasks/record",
85
+ "./tasks/inbox",
86
+ "./status",
87
+ "./panel",
88
+ "./record",
89
+ "./thread",
90
+ "./inbox",
91
+ ]) {
92
+ expect(pkg.exports[name], name).toBeDefined();
93
+ }
94
+ });
95
+ });
@@ -0,0 +1,5 @@
1
+ # @heyramzi/ui/panel
2
+
3
+ `SidePanel`, a sheet for one record: badges, links out to other tools, a copy-a-prompt handoff and a stats strip. `PanelStat` types one stat. It used to live in `@heyramzi/ui/tasks`, which still re-exports both, and `tasks/side-panel` still resolves.
4
+
5
+ Exports: `SidePanel` and the types `PanelStat`, `SidePanelWidth`.
@@ -0,0 +1,2 @@
1
+ export { default as SidePanel } from "./side-panel.svelte";
2
+ export type { PanelStat, SidePanelWidth } from "./side-panel-types.js";
@@ -13,7 +13,7 @@ import { OpenIn, type OpenInTool } from "../components/open-in/index.js";
13
13
  import { CopyButton } from "../components/copy/index.js";
14
14
  import type { Tone } from "../lib/tone.js";
15
15
  import { cn } from "../lib/utils.js";
16
- import StatusBadge from "./status-badge.svelte";
16
+ import StatusBadge from "../status/status-badge.svelte";
17
17
 
18
18
  // =========================
19
19
  // PROPS
@@ -0,0 +1,3 @@
1
+ # @heyramzi/ui/record
2
+
3
+ The page shell for a project or a client record: header, where-we-are, next up, stats, steps rail, timeline, project list and field sections. It used to live at `@heyramzi/ui/tasks/record`, which still resolves to the same module.
@@ -2,7 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
- import { cn } from "../../lib/utils.js";
5
+ import { cn } from "../lib/utils.js";
6
6
  import { fieldRow, groupFields, type FieldValues } from "./fields.js";
7
7
  import InfoList from "./info-list.svelte";
8
8
  import type {
@@ -3,15 +3,15 @@
3
3
  // IMPORTS
4
4
  // =========================
5
5
  import { Check, CaretUpDown } from "phosphor-svelte";
6
- import { cn } from "../../lib/utils.js";
7
- import { Button } from "../../shadcn/button/index.js";
8
- import { Input } from "../../shadcn/input/index.js";
9
- import { Textarea } from "../../shadcn/textarea/index.js";
10
- import { Spinner } from "../../shadcn/spinner/index.js";
11
- import * as Select from "../../shadcn/select/index.js";
12
- import * as Popover from "../../shadcn/popover/index.js";
13
- import * as Command from "../../shadcn/command/index.js";
14
- import StatusBadge from "../status-badge.svelte";
6
+ import { cn } from "../lib/utils.js";
7
+ import { Button } from "../shadcn/button/index.js";
8
+ import { Input } from "../shadcn/input/index.js";
9
+ import { Textarea } from "../shadcn/textarea/index.js";
10
+ import { Spinner } from "../shadcn/spinner/index.js";
11
+ import * as Select from "../shadcn/select/index.js";
12
+ import * as Popover from "../shadcn/popover/index.js";
13
+ import * as Command from "../shadcn/command/index.js";
14
+ import StatusBadge from "../status/status-badge.svelte";
15
15
  import { resolveLabels, type InfoRow, type RecordLabels } from "./types.js";
16
16
 
17
17
  // =========================
@@ -3,12 +3,12 @@
3
3
  // IMPORTS
4
4
  // =========================
5
5
  import { ArrowRight } from "phosphor-svelte";
6
- import { cn } from "../../lib/utils.js";
7
- import { TONE_TEXT } from "../../lib/tone.js";
8
- import StatusBadge from "../status-badge.svelte";
6
+ import { cn } from "../lib/utils.js";
7
+ import { TONE_TEXT } from "../lib/tone.js";
8
+ import StatusBadge from "../status/status-badge.svelte";
9
9
  import { gutterClass } from "./gutter.js";
10
10
  import { formatRange } from "./format.js";
11
- import { todayIso } from "../dates.js";
11
+ import { todayIso } from "../tasks/dates.js";
12
12
  import { resolveLabels, type RecordLabels, type RecordNext } from "./types.js";
13
13
 
14
14
  // =========================