@heyramzi/ui 2.7.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/ai/generation-progress/GenerationProgress.svelte +2 -2
- package/src/animations/README.md +1 -1
- package/src/animations/card-tilt.css +1 -1
- package/src/animations/digit-pop-in.css +1 -1
- package/src/animations/input-clear-dissolve.css +2 -2
- package/src/animations/learn-more-hover.css +2 -2
- package/src/animations/like-button.css +1 -1
- package/src/animations/plus-menu-morph.css +1 -1
- package/src/animations/success-check.css +5 -5
- 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.css +2 -1
- package/src/charts/bar-chart.svelte +18 -3
- package/src/charts/charts.css +2 -1
- 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 +4 -2
- 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/design/index.css +7 -5
- 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/effects/reveal/reveal.css +1 -1
- 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/lib/theme-transition.ts +6 -2
- 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/slides/theme.css +2 -2
- package/src/status/README.md +1 -1
- package/src/status/status-dot.svelte +3 -3
- 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/tokens/craft.css +7 -5
- 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-grid.svelte +2 -2
- package/src/{tasks → work}/gantt/gantt-group-row.svelte +1 -1
- package/src/{tasks → work}/gantt/gantt.css +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}/kanban-column.svelte +1 -1
- package/src/{tasks → work}/task-list.svelte +2 -2
- package/src/{tasks → work}/time/time-ring.svelte +1 -1
- package/src/{tasks → work}/time/time-tracker.svelte +3 -6
- 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-header.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-task-row.svelte +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-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-types.ts +0 -0
- /package/src/{tasks → work}/use-kanban-dnd.svelte.ts +0 -0
package/src/inbox/logic.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { isOpen, isTyping } from "../
|
|
1
|
+
import { isOpen, isTyping } from "../work/inbox/inbox-logic.js";
|
|
2
2
|
import {
|
|
3
3
|
ALL,
|
|
4
4
|
DEFAULT_INBOX_LABELS,
|
|
5
5
|
type InboxEntry,
|
|
6
|
+
type InboxEntryKind,
|
|
6
7
|
type InboxFilter,
|
|
7
8
|
type InboxKey,
|
|
8
9
|
type InboxLabels,
|
|
@@ -97,13 +98,15 @@ export function subjectTypes(threads: InboxThread[]): string[] {
|
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
export function actorNames(threads: InboxThread[]): string[] {
|
|
100
|
-
return [
|
|
101
|
+
return [
|
|
102
|
+
...new Set(threads.flatMap((thread) => thread.entries.map((entry) => entry.actor.name))),
|
|
103
|
+
].filter(Boolean);
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
type KeyEventLike = Pick<
|
|
104
107
|
KeyboardEvent,
|
|
105
108
|
"key" | "metaKey" | "ctrlKey" | "altKey" | "defaultPrevented" | "target"
|
|
106
|
-
|
|
109
|
+
> & { shiftKey?: boolean; code?: string };
|
|
107
110
|
|
|
108
111
|
const KEYS: Record<string, InboxKey> = {
|
|
109
112
|
j: "next",
|
|
@@ -113,13 +116,20 @@ const KEYS: Record<string, InboxKey> = {
|
|
|
113
116
|
a: "approve",
|
|
114
117
|
d: "decline",
|
|
115
118
|
s: "steer",
|
|
119
|
+
S: "unfollow",
|
|
116
120
|
e: "edit",
|
|
117
121
|
};
|
|
118
122
|
|
|
119
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* What a key press asks for, or null when it isn't ours: focus is in a text field, or a modifier
|
|
125
|
+
* other than Linear's Shift+S, Shift+Backspace and Alt+U is held.
|
|
126
|
+
*/
|
|
120
127
|
export function inboxKeyOf(event: KeyEventLike): InboxKey | null {
|
|
121
|
-
if (event.defaultPrevented || event.metaKey || event.ctrlKey
|
|
128
|
+
if (event.defaultPrevented || event.metaKey || event.ctrlKey) return null;
|
|
122
129
|
if (isTyping(event.target)) return null;
|
|
130
|
+
// Option+U on a Mac types a dead key, so the physical key decides.
|
|
131
|
+
if (event.altKey) return event.code === "KeyU" && !event.shiftKey ? "readAll" : null;
|
|
132
|
+
if (event.key === "Backspace") return event.shiftKey ? "clearRead" : "clear";
|
|
123
133
|
return KEYS[event.key] ?? null;
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -167,3 +177,17 @@ export function entryLine(entry: InboxEntry, labels: InboxLabels): string {
|
|
|
167
177
|
return labels.commented;
|
|
168
178
|
}
|
|
169
179
|
}
|
|
180
|
+
|
|
181
|
+
/** An entry as a sentence: who, then what. With no known actor it starts with the verb, capitalised. */
|
|
182
|
+
export function sentenceOf(entry: InboxEntry, labels: InboxLabels): string {
|
|
183
|
+
const line = entryLine(entry, labels);
|
|
184
|
+
if (entry.actor.name) return `${entry.actor.name} ${line}`;
|
|
185
|
+
return line.charAt(0).toUpperCase() + line.slice(1);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** What happened to the subject, as against something a person wrote. */
|
|
189
|
+
const EVENT_KINDS: ReadonlySet<InboxEntryKind> = new Set(["status", "assigned", "proposal"]);
|
|
190
|
+
|
|
191
|
+
export function isEvent(entry: InboxEntry): boolean {
|
|
192
|
+
return EVENT_KINDS.has(entry.kind);
|
|
193
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -10,7 +10,7 @@ export interface RevealThemeOptions {
|
|
|
10
10
|
/** Where the wipe grows from: a click, an element, or a point in the viewport. */
|
|
11
11
|
export type RevealOrigin = MouseEvent | Element | { x: number; y: number };
|
|
12
12
|
|
|
13
|
-
const DEFAULT_DURATION =
|
|
13
|
+
const DEFAULT_DURATION = 300;
|
|
14
14
|
const STYLE_ID = "heyramzi-reveal-theme";
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -22,7 +22,11 @@ export function revealTheme(
|
|
|
22
22
|
apply: () => void,
|
|
23
23
|
options: RevealThemeOptions = {},
|
|
24
24
|
): void {
|
|
25
|
-
const {
|
|
25
|
+
const {
|
|
26
|
+
skip = false,
|
|
27
|
+
duration = DEFAULT_DURATION,
|
|
28
|
+
easing = "cubic-bezier(0.77, 0, 0.175, 1)",
|
|
29
|
+
} = options;
|
|
26
30
|
const point = resolveOrigin(origin);
|
|
27
31
|
|
|
28
32
|
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|