@heyramzi/ui 2.22.0 → 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-grid.svelte +72 -12
- 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
package/src/editor/editor.svelte
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import "./doc-page.css";
|
|
9
9
|
import { buildExtensions } from "./extensions.js";
|
|
10
10
|
import { buildSlashItems, type SlashItem } from "./slash-items.js";
|
|
11
|
+
import { SLASH_WINDOW, slashQueryAt } from "./slash-query.js";
|
|
11
12
|
import { parseYoutubeUrl } from "./youtube-url.js";
|
|
12
13
|
import EditorToolbar from "./editor-toolbar.svelte";
|
|
13
14
|
import SlashMenu from "./slash-menu.svelte";
|
|
@@ -238,7 +239,8 @@
|
|
|
238
239
|
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
|
239
240
|
editorProps: {
|
|
240
241
|
handleKeyDown: (_view, event) => {
|
|
241
|
-
|
|
242
|
+
// No items means no menu on screen, so Enter and the arrows stay the editor's.
|
|
243
|
+
if (showSlashMenu && slashItems.length > 0) {
|
|
242
244
|
if (event.key === "ArrowDown") {
|
|
243
245
|
event.preventDefault();
|
|
244
246
|
slashSelectedIndex = (slashSelectedIndex + 1) % slashItems.length;
|
|
@@ -294,11 +296,11 @@
|
|
|
294
296
|
onchange?.(e.getJSON());
|
|
295
297
|
|
|
296
298
|
const { from } = e.state.selection;
|
|
297
|
-
const textBefore = e.state.doc.textBetween(Math.max(0, from -
|
|
298
|
-
const
|
|
299
|
+
const textBefore = e.state.doc.textBetween(Math.max(0, from - SLASH_WINDOW), from, "\n");
|
|
300
|
+
const query = slashQueryAt(textBefore);
|
|
299
301
|
|
|
300
|
-
if (
|
|
301
|
-
slashQuery =
|
|
302
|
+
if (query !== null) {
|
|
303
|
+
slashQuery = query;
|
|
302
304
|
slashMenuPos = getCaretCoords();
|
|
303
305
|
showSlashMenu = true;
|
|
304
306
|
} else if (showSlashMenu) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:)/i;
|
|
2
|
+
const ANY_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
3
|
+
// WHY no SVG: an SVG can carry script, and pasted images are stored as these data: URLs.
|
|
4
|
+
const SAFE_IMAGE_DATA = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,/i;
|
|
5
|
+
|
|
6
|
+
// WHY: browsers drop tabs, newlines and control bytes from a URL, so "java\tscript:" runs as javascript:.
|
|
7
|
+
function clean(raw: string): string {
|
|
8
|
+
return [...raw]
|
|
9
|
+
.filter((c) => c.charCodeAt(0) > 31 && c.charCodeAt(0) !== 127)
|
|
10
|
+
.join("")
|
|
11
|
+
.trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The URL when it's safe to put in an href, else null.
|
|
15
|
+
export function safeUrl(raw: string | null | undefined): string | null {
|
|
16
|
+
if (!raw) return null;
|
|
17
|
+
const href = clean(raw);
|
|
18
|
+
// Relative URLs and same-page anchors carry no scheme, so they are safe.
|
|
19
|
+
if (href.startsWith("#") || href.startsWith("/") || href.startsWith(".")) return href;
|
|
20
|
+
if (SAFE_SCHEME.test(href)) return href;
|
|
21
|
+
if (!ANY_SCHEME.test(href)) return href;
|
|
22
|
+
return null; // javascript:, data:, vbscript:, ...
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The URL when it's safe to put in an img src, else null.
|
|
26
|
+
export function safeImageUrl(raw: string | null | undefined): string | null {
|
|
27
|
+
const url = safeUrl(raw);
|
|
28
|
+
if (url !== null) return url;
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
const src = clean(raw);
|
|
31
|
+
return SAFE_IMAGE_DATA.test(src) ? src : null;
|
|
32
|
+
}
|
|
@@ -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}
|