@heyramzi/ui 2.8.0 → 2.9.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.
- package/COMPONENTS.md +1 -1
- package/README.md +1 -1
- package/package.json +58 -74
- package/src/agents/vendor/draw.ts +4 -4
- package/src/canvas/headless/canvas-cleanup.test.ts +1 -1
- package/src/canvas/ops/ops.ts +1 -1
- package/src/charts/area-chart.svelte +11 -1
- package/src/charts/bar-chart.svelte +18 -3
- package/src/charts/line-chart.svelte +11 -1
- package/src/components/data-table/data-table.ts +4 -5
- package/src/components/data-table/pagination.svelte +5 -3
- package/src/components/docs/on-this-page.svelte +82 -5
- package/src/components/docs/scroll-spy.svelte.ts +6 -1
- package/src/components/list-page/detail-header.svelte +7 -3
- package/src/components/list-page/empty-state.svelte +3 -1
- package/src/components/list-page/filter-popover.svelte +2 -2
- package/src/components/list-page/rail-row.svelte +5 -2
- package/src/components/mark/mark.svelte +2 -1
- package/src/components/section-card/section-card.svelte +3 -1
- package/src/components/section-header/section-header.svelte +5 -5
- package/src/components/segmented/segment.svelte +1 -1
- package/src/components/table-view/table-view.svelte +6 -1
- package/src/components/tags-input/tags-input.svelte +1 -1
- package/src/components/toolbar/toolbar.svelte +1 -1
- package/src/editor/anchors.ts +28 -0
- package/src/editor/doc-blocks.ts +168 -0
- package/src/editor/doc-page.css +389 -0
- package/src/editor/editor.svelte +47 -5
- package/src/editor/extensions.ts +26 -2
- package/src/editor/markdown.ts +27 -2
- package/src/editor/slash-items.ts +18 -0
- package/src/icons/trades.ts +1 -1
- package/src/inbox/README.md +6 -4
- package/src/inbox/inbox.test.ts +1 -1
- package/src/inbox/index.ts +2 -2
- package/src/inbox/logic.ts +29 -5
- package/src/inbox/thread-inbox.svelte +219 -92
- package/src/inbox/types.ts +27 -1
- package/src/lib/initials.test.ts +25 -0
- package/src/lib/initials.ts +29 -0
- package/src/modules.test.ts +4 -64
- package/src/panel/README.md +1 -1
- package/src/record/README.md +1 -1
- package/src/record/info-list.svelte +5 -4
- package/src/record/next-up.svelte +1 -1
- package/src/record/project-list.svelte +1 -1
- package/src/record/steps-rail.svelte +3 -3
- package/src/shadcn/badge/badge.svelte +1 -1
- package/src/shadcn/card/card-title.svelte +1 -5
- package/src/shadcn/empty/empty-description.svelte +1 -1
- package/src/shadcn/progress/progress.svelte +8 -1
- package/src/shadcn/table/table-cell.svelte +10 -2
- package/src/status/README.md +1 -1
- package/src/thread/README.md +2 -0
- package/src/thread/index.ts +9 -1
- package/src/thread/logic.ts +33 -3
- package/src/thread/thread-body.svelte +37 -0
- package/src/thread/thread-events.svelte +30 -0
- package/src/thread/thread.svelte +74 -59
- package/src/thread/types.ts +21 -0
- package/src/{tasks → work}/README.md +11 -8
- package/src/{tasks → work}/detail/task-thread.svelte +8 -15
- package/src/work/docs/doc-editor-page.svelte +194 -0
- package/src/work/docs/doc-menu.svelte +333 -0
- package/src/work/docs/docs-demo.svelte +227 -0
- package/src/work/docs/edited-chip.svelte +125 -0
- package/src/work/docs/edited.ts +54 -0
- package/src/work/docs/index.ts +38 -0
- package/src/work/docs/labels.ts +70 -0
- package/src/work/docs/menu.ts +127 -0
- package/src/work/docs/outline.ts +60 -0
- package/src/{tasks → work}/gantt/gantt-group-row.svelte +1 -1
- package/src/{tasks → work}/inbox/inbox-card.svelte +113 -50
- package/src/work/inbox/inbox-logic.ts +98 -0
- package/src/work/inbox/inbox-trace.svelte +58 -0
- package/src/{tasks → work}/inbox/inbox-types.ts +2 -0
- package/src/{tasks → work}/index.ts +0 -10
- package/src/{tasks → work}/kanban-board.svelte +1 -1
- package/src/{tasks → work}/task-list.svelte +1 -1
- package/src/{tasks → work}/time/time-tracker.svelte +2 -5
- package/src/tasks/inbox/inbox-logic.ts +0 -21
- package/src/tasks/inbox/inbox-trace.svelte +0 -51
- /package/src/{tasks → work}/ambient.d.ts +0 -0
- /package/src/{tasks → work}/board-order.ts +0 -0
- /package/src/{tasks → work}/dates.ts +0 -0
- /package/src/{tasks → work}/demo.svelte +0 -0
- /package/src/{tasks → work}/detail/detail-demo.svelte +0 -0
- /package/src/{tasks → work}/detail/detail-types.ts +0 -0
- /package/src/{tasks → work}/detail/task-chip.svelte +0 -0
- /package/src/{tasks → work}/detail/task-detail.svelte +0 -0
- /package/src/{tasks → work}/detail/task-property.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-bar.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-grid.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-header.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-task-row.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt.css +0 -0
- /package/src/{tasks → work}/gantt/gantt.test.ts +0 -0
- /package/src/{tasks → work}/gantt/logic.test.ts +0 -0
- /package/src/{tasks → work}/gantt/logic.ts +0 -0
- /package/src/{tasks → work}/inbox/inbox-draft.svelte +0 -0
- /package/src/{tasks → work}/inbox/inbox-statuses.ts +0 -0
- /package/src/{tasks → work}/inbox/index.ts +0 -0
- /package/src/{tasks → work}/kanban-column.svelte +0 -0
- /package/src/{tasks → work}/kanban-drag.ts +0 -0
- /package/src/{tasks → work}/kanban-types.ts +0 -0
- /package/src/{tasks → work}/presets/sales.ts +0 -0
- /package/src/{tasks → work}/reorder.ts +0 -0
- /package/src/{tasks → work}/task-gantt.svelte +0 -0
- /package/src/{tasks → work}/task-types.ts +0 -0
- /package/src/{tasks → work}/time/duration.ts +0 -0
- /package/src/{tasks → work}/time/time-chip.svelte +0 -0
- /package/src/{tasks → work}/time/time-dot.svelte +0 -0
- /package/src/{tasks → work}/time/time-ring.svelte +0 -0
- /package/src/{tasks → work}/time/time-types.ts +0 -0
- /package/src/{tasks → work}/use-kanban-dnd.svelte.ts +0 -0
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { tick, type Snippet } from "svelte";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
ArrowRight,
|
|
5
|
+
ArrowSquareOut,
|
|
6
|
+
Bell,
|
|
7
|
+
BellSlash,
|
|
8
|
+
Check,
|
|
9
|
+
Checks,
|
|
10
|
+
Circle,
|
|
11
|
+
Envelope,
|
|
12
|
+
EnvelopeOpen,
|
|
13
|
+
EyeSlash,
|
|
14
|
+
Sparkle,
|
|
15
|
+
UserPlus,
|
|
16
|
+
} from "phosphor-svelte";
|
|
5
17
|
import DetailHeader from "../components/list-page/detail-header.svelte";
|
|
6
18
|
import DetailPanel from "../components/list-page/detail-panel.svelte";
|
|
7
19
|
import FilterItem from "../components/list-page/filter-item.svelte";
|
|
@@ -18,12 +30,20 @@
|
|
|
18
30
|
import { Kbd, KbdGroup } from "../shadcn/kbd/index.js";
|
|
19
31
|
import { cn } from "../lib/utils.js";
|
|
20
32
|
import { TONE_CLASS } from "../lib/tone.js";
|
|
21
|
-
import
|
|
22
|
-
|
|
33
|
+
import {
|
|
34
|
+
Thread,
|
|
35
|
+
type ThreadComment,
|
|
36
|
+
type ThreadEvent,
|
|
37
|
+
type ThreadLabels,
|
|
38
|
+
} from "../thread/index.js";
|
|
39
|
+
import InboxCard from "../work/inbox/inbox-card.svelte";
|
|
40
|
+
import { whenOf } from "../work/inbox/inbox-logic.js";
|
|
41
|
+
import type { InboxItem, InboxVerdict } from "../work/inbox/inbox-types.js";
|
|
23
42
|
import {
|
|
24
43
|
NO_FILTER,
|
|
25
44
|
actorNames,
|
|
26
|
-
|
|
45
|
+
sentenceOf,
|
|
46
|
+
isEvent,
|
|
27
47
|
filterThreads,
|
|
28
48
|
inboxKeyOf,
|
|
29
49
|
resolveInboxLabels,
|
|
@@ -57,12 +77,33 @@
|
|
|
57
77
|
status?: Snippet<[InboxSubject]>;
|
|
58
78
|
/** Replaces a pinned proposal's summary lines. */
|
|
59
79
|
renderProposal?: Snippet<[InboxItem]>;
|
|
60
|
-
/**
|
|
80
|
+
/** Content under an entry's text, such as links to what it mentions. */
|
|
81
|
+
attachments?: Snippet<[InboxEntry]>;
|
|
82
|
+
/** The words of the thread under the pinned card, such as its "Activity" heading. */
|
|
83
|
+
threadLabels?: Partial<ThreadLabels>;
|
|
84
|
+
/** How a timestamp reads. Defaults to a relative time. */
|
|
61
85
|
when?: (at: string) => string;
|
|
62
86
|
onSelect?: (thread: InboxThread) => void;
|
|
63
87
|
/** `read` is true when the thread had unread entries, so the host marks them all read. */
|
|
64
88
|
onRead?: (thread: InboxThread, read: boolean) => void;
|
|
89
|
+
/** Alt+U: mark every thread read. */
|
|
90
|
+
onReadAll?: () => void;
|
|
65
91
|
onSnooze?: (request: InboxSnooze) => void;
|
|
92
|
+
/** The host is passing snoozed threads too, each with `subject.snoozedUntil`. */
|
|
93
|
+
showSnoozed?: boolean;
|
|
94
|
+
/** Adds "Show snoozed" to the filter. The host passes the snoozed entries or stops. */
|
|
95
|
+
onShowSnoozed?: (show: boolean) => void;
|
|
96
|
+
/** `h` on a snoozed thread brings it back now. */
|
|
97
|
+
onUnsnooze?: (thread: InboxThread) => void;
|
|
98
|
+
/** Shift+S on a thread whose `subject.followed` is set. */
|
|
99
|
+
onUnfollow?: (thread: InboxThread) => void;
|
|
100
|
+
/**
|
|
101
|
+
* Backspace: take the thread out of the inbox. The host stops passing its entries, and new
|
|
102
|
+
* activity can bring it back. A thread holding a pending proposal can't be cleared.
|
|
103
|
+
*/
|
|
104
|
+
onClear?: (thread: InboxThread) => void;
|
|
105
|
+
/** Shift+Backspace: clear every thread with nothing unread and no pending proposal. */
|
|
106
|
+
onClearRead?: (threads: InboxThread[]) => void;
|
|
66
107
|
onDecide?: (verdict: InboxVerdict, thread: InboxThread) => void;
|
|
67
108
|
class?: string;
|
|
68
109
|
}
|
|
@@ -76,20 +117,19 @@
|
|
|
76
117
|
avatar,
|
|
77
118
|
status,
|
|
78
119
|
renderProposal,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
? ""
|
|
83
|
-
: date.toLocaleString(undefined, {
|
|
84
|
-
month: "short",
|
|
85
|
-
day: "numeric",
|
|
86
|
-
hour: "numeric",
|
|
87
|
-
minute: "2-digit",
|
|
88
|
-
});
|
|
89
|
-
},
|
|
120
|
+
attachments,
|
|
121
|
+
threadLabels,
|
|
122
|
+
when = (at) => whenOf(at),
|
|
90
123
|
onSelect,
|
|
91
124
|
onRead,
|
|
125
|
+
onReadAll,
|
|
92
126
|
onSnooze,
|
|
127
|
+
showSnoozed = false,
|
|
128
|
+
onShowSnoozed,
|
|
129
|
+
onUnsnooze,
|
|
130
|
+
onUnfollow,
|
|
131
|
+
onClear,
|
|
132
|
+
onClearRead,
|
|
93
133
|
onDecide,
|
|
94
134
|
class: className,
|
|
95
135
|
}: Props = $props();
|
|
@@ -110,14 +150,37 @@
|
|
|
110
150
|
let current = $derived(visible.find((thread) => thread.key === selected) ?? visible[0]);
|
|
111
151
|
let needsYouCount = $derived(all.filter((thread) => thread.pending.length > 0).length);
|
|
112
152
|
let filtered = $derived(filter.type !== ALL || filter.who !== ALL || filter.needsYou);
|
|
113
|
-
let narrowed = $derived(
|
|
153
|
+
let narrowed = $derived(
|
|
154
|
+
Number(filter.type !== ALL) + Number(filter.who !== ALL) + Number(showSnoozed),
|
|
155
|
+
);
|
|
156
|
+
let anyUnread = $derived(all.some((thread) => thread.unread > 0));
|
|
114
157
|
let views = $derived([
|
|
115
158
|
{ value: ALL, label: labels.all, count: all.length },
|
|
116
159
|
{ value: NEEDS_YOU, label: labels.needsYou, count: needsYouCount },
|
|
117
160
|
]);
|
|
161
|
+
let readThreads = $derived(
|
|
162
|
+
all.filter((thread) => thread.unread === 0 && thread.pending.length === 0),
|
|
163
|
+
);
|
|
118
164
|
let pinned = $derived(current?.pending[0]);
|
|
119
|
-
let
|
|
120
|
-
|
|
165
|
+
let rest = $derived(current?.entries.filter((entry) => !current.pending.includes(entry)) ?? []);
|
|
166
|
+
let byId = $derived(new Map(rest.map((entry) => [entry.id, entry])));
|
|
167
|
+
let comments = $derived(
|
|
168
|
+
rest
|
|
169
|
+
.filter((entry) => !isEvent(entry))
|
|
170
|
+
.map((entry): ThreadComment => ({
|
|
171
|
+
id: entry.id,
|
|
172
|
+
author: entry.actor,
|
|
173
|
+
body: entry.body ?? "",
|
|
174
|
+
createdAt: entry.at,
|
|
175
|
+
})),
|
|
176
|
+
);
|
|
177
|
+
let events = $derived(
|
|
178
|
+
rest.filter(isEvent).map((entry): ThreadEvent => ({
|
|
179
|
+
id: entry.id,
|
|
180
|
+
title: sentenceOf(entry, labels),
|
|
181
|
+
at: entry.at,
|
|
182
|
+
icon: entry.kind === "status" ? ArrowRight : entry.kind === "assigned" ? UserPlus : Sparkle,
|
|
183
|
+
})),
|
|
121
184
|
);
|
|
122
185
|
|
|
123
186
|
function open(thread: InboxThread): void {
|
|
@@ -143,6 +206,35 @@
|
|
|
143
206
|
snoozing = false;
|
|
144
207
|
}
|
|
145
208
|
|
|
209
|
+
/** A snoozed thread comes back at once. Any other opens the snooze row. */
|
|
210
|
+
function snoozeKey(): void {
|
|
211
|
+
if (current?.subject.snoozedUntil && onUnsnooze) onUnsnooze(current);
|
|
212
|
+
else snoozing = !snoozing;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function unfollow(): void {
|
|
216
|
+
if (current?.subject.followed) onUnfollow?.(current);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function readAll(): void {
|
|
220
|
+
if (anyUnread) onReadAll?.();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Clears the open thread and opens the one below it, or above it at the end of the list. */
|
|
224
|
+
function clear(): void {
|
|
225
|
+
if (!current || !onClear || current.pending.length > 0) return;
|
|
226
|
+
const gone = current;
|
|
227
|
+
const index = visible.indexOf(gone);
|
|
228
|
+
const next = visible[index + 1] ?? visible[index - 1];
|
|
229
|
+
if (next) selected = next.key;
|
|
230
|
+
snoozing = false;
|
|
231
|
+
onClear(gone);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function clearRead(): void {
|
|
235
|
+
if (onClearRead && readThreads.length > 0) onClearRead(readThreads);
|
|
236
|
+
}
|
|
237
|
+
|
|
146
238
|
function onkeydown(event: KeyboardEvent): void {
|
|
147
239
|
if (snoozing && !event.defaultPrevented && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
148
240
|
const choice = snoozeChoices(new Date())[Number(event.key) - 1];
|
|
@@ -163,7 +255,11 @@
|
|
|
163
255
|
next: () => move(1),
|
|
164
256
|
previous: () => move(-1),
|
|
165
257
|
toggleRead,
|
|
166
|
-
|
|
258
|
+
readAll,
|
|
259
|
+
snooze: snoozeKey,
|
|
260
|
+
unfollow,
|
|
261
|
+
clear,
|
|
262
|
+
clearRead,
|
|
167
263
|
approve: () => card?.approve(),
|
|
168
264
|
decline: () => card?.decline(),
|
|
169
265
|
steer: () => card?.steer(),
|
|
@@ -181,32 +277,18 @@
|
|
|
181
277
|
: labels.snoozeNextWeek;
|
|
182
278
|
}
|
|
183
279
|
|
|
184
|
-
function initial(name: string): string {
|
|
185
|
-
return name.trim().charAt(0).toLocaleUpperCase() || "?";
|
|
186
|
-
}
|
|
187
|
-
|
|
188
280
|
function lastEntry(thread: InboxThread): InboxEntry {
|
|
189
281
|
return thread.entries[thread.entries.length - 1];
|
|
190
282
|
}
|
|
191
283
|
</script>
|
|
192
284
|
|
|
193
|
-
{#snippet
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
<img src={actor.avatar} alt="" class="size-6 rounded-full object-cover" />
|
|
201
|
-
{:else}
|
|
202
|
-
<span
|
|
203
|
-
class="grid size-6 place-items-center rounded-full bg-muted text-xs font-medium text-muted-foreground"
|
|
204
|
-
aria-hidden="true"
|
|
205
|
-
>
|
|
206
|
-
{initial(actor.name)}
|
|
207
|
-
</span>
|
|
208
|
-
{/if}
|
|
209
|
-
</span>
|
|
285
|
+
{#snippet threadAvatar(comment: ThreadComment)}
|
|
286
|
+
{@render avatar?.(comment.author)}
|
|
287
|
+
{/snippet}
|
|
288
|
+
|
|
289
|
+
{#snippet threadAttachments(comment: ThreadComment)}
|
|
290
|
+
{@const entry = byId.get(comment.id)}
|
|
291
|
+
{#if entry}{@render attachments?.(entry)}{/if}
|
|
210
292
|
{/snippet}
|
|
211
293
|
|
|
212
294
|
{#snippet subjectStatus()}
|
|
@@ -222,11 +304,13 @@
|
|
|
222
304
|
class={cn("flex h-full min-h-0 flex-col gap-3", className)}
|
|
223
305
|
{onkeydown}
|
|
224
306
|
>
|
|
307
|
+
<!-- WHY *:shrink-0: the header and panels clip their overflow, so they squashed to a sliver
|
|
308
|
+
when an expanded card filled the pane. Now the pane scrolls. -->
|
|
225
309
|
<MasterDetail
|
|
226
310
|
bind:pane
|
|
227
311
|
railWidth={queueRailWidth}
|
|
228
312
|
railClass={queueRailClass}
|
|
229
|
-
detailClass="flex flex-col gap-4 overflow-y-auto"
|
|
313
|
+
detailClass="flex flex-col gap-4 overflow-y-auto *:shrink-0"
|
|
230
314
|
pad={false}
|
|
231
315
|
backLabel={labels.title}
|
|
232
316
|
class="min-h-0 flex-1"
|
|
@@ -245,9 +329,17 @@
|
|
|
245
329
|
label={labels.filter}
|
|
246
330
|
count={narrowed}
|
|
247
331
|
align="end"
|
|
248
|
-
onClear={() =>
|
|
332
|
+
onClear={() => {
|
|
333
|
+
filter = { ...NO_FILTER, needsYou: filter.needsYou };
|
|
334
|
+
if (showSnoozed) onShowSnoozed?.(false);
|
|
335
|
+
}}
|
|
249
336
|
clearLabel={labels.clearFilters}
|
|
250
337
|
>
|
|
338
|
+
{#if onShowSnoozed}
|
|
339
|
+
<FilterItem checked={showSnoozed} onclick={() => onShowSnoozed(!showSnoozed)}>
|
|
340
|
+
{labels.showSnoozed}
|
|
341
|
+
</FilterItem>
|
|
342
|
+
{/if}
|
|
251
343
|
{#if types.length > 1}
|
|
252
344
|
<p class={filterSectionClass()}>{labels.kind}</p>
|
|
253
345
|
{#each [ALL, ...types] as type (type)}
|
|
@@ -271,6 +363,32 @@
|
|
|
271
363
|
</FilterItem>
|
|
272
364
|
{/each}
|
|
273
365
|
</FilterPopover>
|
|
366
|
+
{#if onReadAll && anyUnread}
|
|
367
|
+
<Button
|
|
368
|
+
size="sm"
|
|
369
|
+
variant="ghost"
|
|
370
|
+
icon
|
|
371
|
+
onclick={readAll}
|
|
372
|
+
aria-label={labels.markAllRead}
|
|
373
|
+
title={labels.markAllRead}
|
|
374
|
+
aria-keyshortcuts="Alt+U"
|
|
375
|
+
>
|
|
376
|
+
<EnvelopeOpen aria-hidden="true" />
|
|
377
|
+
</Button>
|
|
378
|
+
{/if}
|
|
379
|
+
{#if onClearRead && readThreads.length > 0}
|
|
380
|
+
<Button
|
|
381
|
+
size="sm"
|
|
382
|
+
variant="ghost"
|
|
383
|
+
icon
|
|
384
|
+
onclick={clearRead}
|
|
385
|
+
aria-label={labels.clearRead}
|
|
386
|
+
title={labels.clearRead}
|
|
387
|
+
aria-keyshortcuts="Shift+Backspace"
|
|
388
|
+
>
|
|
389
|
+
<Checks aria-hidden="true" />
|
|
390
|
+
</Button>
|
|
391
|
+
{/if}
|
|
274
392
|
</div>
|
|
275
393
|
</RailHead>
|
|
276
394
|
<RailBody>
|
|
@@ -283,12 +401,19 @@
|
|
|
283
401
|
titleLines={2}
|
|
284
402
|
selected={current?.key === thread.key}
|
|
285
403
|
onclick={() => open(thread)}
|
|
286
|
-
preview=
|
|
287
|
-
meta={[
|
|
404
|
+
preview={sentenceOf(last, labels)}
|
|
405
|
+
meta={[
|
|
406
|
+
thread.subject.ref,
|
|
407
|
+
thread.subject.snoozedUntil
|
|
408
|
+
? `${labels.snoozed} ${when(thread.subject.snoozedUntil)}`
|
|
409
|
+
: when(thread.latestAt),
|
|
410
|
+
]}
|
|
288
411
|
metaTruncate="last"
|
|
289
412
|
>
|
|
290
413
|
{#snippet trailing()}
|
|
291
|
-
{#if thread.
|
|
414
|
+
{#if thread.subject.snoozedUntil}
|
|
415
|
+
<BellSlash class="size-3.5 text-muted-foreground" aria-label={labels.snoozed} />
|
|
416
|
+
{:else if thread.pending.length > 0}
|
|
292
417
|
<span
|
|
293
418
|
class={cn(
|
|
294
419
|
"inline-flex h-5 items-center rounded-sm border px-1.5 font-medium",
|
|
@@ -342,15 +467,31 @@
|
|
|
342
467
|
<Envelope aria-hidden="true" />{labels.markUnread}
|
|
343
468
|
{/if}
|
|
344
469
|
</Button>
|
|
345
|
-
|
|
346
|
-
size="sm"
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
470
|
+
{#if current.subject.snoozedUntil && onUnsnooze}
|
|
471
|
+
<Button size="sm" variant="ghost" onclick={snoozeKey} aria-keyshortcuts="h">
|
|
472
|
+
<Bell aria-hidden="true" />{labels.unsnooze}
|
|
473
|
+
</Button>
|
|
474
|
+
{:else}
|
|
475
|
+
<Button
|
|
476
|
+
size="sm"
|
|
477
|
+
variant="ghost"
|
|
478
|
+
onclick={snoozeKey}
|
|
479
|
+
aria-expanded={snoozing}
|
|
480
|
+
aria-keyshortcuts="h"
|
|
481
|
+
>
|
|
482
|
+
<BellSlash aria-hidden="true" />{labels.snooze}
|
|
483
|
+
</Button>
|
|
484
|
+
{/if}
|
|
485
|
+
{#if onUnfollow && current.subject.followed}
|
|
486
|
+
<Button size="sm" variant="ghost" onclick={unfollow} aria-keyshortcuts="Shift+S">
|
|
487
|
+
<EyeSlash aria-hidden="true" />{labels.unfollow}
|
|
488
|
+
</Button>
|
|
489
|
+
{/if}
|
|
490
|
+
{#if onClear && current.pending.length === 0}
|
|
491
|
+
<Button size="sm" variant="ghost" onclick={clear} aria-keyshortcuts="Backspace">
|
|
492
|
+
<Check aria-hidden="true" />{labels.clear}
|
|
493
|
+
</Button>
|
|
494
|
+
{/if}
|
|
354
495
|
{#if current.subject.href}
|
|
355
496
|
<Button size="sm" variant="ghost" href={current.subject.href}>
|
|
356
497
|
<ArrowSquareOut aria-hidden="true" />{labels.open}
|
|
@@ -376,46 +517,24 @@
|
|
|
376
517
|
selected={entry === pinned}
|
|
377
518
|
onverdict={(verdict) => onDecide?.(verdict, current)}
|
|
378
519
|
{renderProposal}
|
|
520
|
+
when={(at) => when(at)}
|
|
379
521
|
/>
|
|
380
522
|
{/each}
|
|
381
523
|
|
|
382
|
-
{#if
|
|
524
|
+
{#if comments.length + events.length > 0}
|
|
383
525
|
<DetailPanel>
|
|
384
|
-
<
|
|
385
|
-
{
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
)}
|
|
397
|
-
>
|
|
398
|
-
{labels.agent}
|
|
399
|
-
</span>
|
|
400
|
-
{/if}
|
|
401
|
-
<span class="font-normal text-muted-foreground"
|
|
402
|
-
>{entryLine(entry, labels)}</span
|
|
403
|
-
>
|
|
404
|
-
<span class="ms-auto text-xs font-normal text-muted-foreground tabular-nums"
|
|
405
|
-
>{when(entry.at)}</span
|
|
406
|
-
>
|
|
407
|
-
</div>
|
|
408
|
-
{#if entry.body}
|
|
409
|
-
<p
|
|
410
|
-
class="mt-1 text-sm leading-relaxed font-normal whitespace-pre-wrap text-pretty"
|
|
411
|
-
>
|
|
412
|
-
{entry.body}
|
|
413
|
-
</p>
|
|
414
|
-
{/if}
|
|
415
|
-
</div>
|
|
416
|
-
</li>
|
|
417
|
-
{/each}
|
|
418
|
-
</ol>
|
|
526
|
+
<Thread
|
|
527
|
+
{comments}
|
|
528
|
+
{events}
|
|
529
|
+
labels={{
|
|
530
|
+
agent: labels.agent,
|
|
531
|
+
...threadLabels,
|
|
532
|
+
}}
|
|
533
|
+
avatar={avatar ? threadAvatar : undefined}
|
|
534
|
+
attachments={attachments ? threadAttachments : undefined}
|
|
535
|
+
when={(comment) => when(comment.createdAt)}
|
|
536
|
+
whenEvent={(event) => when(event.at)}
|
|
537
|
+
/>
|
|
419
538
|
</DetailPanel>
|
|
420
539
|
{/if}
|
|
421
540
|
{:else}
|
|
@@ -431,7 +550,15 @@
|
|
|
431
550
|
><KbdGroup><Kbd>j</Kbd><Kbd>k</Kbd></KbdGroup> {labels.hintMove}</span
|
|
432
551
|
>
|
|
433
552
|
<span class="inline-flex items-center gap-1.5"><Kbd>u</Kbd> {labels.hintRead}</span>
|
|
553
|
+
{#if onReadAll}
|
|
554
|
+
<span class="inline-flex items-center gap-1.5"
|
|
555
|
+
><KbdGroup><Kbd>⌥</Kbd><Kbd>u</Kbd></KbdGroup> {labels.hintReadAll}</span
|
|
556
|
+
>
|
|
557
|
+
{/if}
|
|
434
558
|
<span class="inline-flex items-center gap-1.5"><Kbd>h</Kbd> {labels.hintSnooze}</span>
|
|
559
|
+
{#if onClear}
|
|
560
|
+
<span class="inline-flex items-center gap-1.5"><Kbd>⌫</Kbd> {labels.hintClear}</span>
|
|
561
|
+
{/if}
|
|
435
562
|
<span class="inline-flex items-center gap-1.5"
|
|
436
563
|
><KbdGroup><Kbd>a</Kbd><Kbd>d</Kbd><Kbd>s</Kbd><Kbd>e</Kbd></KbdGroup> {labels.hintAct}</span
|
|
437
564
|
>
|
package/src/inbox/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { InboxItem } from "../
|
|
1
|
+
import type { InboxItem } from "../work/inbox/inbox-types.js";
|
|
2
2
|
|
|
3
3
|
export type InboxActorKind = "user" | "agent";
|
|
4
4
|
|
|
@@ -28,6 +28,10 @@ export type InboxSubject = {
|
|
|
28
28
|
/** A status id the host draws itself, for a badge in the thread header. */
|
|
29
29
|
status?: string;
|
|
30
30
|
href?: string;
|
|
31
|
+
/** ISO time a snoozed thread comes back. Set only when the host shows snoozed threads. */
|
|
32
|
+
snoozedUntil?: string;
|
|
33
|
+
/** The person follows this subject, so the thread offers Unfollow. */
|
|
34
|
+
followed?: boolean;
|
|
31
35
|
};
|
|
32
36
|
|
|
33
37
|
export type InboxEntry = {
|
|
@@ -77,7 +81,11 @@ export type InboxKey =
|
|
|
77
81
|
| "next"
|
|
78
82
|
| "previous"
|
|
79
83
|
| "toggleRead"
|
|
84
|
+
| "readAll"
|
|
80
85
|
| "snooze"
|
|
86
|
+
| "unfollow"
|
|
87
|
+
| "clear"
|
|
88
|
+
| "clearRead"
|
|
81
89
|
| "approve"
|
|
82
90
|
| "decline"
|
|
83
91
|
| "steer"
|
|
@@ -101,6 +109,13 @@ export type InboxLabels = {
|
|
|
101
109
|
snoozeLater: string;
|
|
102
110
|
snoozeTomorrow: string;
|
|
103
111
|
snoozeNextWeek: string;
|
|
112
|
+
snoozed: string;
|
|
113
|
+
showSnoozed: string;
|
|
114
|
+
unsnooze: string;
|
|
115
|
+
unfollow: string;
|
|
116
|
+
markAllRead: string;
|
|
117
|
+
clear: string;
|
|
118
|
+
clearRead: string;
|
|
104
119
|
selectThread: string;
|
|
105
120
|
filterType: string;
|
|
106
121
|
filterWho: string;
|
|
@@ -119,7 +134,9 @@ export type InboxLabels = {
|
|
|
119
134
|
/** Keyboard hint words. */
|
|
120
135
|
hintMove: string;
|
|
121
136
|
hintRead: string;
|
|
137
|
+
hintReadAll: string;
|
|
122
138
|
hintSnooze: string;
|
|
139
|
+
hintClear: string;
|
|
123
140
|
hintAct: string;
|
|
124
141
|
};
|
|
125
142
|
|
|
@@ -139,6 +156,13 @@ export const DEFAULT_INBOX_LABELS: InboxLabels = {
|
|
|
139
156
|
snoozeLater: "Later today",
|
|
140
157
|
snoozeTomorrow: "Tomorrow",
|
|
141
158
|
snoozeNextWeek: "Next week",
|
|
159
|
+
snoozed: "Snoozed",
|
|
160
|
+
showSnoozed: "Show snoozed",
|
|
161
|
+
unsnooze: "Unsnooze",
|
|
162
|
+
unfollow: "Unfollow",
|
|
163
|
+
markAllRead: "Mark all read",
|
|
164
|
+
clear: "Clear",
|
|
165
|
+
clearRead: "Clear read",
|
|
142
166
|
selectThread: "Select a thread",
|
|
143
167
|
filterType: "Filter by type",
|
|
144
168
|
filterWho: "Filter by person or agent",
|
|
@@ -156,6 +180,8 @@ export const DEFAULT_INBOX_LABELS: InboxLabels = {
|
|
|
156
180
|
proposed: "asks for your call",
|
|
157
181
|
hintMove: "move",
|
|
158
182
|
hintRead: "read",
|
|
183
|
+
hintReadAll: "all read",
|
|
159
184
|
hintSnooze: "snooze",
|
|
185
|
+
hintClear: "clear, with shift every read thread",
|
|
160
186
|
hintAct: "approve, decline, steer, edit",
|
|
161
187
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { initial, initials } from "./initials.js";
|
|
3
|
+
|
|
4
|
+
describe("initial", () => {
|
|
5
|
+
it("keeps an emoji whole and uppercases a lowercase name", () => {
|
|
6
|
+
expect(initial("🦊 Fox")).toBe("🦊");
|
|
7
|
+
expect(initial("dana")).toBe("D");
|
|
8
|
+
expect(initial("Ólafur Darri")).toBe("Ó");
|
|
9
|
+
});
|
|
10
|
+
it("falls back for a missing or blank name", () => {
|
|
11
|
+
expect(initial(undefined)).toBe("?");
|
|
12
|
+
expect(initial(" ")).toBe("?");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("initials", () => {
|
|
17
|
+
it("takes the first and last word and skips a suffix", () => {
|
|
18
|
+
expect(initials("Christopher Alexander Montgomery III")).toBe("CM");
|
|
19
|
+
expect(initials(" Sam Lee ")).toBe("SL");
|
|
20
|
+
});
|
|
21
|
+
it("gives one letter for one word and one script without spaces", () => {
|
|
22
|
+
expect(initials("Jo")).toBe("J");
|
|
23
|
+
expect(initials("王秀英")).toBe("王");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter() : null;
|
|
2
|
+
|
|
3
|
+
/** The first whole character of a string: an emoji or an accented letter stays in one piece. */
|
|
4
|
+
function firstGrapheme(text: string): string {
|
|
5
|
+
if (!text) return "";
|
|
6
|
+
if (segmenter) return segmenter.segment(text)[Symbol.iterator]().next().value?.segment ?? "";
|
|
7
|
+
return Array.from(text)[0] ?? "";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** One letter for an avatar fallback. Falls back to `fallback` for an empty or missing name. */
|
|
11
|
+
export function initial(name: string | null | undefined, fallback = "?"): string {
|
|
12
|
+
const first = firstGrapheme((name ?? "").trim());
|
|
13
|
+
return first ? first.toLocaleUpperCase() : fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Two letters for an avatar fallback, first and last word, skipping suffixes: "Christopher
|
|
18
|
+
* Montgomery III" gives "CM". A one-word name gives one letter.
|
|
19
|
+
*/
|
|
20
|
+
export function initials(name: string | null | undefined, fallback = "?"): string {
|
|
21
|
+
const words = (name ?? "")
|
|
22
|
+
.trim()
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.filter((word) => word && !/^(?:[IVX]{1,4}|jr\.?|sr\.?)$/i.test(word));
|
|
25
|
+
if (words.length === 0) return initial(name, fallback);
|
|
26
|
+
const head = firstGrapheme(words[0]);
|
|
27
|
+
const tail = words.length > 1 ? firstGrapheme(words[words.length - 1]) : "";
|
|
28
|
+
return (head + tail).toLocaleUpperCase();
|
|
29
|
+
}
|
package/src/modules.test.ts
CHANGED
|
@@ -1,41 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { describe, expect, it
|
|
5
|
-
import * as status from "./status/index.js";
|
|
6
|
-
import * as panel from "./panel/index.js";
|
|
4
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
7
5
|
import * as record from "./record/index.js";
|
|
8
6
|
import * as inbox from "./inbox/index.js";
|
|
9
7
|
import * as thread from "./thread/index.js";
|
|
10
|
-
import type {
|
|
11
|
-
PanelStat as FromTasksPanelStat,
|
|
12
|
-
SidePanelWidth as FromTasksWidth,
|
|
13
|
-
StatusDef as FromTasksStatusDef,
|
|
14
|
-
StatusDotVariant as FromTasksVariant,
|
|
15
|
-
StatusId as FromTasksId,
|
|
16
|
-
StatusMap as FromTasksMap,
|
|
17
|
-
Statuses as FromTasksStatuses,
|
|
18
|
-
} from "./tasks/index.js";
|
|
19
|
-
import type { PanelStat, SidePanelWidth } from "./panel/index.js";
|
|
20
|
-
import type {
|
|
21
|
-
StatusDef,
|
|
22
|
-
StatusDotVariant,
|
|
23
|
-
StatusId,
|
|
24
|
-
StatusMap,
|
|
25
|
-
Statuses,
|
|
26
|
-
} from "./status/index.js";
|
|
27
|
-
|
|
28
|
-
// Both spellings of every moved type have to be the same type.
|
|
29
|
-
type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
30
|
-
const sameTypes: [
|
|
31
|
-
Same<PanelStat, FromTasksPanelStat>,
|
|
32
|
-
Same<SidePanelWidth, FromTasksWidth>,
|
|
33
|
-
Same<StatusDef, FromTasksStatusDef>,
|
|
34
|
-
Same<StatusDotVariant, FromTasksVariant>,
|
|
35
|
-
Same<StatusId<{ a: StatusDef }>, FromTasksId<{ a: StatusDef }>>,
|
|
36
|
-
Same<StatusMap, FromTasksMap>,
|
|
37
|
-
Same<Statuses, FromTasksStatuses>,
|
|
38
|
-
] = [true, true, true, true, true, true, true];
|
|
39
8
|
|
|
40
9
|
const pkg = JSON.parse(
|
|
41
10
|
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"),
|
|
@@ -44,23 +13,6 @@ const pkg = JSON.parse(
|
|
|
44
13
|
};
|
|
45
14
|
|
|
46
15
|
describe("module split", () => {
|
|
47
|
-
it("keeps every moved name on tasks, the same object as on its new module", async () => {
|
|
48
|
-
// The kanban board reads matchMedia on import, and jsdom has none.
|
|
49
|
-
vi.stubGlobal("matchMedia", () => ({
|
|
50
|
-
matches: false,
|
|
51
|
-
addEventListener() {},
|
|
52
|
-
removeEventListener() {},
|
|
53
|
-
}));
|
|
54
|
-
const tasks = await import("./tasks/index.js");
|
|
55
|
-
expect(sameTypes).toHaveLength(7);
|
|
56
|
-
expect(tasks.StatusBadge).toBe(status.StatusBadge);
|
|
57
|
-
expect(tasks.StatusDot).toBe(status.StatusDot);
|
|
58
|
-
expect(tasks.StatusIcon).toBe(status.StatusIcon);
|
|
59
|
-
expect(tasks.defineStatuses).toBe(status.defineStatuses);
|
|
60
|
-
expect(tasks.StatusBadge).toBeDefined();
|
|
61
|
-
expect(tasks.SidePanel).toBe(panel.SidePanel);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
16
|
it("holds the record page's names", () => {
|
|
65
17
|
expect(record.RecordShell).toBeDefined();
|
|
66
18
|
expect(record.DEFAULT_RECORD_LABELS).toBeDefined();
|
|
@@ -84,22 +36,10 @@ describe("module split", () => {
|
|
|
84
36
|
expect(missing).toEqual([]);
|
|
85
37
|
});
|
|
86
38
|
|
|
87
|
-
it("
|
|
88
|
-
for (const name of [
|
|
89
|
-
"./tasks",
|
|
90
|
-
"./tasks/status",
|
|
91
|
-
"./tasks/status-dot",
|
|
92
|
-
"./tasks/statuses",
|
|
93
|
-
"./tasks/side-panel",
|
|
94
|
-
"./tasks/record",
|
|
95
|
-
"./tasks/inbox",
|
|
96
|
-
"./status",
|
|
97
|
-
"./panel",
|
|
98
|
-
"./record",
|
|
99
|
-
"./thread",
|
|
100
|
-
"./inbox",
|
|
101
|
-
]) {
|
|
39
|
+
it("exports each module once, under its own name", () => {
|
|
40
|
+
for (const name of ["./work", "./status", "./panel", "./record", "./thread", "./inbox"]) {
|
|
102
41
|
expect(pkg.exports[name], name).toBeDefined();
|
|
103
42
|
}
|
|
43
|
+
expect(Object.keys(pkg.exports).filter((name) => name.startsWith("./tasks"))).toEqual([]);
|
|
104
44
|
});
|
|
105
45
|
});
|
package/src/panel/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# @heyramzi/ui/panel
|
|
2
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/
|
|
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/work`, which still re-exports both, and `tasks/side-panel` still resolves.
|
|
4
4
|
|
|
5
5
|
Exports: `SidePanel` and the types `PanelStat`, `SidePanelWidth`.
|
package/src/record/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# @heyramzi/ui/record
|
|
2
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.
|
|
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.
|