@heyramzi/ui 2.22.1 → 2.23.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/dist/canvas/editor/rebase.js +26 -2
- package/dist/editor/safe-url.js +26 -0
- package/dist/editor/serializer.js +10 -4
- package/dist/editor/slash-query.js +10 -0
- package/dist/triage/index.js +4 -0
- package/dist/triage/types.js +12 -0
- package/package.json +7 -1
- package/scripts/sync-shadcn.mjs +4 -6
- package/src/animations/modal.css +4 -1
- package/src/canvas/editor/rebase.test.ts +37 -0
- package/src/canvas/editor/rebase.ts +33 -1
- package/src/canvas/ui/canvas-ui.css +32 -1
- package/src/charts/area-chart.svelte +2 -2
- package/src/charts/bar-chart.svelte +2 -2
- package/src/charts/chart-tokens.css +19 -0
- package/src/charts/dashboard/stat-tile.svelte +1 -1
- package/src/charts/donut-chart.svelte +1 -1
- package/src/charts/gauge-chart.svelte +1 -1
- package/src/charts/line-chart.svelte +2 -2
- package/src/charts/number-chart.svelte +1 -1
- package/src/charts/ring-chart.svelte +2 -2
- package/src/charts/sparkline.svelte +1 -1
- package/src/charts/stat-card.svelte +1 -1
- package/src/charts/time-series-chart.svelte +1 -1
- package/src/editor/editor.svelte +7 -5
- package/src/editor/safe-url.ts +32 -0
- package/src/editor/serializer.test.ts +58 -0
- package/src/editor/serializer.ts +9 -6
- package/src/editor/slash-query.test.ts +19 -0
- package/src/editor/slash-query.ts +11 -0
- package/src/modules.test.ts +13 -1
- package/src/shadcn/alert-dialog/alert-dialog-overlay.svelte +1 -1
- package/src/shadcn/button/button.svelte +1 -1
- package/src/shadcn/button/button.test.ts +2 -2
- package/src/shadcn/command/command-dialog.svelte +5 -10
- package/src/shadcn/dialog/dialog-overlay.svelte +1 -1
- package/src/shadcn/input/input.svelte +2 -2
- package/src/shadcn/sheet/sheet-overlay.svelte +1 -1
- package/src/tokens/craft.css +30 -0
- package/src/triage/README.md +28 -0
- package/src/triage/index.ts +10 -0
- package/src/triage/triage-plan.svelte +132 -0
- package/src/triage/triage-queue.svelte +48 -0
- package/src/triage/triage-task.svelte +95 -0
- package/src/triage/types.ts +35 -0
- package/src/work/kanban-column.svelte +5 -3
- package/src/work/time/time-chip.svelte +1 -3
|
@@ -87,6 +87,64 @@ describe("renderTiptapContent", () => {
|
|
|
87
87
|
});
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
function linkDoc(href: string) {
|
|
91
|
+
return {
|
|
92
|
+
type: "doc",
|
|
93
|
+
content: [
|
|
94
|
+
{
|
|
95
|
+
type: "paragraph",
|
|
96
|
+
content: [{ type: "text", text: "click", marks: [{ type: "link", attrs: { href } }] }],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function imageDoc(src: string) {
|
|
103
|
+
return { type: "doc", content: [{ type: "image", attrs: { src } }] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("unsafe URLs", () => {
|
|
107
|
+
it("keeps the text and drops a javascript: href", () => {
|
|
108
|
+
const html = renderTiptapContent(linkDoc("javascript:alert(1)"));
|
|
109
|
+
expect(html).toContain("click");
|
|
110
|
+
expect(html).not.toContain("javascript:");
|
|
111
|
+
expect(html).not.toContain("href=");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("drops a javascript: href hidden by a tab inside the scheme", () => {
|
|
115
|
+
const html = renderTiptapContent(linkDoc("java\tscript:alert(1)"));
|
|
116
|
+
expect(html).toContain("click");
|
|
117
|
+
expect(html).not.toContain("javascript:");
|
|
118
|
+
expect(html).not.toContain("href=");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it.each(["data:text/html,x", "vbscript:x"])("drops the %s href", (href) => {
|
|
122
|
+
expect(renderTiptapContent(linkDoc(href))).not.toContain("href=");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it.each(["https://example.com/a?b=1", "/docs", "#top", "mailto:a@example.com"])(
|
|
126
|
+
"keeps the %s href",
|
|
127
|
+
(href) => {
|
|
128
|
+
expect(renderTiptapContent(linkDoc(href))).toContain(`href="${href}"`);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
it("drops an image with a javascript: src", () => {
|
|
133
|
+
expect(renderTiptapContent(imageDoc("javascript:alert(1)"))).not.toContain("<img");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it.each(["https://example.com/a.png", "data:image/png;base64,AAAA"])(
|
|
137
|
+
"keeps an image with the %s src",
|
|
138
|
+
(src) => {
|
|
139
|
+
expect(renderTiptapContent(imageDoc(src))).toContain("<img");
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
it("drops an image with an SVG data: src", () => {
|
|
144
|
+
expect(renderTiptapContent(imageDoc("data:image/svg+xml;base64,AAAA"))).not.toContain("<img");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
90
148
|
describe("youtube node", () => {
|
|
91
149
|
function youtubeDoc(attrs: Record<string, unknown>) {
|
|
92
150
|
return { type: "doc", content: [{ type: "youtube", attrs }] };
|
package/src/editor/serializer.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { safeImageUrl, safeUrl } from "./safe-url.js";
|
|
2
|
+
|
|
1
3
|
// WHY our own slugify: the website's uses NFKD, which strips accents from French
|
|
2
4
|
// headings. The vibe-kit utils one doesn't, so accented anchors would differ.
|
|
3
5
|
function slugify(input: string, maxLength = 60): string {
|
|
@@ -158,7 +160,7 @@ function numString(attrs: Record<string, unknown> | undefined, key: string): str
|
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
function renderImage(node: Node): string {
|
|
161
|
-
const src = strAttr(node.attrs, "src");
|
|
163
|
+
const src = safeImageUrl(strAttr(node.attrs, "src"));
|
|
162
164
|
if (!src) return "";
|
|
163
165
|
const attrs = renderAttributes({
|
|
164
166
|
alt: strAttr(node.attrs, "alt") ?? "",
|
|
@@ -321,11 +323,12 @@ const MARK_TAGS: Record<string, string> = {
|
|
|
321
323
|
|
|
322
324
|
function wrapMark(content: string, mark: Mark): string {
|
|
323
325
|
if (mark.type === "link") {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
326
|
+
// WHY: stored docs come from teammates, clients and CLIs that skip the editor's link filter.
|
|
327
|
+
const href = safeUrl(strAttr(mark.attrs, "href"));
|
|
328
|
+
if (!href) return content;
|
|
329
|
+
const target = strAttr(mark.attrs, "target");
|
|
330
|
+
const rel = strAttr(mark.attrs, "rel") || (target ? "noopener noreferrer" : undefined);
|
|
331
|
+
const attrs = renderAttributes({ href, target, rel });
|
|
329
332
|
return `<a${attrs}>${content}</a>`;
|
|
330
333
|
}
|
|
331
334
|
const tag = pick(MARK_TAGS, mark.type);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { slashQueryAt } from "./slash-query.js";
|
|
3
|
+
|
|
4
|
+
describe("slashQueryAt", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
["/", ""],
|
|
7
|
+
["/head", "head"],
|
|
8
|
+
["hello /bul", "bul"],
|
|
9
|
+
["line one\n/", ""],
|
|
10
|
+
["TCP/IP", null],
|
|
11
|
+
["and/or", null],
|
|
12
|
+
["src/lib", null],
|
|
13
|
+
["https://example", null],
|
|
14
|
+
[`x/${"a".repeat(20)}`, null],
|
|
15
|
+
["no slash here", null],
|
|
16
|
+
])("reads %j as %j", (textBefore, expected) => {
|
|
17
|
+
expect(slashQueryAt(textBefore)).toBe(expected);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// One character of context, the slash, and a query of up to 20.
|
|
2
|
+
export const SLASH_WINDOW = 22;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The slash command typed just before the caret, or null when there isn't one. The slash has to
|
|
6
|
+
* start the text or follow whitespace, so `TCP/IP` and `and/or` stay plain text.
|
|
7
|
+
*/
|
|
8
|
+
export function slashQueryAt(textBefore: string): string | null {
|
|
9
|
+
const match = textBefore.match(/(?:^|\s)\/([a-zA-Z0-9 ]{0,20})$/);
|
|
10
|
+
return match ? match[1] : null;
|
|
11
|
+
}
|
package/src/modules.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vite-plus/test";
|
|
|
5
5
|
import * as record from "./record/index.js";
|
|
6
6
|
import * as inbox from "./inbox/index.js";
|
|
7
7
|
import * as thread from "./thread/index.js";
|
|
8
|
+
import * as triage from "./triage/index.js";
|
|
8
9
|
|
|
9
10
|
const pkg = JSON.parse(
|
|
10
11
|
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"),
|
|
@@ -23,6 +24,9 @@ describe("module split", () => {
|
|
|
23
24
|
expect(inbox.ThreadInbox).toBeDefined();
|
|
24
25
|
expect(inbox.InboxCard).toBeDefined();
|
|
25
26
|
expect(inbox.threadsOf).toBeTypeOf("function");
|
|
27
|
+
expect(triage.TriagePlan).toBeDefined();
|
|
28
|
+
expect(triage.TriageTask).toBeDefined();
|
|
29
|
+
expect(triage.TriageQueue).toBeDefined();
|
|
26
30
|
});
|
|
27
31
|
|
|
28
32
|
it("resolves every export path to a file", () => {
|
|
@@ -37,7 +41,15 @@ describe("module split", () => {
|
|
|
37
41
|
});
|
|
38
42
|
|
|
39
43
|
it("exports each module once, under its own name", () => {
|
|
40
|
-
for (const name of [
|
|
44
|
+
for (const name of [
|
|
45
|
+
"./work",
|
|
46
|
+
"./status",
|
|
47
|
+
"./panel",
|
|
48
|
+
"./record",
|
|
49
|
+
"./thread",
|
|
50
|
+
"./inbox",
|
|
51
|
+
"./triage",
|
|
52
|
+
]) {
|
|
41
53
|
expect(pkg.exports[name], name).toBeDefined();
|
|
42
54
|
}
|
|
43
55
|
expect(Object.keys(pkg.exports).filter((name) => name.startsWith("./tasks"))).toEqual([]);
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
size: {
|
|
48
48
|
sm: "relative after:absolute after:top-1/2 after:left-1/2 after:h-[max(100%,2rem)] after:w-[max(100%,2rem)] after:-translate-x-1/2 after:-translate-y-1/2 any-pointer-coarse:after:h-[max(100%,2.75rem)] any-pointer-coarse:after:w-[max(100%,2.75rem)] h-7 gap-1 px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 any-pointer-coarse:h-10 any-pointer-coarse:px-3",
|
|
49
49
|
default:
|
|
50
|
-
"h-
|
|
50
|
+
"h-(--control-height) gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 any-pointer-coarse:px-3.5",
|
|
51
51
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3 any-pointer-coarse:h-11 any-pointer-coarse:px-3.5",
|
|
52
52
|
},
|
|
53
53
|
/** A glyph with no label: square at the height its size already sets. */
|
|
@@ -10,10 +10,10 @@ describe("buttonVariants text", () => {
|
|
|
10
10
|
expect(classes).toContain("any-pointer-coarse:min-h-11");
|
|
11
11
|
expect(classes).not.toContain("whitespace-nowrap");
|
|
12
12
|
expect(classes).not.toContain("h-7");
|
|
13
|
-
expect(classes).not.toContain("h-
|
|
13
|
+
expect(classes).not.toContain("h-(--control-height)");
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
16
|
it("leaves the other variants fixed-height", () => {
|
|
17
|
-
expect(buttonVariants({ variant: "ghost" }).split(" ")).toContain("h-
|
|
17
|
+
expect(buttonVariants({ variant: "ghost" }).split(" ")).toContain("h-(--control-height)");
|
|
18
18
|
});
|
|
19
19
|
});
|
|
@@ -40,9 +40,7 @@
|
|
|
40
40
|
"rounded-2xl! top-[max(0.5rem,env(safe-area-inset-top))] right-2 bottom-auto left-2 w-auto max-h-[70svh] translate-y-0 gap-0 overflow-hidden p-0 sm:p-0",
|
|
41
41
|
// WHY no border and no card background here: the palette is a floating
|
|
42
42
|
|
|
43
|
-
// object, so its edge is a lit rim and a cast shadow rather than a drawn line, and the surface
|
|
44
|
-
|
|
45
|
-
// can read the dimmed page through it.
|
|
43
|
+
// object, so its edge is a lit rim and a cast shadow rather than a drawn line, and the surface belongs to the Command root beneath.
|
|
46
44
|
"border-0 bg-transparent shadow-edge-overlay",
|
|
47
45
|
// WHY a third of the way down rather than centred: the eye is already at
|
|
48
46
|
|
|
@@ -55,12 +53,9 @@
|
|
|
55
53
|
{showCloseButton}
|
|
56
54
|
{portalProps}
|
|
57
55
|
>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
class="bg-popover supports-backdrop-filter:bg-popover/94 supports-backdrop-filter:backdrop-blur-2xl"
|
|
63
|
-
{children}
|
|
64
|
-
/>
|
|
56
|
+
<!-- WHY the root's opaque `bg-popover`, no glass: `.t-modal` on the content sets `will-change:
|
|
57
|
+
opacity`, which makes the content the backdrop root, so a blur here read only the empty content
|
|
58
|
+
box, never the page. Its 94% surface let page text show through the rows (light, 390px). -->
|
|
59
|
+
<Command {...restProps} bind:value bind:ref {children} />
|
|
65
60
|
</Dialog.Content>
|
|
66
61
|
</Dialog.Root>
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
bind:this={ref}
|
|
26
26
|
data-slot={dataSlot}
|
|
27
27
|
class={cn(
|
|
28
|
-
"dark:bg-input/30 border-input enabled:hover:border-foreground/20 focus-visible:border-ring focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-
|
|
28
|
+
"dark:bg-input/30 border-input enabled:hover:border-foreground/20 focus-visible:border-ring focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-(--control-height) rounded-lg border bg-transparent px-2.5 py-1 text-base shadow-field transition-[color,background-color,border-color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] file:h-6 file:text-sm file:font-medium aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
29
29
|
className,
|
|
30
30
|
)}
|
|
31
31
|
type="file"
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
bind:this={ref}
|
|
39
39
|
data-slot={dataSlot}
|
|
40
40
|
class={cn(
|
|
41
|
-
"dark:bg-input/30 border-input enabled:hover:border-foreground/20 focus-visible:border-ring focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-
|
|
41
|
+
"dark:bg-input/30 border-input enabled:hover:border-foreground/20 focus-visible:border-ring focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-(--control-height) rounded-lg border bg-transparent px-2.5 py-1 text-base shadow-field transition-[color,background-color,border-color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] file:h-6 file:text-sm file:font-medium aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
42
42
|
className,
|
|
43
43
|
)}
|
|
44
44
|
{type}
|
package/src/tokens/craft.css
CHANGED
|
@@ -23,6 +23,21 @@
|
|
|
23
23
|
--text-2xs: 0.6875rem; /* a count, a kbd hint, a meta line under a tile; the floor */
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/* ── Control height ──────────────────────────────────────────
|
|
27
|
+
One height for every control that sits in a row together: an input, a
|
|
28
|
+
default button, a select trigger. 32px on a desktop row, 44px under a finger
|
|
29
|
+
(DESIGN.md rule 12), so a search field and its button always line up. Write
|
|
30
|
+
it `h-(--control-height)`. */
|
|
31
|
+
:root {
|
|
32
|
+
--control-height: 2rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@media (any-pointer: coarse) {
|
|
36
|
+
:root {
|
|
37
|
+
--control-height: 2.75rem;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
/* ── Elevation ───────────────────────────────────────────────
|
|
27
42
|
`--elev-card` and its hover carry their own hairline, so a box that also
|
|
28
43
|
draws `border` gets a 2px edge. The other steps carry none. */
|
|
@@ -88,6 +103,21 @@
|
|
|
88
103
|
--elev-field: inset 0 1px 2px color-mix(in oklab, black 40%, transparent);
|
|
89
104
|
}
|
|
90
105
|
|
|
106
|
+
/* ── Scrim ───────────────────────────────────────────────────
|
|
107
|
+
The dim behind a dialog, an alert dialog and a sheet. A dim with a 2px blur:
|
|
108
|
+
the page behind stays legible as the page you came from, so the overlay reads
|
|
109
|
+
as on top of it, not instead of it. Dark needs a deeper dim, since 32% black
|
|
110
|
+
on an already dark page barely moves it. */
|
|
111
|
+
:root {
|
|
112
|
+
--scrim: color-mix(in oklab, var(--elev-tint) 32%, transparent);
|
|
113
|
+
--scrim-blur: var(--blur-small);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* After `:root`, since `.dark` sits on the same element at the same specificity. */
|
|
117
|
+
.dark {
|
|
118
|
+
--scrim: color-mix(in oklab, black 55%, transparent);
|
|
119
|
+
}
|
|
120
|
+
|
|
91
121
|
/* ── Focus ───────────────────────────────────────────────────
|
|
92
122
|
One variable, so every control focuses the same way: a solid accent edge,
|
|
93
123
|
then a soft halo. */
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @heyramzi/ui/triage
|
|
2
|
+
|
|
3
|
+
The three pieces of an email triage screen. They never fetch: you pass the words and callbacks, and they hand back what the person did.
|
|
4
|
+
|
|
5
|
+
- `TriagePlan` is one email with the plan the model proposes ("Create a task in Clients") and three ways out: approve, type another task, dismiss.
|
|
6
|
+
- `TriageTask` is one settled email: what happened, the task it became, when, and Undo when the host passes `onUndo`.
|
|
7
|
+
- `TriageQueue` is the list around them, with the empty state. `kind="plans"` stacks cards, `kind="tasks"` draws one divided list.
|
|
8
|
+
|
|
9
|
+
```svelte
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
import { TriageQueue, TriagePlan } from "@heyramzi/ui/triage";
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<TriageQueue items={emails} key={(e) => e.id} empty="Nothing to review.">
|
|
15
|
+
{#snippet row(e)}
|
|
16
|
+
<TriagePlan
|
|
17
|
+
sender={e.from}
|
|
18
|
+
subject={e.subject}
|
|
19
|
+
plan={e.plan}
|
|
20
|
+
onApprove={() => approve(e.id)}
|
|
21
|
+
onPick={(task) => approve(e.id, task)}
|
|
22
|
+
onDismiss={() => dismiss(e.id)}
|
|
23
|
+
/>
|
|
24
|
+
{/snippet}
|
|
25
|
+
</TriageQueue>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`labels` on each piece is a partial, so a host translates it. A callback may return a promise: the buttons stay disabled until it settles.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { default as TriagePlan } from "./triage-plan.svelte";
|
|
2
|
+
export { default as TriageTask } from "./triage-task.svelte";
|
|
3
|
+
export { default as TriageQueue } from "./triage-queue.svelte";
|
|
4
|
+
export { DEFAULT_TRIAGE_PLAN_LABELS, DEFAULT_TRIAGE_TASK_LABELS } from "./types.js";
|
|
5
|
+
export type {
|
|
6
|
+
TriageOutcome,
|
|
7
|
+
TriagePlanLabels,
|
|
8
|
+
TriageTaskLabels,
|
|
9
|
+
TriageTaskLink,
|
|
10
|
+
} from "./types.js";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from "../shadcn/button/index.js";
|
|
3
|
+
import { Input } from "../shadcn/input/index.js";
|
|
4
|
+
import { cn } from "../lib/utils.js";
|
|
5
|
+
import { DEFAULT_TRIAGE_PLAN_LABELS, type TriagePlanLabels } from "./types.js";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Who sent the email. */
|
|
9
|
+
sender: string;
|
|
10
|
+
subject: string;
|
|
11
|
+
/** The first lines of the body. */
|
|
12
|
+
snippet?: string;
|
|
13
|
+
/** When it arrived, as text the host already formatted. */
|
|
14
|
+
received?: string | null;
|
|
15
|
+
/** The ISO time behind `received`, for the `time` element. */
|
|
16
|
+
receivedAt?: string | null;
|
|
17
|
+
/** What the plan will do, as one sentence. Without it the card only offers to dismiss. */
|
|
18
|
+
plan?: string | null;
|
|
19
|
+
/** Why the model wasn't sure. */
|
|
20
|
+
reason?: string | null;
|
|
21
|
+
/** A failed attempt, shown on the card. */
|
|
22
|
+
error?: string | null;
|
|
23
|
+
labels?: Partial<TriagePlanLabels>;
|
|
24
|
+
/** Run the plan. The buttons stay disabled until it settles. */
|
|
25
|
+
onApprove: () => void | Promise<void>;
|
|
26
|
+
/** Run a task the person typed instead of the plan. */
|
|
27
|
+
onPick: (task: string) => void | Promise<void>;
|
|
28
|
+
onDismiss: () => void | Promise<void>;
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
sender,
|
|
34
|
+
subject,
|
|
35
|
+
snippet,
|
|
36
|
+
received,
|
|
37
|
+
receivedAt,
|
|
38
|
+
plan = null,
|
|
39
|
+
reason,
|
|
40
|
+
error,
|
|
41
|
+
labels,
|
|
42
|
+
onApprove,
|
|
43
|
+
onPick,
|
|
44
|
+
onDismiss,
|
|
45
|
+
class: className,
|
|
46
|
+
}: Props = $props();
|
|
47
|
+
|
|
48
|
+
const id = $props.id();
|
|
49
|
+
let words = $derived({ ...DEFAULT_TRIAGE_PLAN_LABELS, ...labels });
|
|
50
|
+
let picking = $state(false);
|
|
51
|
+
let busy = $state(false);
|
|
52
|
+
let task = $state("");
|
|
53
|
+
|
|
54
|
+
async function run(job: () => void | Promise<void>) {
|
|
55
|
+
busy = true;
|
|
56
|
+
try {
|
|
57
|
+
await job();
|
|
58
|
+
} finally {
|
|
59
|
+
busy = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<article class={cn("bg-card border-border rounded-xl border p-5", className)}>
|
|
65
|
+
<div class="flex items-start justify-between gap-3">
|
|
66
|
+
<h2 class="text-foreground min-w-0 text-sm font-semibold [overflow-wrap:anywhere]">
|
|
67
|
+
{sender}: “{subject}”
|
|
68
|
+
</h2>
|
|
69
|
+
{#if received}
|
|
70
|
+
<time class="text-muted-foreground shrink-0 text-xs" datetime={receivedAt ?? undefined}>
|
|
71
|
+
{received}
|
|
72
|
+
</time>
|
|
73
|
+
{/if}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{#if snippet}
|
|
77
|
+
<p class="text-muted-foreground mt-2 line-clamp-2 text-sm [overflow-wrap:anywhere]">
|
|
78
|
+
{snippet}
|
|
79
|
+
</p>
|
|
80
|
+
{/if}
|
|
81
|
+
|
|
82
|
+
{#if plan}
|
|
83
|
+
<p
|
|
84
|
+
class="bg-primary/10 text-primary mt-4 inline-block max-w-full rounded-md px-2.5 py-1 text-xs font-medium [overflow-wrap:anywhere]"
|
|
85
|
+
>
|
|
86
|
+
{plan}
|
|
87
|
+
</p>
|
|
88
|
+
{/if}
|
|
89
|
+
{#if reason}
|
|
90
|
+
<p class="text-muted-foreground mt-2 text-xs [overflow-wrap:anywhere]">{reason}</p>
|
|
91
|
+
{/if}
|
|
92
|
+
|
|
93
|
+
{#if error}
|
|
94
|
+
<p
|
|
95
|
+
class="bg-destructive/10 text-destructive mt-3 rounded-lg px-3 py-2 text-sm [overflow-wrap:anywhere]"
|
|
96
|
+
role="alert"
|
|
97
|
+
>
|
|
98
|
+
{error}
|
|
99
|
+
</p>
|
|
100
|
+
{/if}
|
|
101
|
+
|
|
102
|
+
{#if picking}
|
|
103
|
+
<form
|
|
104
|
+
class="mt-4 space-y-2"
|
|
105
|
+
onsubmit={(event) => {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
run(() => onPick(task.trim()));
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<label for="{id}-task" class="text-foreground block text-sm font-medium">
|
|
111
|
+
{words.taskLabel}
|
|
112
|
+
</label>
|
|
113
|
+
<Input id="{id}-task" bind:value={task} required autocomplete="off" />
|
|
114
|
+
<div class="flex flex-wrap gap-2">
|
|
115
|
+
<Button type="submit" disabled={busy}>{words.useTask}</Button>
|
|
116
|
+
<Button type="button" variant="outline" onclick={() => (picking = false)}>
|
|
117
|
+
{words.cancel}
|
|
118
|
+
</Button>
|
|
119
|
+
</div>
|
|
120
|
+
</form>
|
|
121
|
+
{:else}
|
|
122
|
+
<div class="mt-4 flex flex-wrap gap-2">
|
|
123
|
+
{#if plan}
|
|
124
|
+
<Button disabled={busy} onclick={() => run(onApprove)}>{words.approve}</Button>
|
|
125
|
+
<Button variant="outline" onclick={() => (picking = true)}>{words.pick}</Button>
|
|
126
|
+
{/if}
|
|
127
|
+
<Button variant="ghost" disabled={busy} onclick={() => run(onDismiss)}>
|
|
128
|
+
{words.dismiss}
|
|
129
|
+
</Button>
|
|
130
|
+
</div>
|
|
131
|
+
{/if}
|
|
132
|
+
</article>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script lang="ts" generics="T">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import { cn } from "../lib/utils.js";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
items: readonly T[];
|
|
7
|
+
/** The key a row is tracked by. */
|
|
8
|
+
key: (item: T) => string;
|
|
9
|
+
/**
|
|
10
|
+
* `plans` stacks cards that need a decision. `tasks` is one divided list of what was
|
|
11
|
+
* decided.
|
|
12
|
+
*/
|
|
13
|
+
kind?: "plans" | "tasks";
|
|
14
|
+
/** One row: a `TriagePlan` for `plans`, a `TriageTask` for `tasks`. */
|
|
15
|
+
row: Snippet<[T]>;
|
|
16
|
+
/** What the person reads when nothing is waiting. */
|
|
17
|
+
empty: string;
|
|
18
|
+
/** The glyph above the empty text. */
|
|
19
|
+
icon?: Snippet;
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let { items, key, kind = "plans", row, empty, icon, class: className }: Props = $props();
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
{#if items.length === 0}
|
|
27
|
+
<div
|
|
28
|
+
class={cn(
|
|
29
|
+
"border-border flex flex-col items-center gap-3 rounded-xl border border-dashed px-6 py-14 text-center",
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
<span class="text-muted-foreground" aria-hidden="true">{@render icon?.()}</span>
|
|
34
|
+
<p class="text-muted-foreground text-sm">{empty}</p>
|
|
35
|
+
</div>
|
|
36
|
+
{:else if kind === "plans"}
|
|
37
|
+
<div class={cn("space-y-4", className)}>
|
|
38
|
+
{#each items as item (key(item))}
|
|
39
|
+
{@render row(item)}
|
|
40
|
+
{/each}
|
|
41
|
+
</div>
|
|
42
|
+
{:else}
|
|
43
|
+
<ul class={cn("bg-card border-border divide-border divide-y rounded-xl border", className)}>
|
|
44
|
+
{#each items as item (key(item))}
|
|
45
|
+
{@render row(item)}
|
|
46
|
+
{/each}
|
|
47
|
+
</ul>
|
|
48
|
+
{/if}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from "../shadcn/button/index.js";
|
|
3
|
+
import StatusBadge from "../status/status-badge.svelte";
|
|
4
|
+
import { cn } from "../lib/utils.js";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_TRIAGE_TASK_LABELS,
|
|
7
|
+
type TriageOutcome,
|
|
8
|
+
type TriageTaskLabels,
|
|
9
|
+
type TriageTaskLink,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
sender: string;
|
|
14
|
+
subject: string;
|
|
15
|
+
/** What happened to the email. */
|
|
16
|
+
outcome: TriageOutcome;
|
|
17
|
+
/** The task it became. */
|
|
18
|
+
task?: TriageTaskLink | null;
|
|
19
|
+
/** When it settled, as text the host already formatted. */
|
|
20
|
+
when: string;
|
|
21
|
+
whenAt?: string | null;
|
|
22
|
+
/** Why it failed. */
|
|
23
|
+
detail?: string | null;
|
|
24
|
+
error?: string | null;
|
|
25
|
+
labels?: Partial<TriageTaskLabels>;
|
|
26
|
+
/** Takes the change back. The button shows only when this is passed. */
|
|
27
|
+
onUndo?: () => void | Promise<void>;
|
|
28
|
+
class?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let {
|
|
32
|
+
sender,
|
|
33
|
+
subject,
|
|
34
|
+
outcome,
|
|
35
|
+
task,
|
|
36
|
+
when,
|
|
37
|
+
whenAt,
|
|
38
|
+
detail,
|
|
39
|
+
error,
|
|
40
|
+
labels,
|
|
41
|
+
onUndo,
|
|
42
|
+
class: className,
|
|
43
|
+
}: Props = $props();
|
|
44
|
+
|
|
45
|
+
let words = $derived({ ...DEFAULT_TRIAGE_TASK_LABELS, ...labels });
|
|
46
|
+
let busy = $state(false);
|
|
47
|
+
|
|
48
|
+
async function undo() {
|
|
49
|
+
busy = true;
|
|
50
|
+
try {
|
|
51
|
+
await onUndo?.();
|
|
52
|
+
} finally {
|
|
53
|
+
busy = false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<li
|
|
59
|
+
class={cn(
|
|
60
|
+
"flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between",
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<div class="min-w-0 space-y-1">
|
|
65
|
+
<p class="text-foreground text-sm font-medium [overflow-wrap:anywhere]">
|
|
66
|
+
{sender}: {subject}
|
|
67
|
+
</p>
|
|
68
|
+
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
69
|
+
<StatusBadge tone={outcome.tone} capitalize={false}>{outcome.label}</StatusBadge>
|
|
70
|
+
{#if task}
|
|
71
|
+
<a
|
|
72
|
+
href={task.url}
|
|
73
|
+
target="_blank"
|
|
74
|
+
rel="noreferrer"
|
|
75
|
+
class="text-primary min-w-0 text-xs [overflow-wrap:anywhere] hover:underline"
|
|
76
|
+
>
|
|
77
|
+
{task.name ?? words.openTask}
|
|
78
|
+
</a>
|
|
79
|
+
{/if}
|
|
80
|
+
<time class="text-muted-foreground text-xs" datetime={whenAt ?? undefined}>{when}</time>
|
|
81
|
+
</div>
|
|
82
|
+
{#if detail}
|
|
83
|
+
<p class="text-muted-foreground text-xs [overflow-wrap:anywhere]">{detail}</p>
|
|
84
|
+
{/if}
|
|
85
|
+
{#if error}
|
|
86
|
+
<p class="text-destructive text-xs [overflow-wrap:anywhere]" role="alert">{error}</p>
|
|
87
|
+
{/if}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{#if onUndo}
|
|
91
|
+
<Button variant="outline" class="shrink-0" disabled={busy} onclick={undo}>
|
|
92
|
+
{words.undo}
|
|
93
|
+
</Button>
|
|
94
|
+
{/if}
|
|
95
|
+
</li>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Tone } from "../lib/tone.js";
|
|
2
|
+
|
|
3
|
+
/** The words a plan card shows. Pass a partial to translate or reword. */
|
|
4
|
+
export type TriagePlanLabels = {
|
|
5
|
+
approve: string;
|
|
6
|
+
pick: string;
|
|
7
|
+
dismiss: string;
|
|
8
|
+
/** The label over the field where the person types the task to use instead. */
|
|
9
|
+
taskLabel: string;
|
|
10
|
+
useTask: string;
|
|
11
|
+
cancel: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_TRIAGE_PLAN_LABELS: TriagePlanLabels = {
|
|
15
|
+
approve: "Approve",
|
|
16
|
+
pick: "Pick another task",
|
|
17
|
+
dismiss: "Dismiss",
|
|
18
|
+
taskLabel: "Task",
|
|
19
|
+
useTask: "Use this task",
|
|
20
|
+
cancel: "Cancel",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The words a task row shows. */
|
|
24
|
+
export type TriageTaskLabels = { undo: string; openTask: string };
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_TRIAGE_TASK_LABELS: TriageTaskLabels = {
|
|
27
|
+
undo: "Undo",
|
|
28
|
+
openTask: "Open task",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** What happened to a settled email: the chip's words and its tone. */
|
|
32
|
+
export type TriageOutcome = { label: string; tone: Tone };
|
|
33
|
+
|
|
34
|
+
/** The task the email ended up as, when there is one. */
|
|
35
|
+
export type TriageTaskLink = { name?: string | null; url: string };
|