@heyramzi/ui 2.22.1 → 2.24.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 +3 -6
- package/dist/ai/confirmation/index.js +3 -6
- package/dist/ai/index.js +1 -5
- package/dist/canvas/editor/rebase.js +26 -2
- package/dist/charts/dashboard/index.js +1 -3
- package/dist/charts/primitives/chart-utils.js +14 -0
- package/dist/components/chip/index.js +1 -0
- package/dist/components/language-flag/flags.js +5 -0
- package/dist/components/language-flag/index.js +1 -0
- package/dist/components/list-page/index.js +1 -3
- package/dist/components/work-parts/index.js +1 -1
- package/dist/editor/doc-blocks.js +2 -1
- 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/effects/animated-beam/path-calculator.svelte.js +13 -6
- package/dist/lib/dates.js +41 -0
- package/dist/lib/panel.js +2 -2
- package/dist/lib/tone.js +13 -0
- package/dist/record/format.js +2 -18
- package/dist/record/logic.js +3 -6
- package/dist/status/index.js +1 -1
- package/dist/triage/index.js +4 -0
- package/dist/triage/types.js +12 -0
- package/dist/work/dates.js +2 -3
- package/dist/work/detail/detail-types.js +0 -8
- package/dist/work/docs/edited.js +8 -7
- package/dist/work/inbox/inbox-logic.js +5 -20
- package/dist/work/index.js +0 -2
- package/dist/work/time/duration.js +2 -3
- package/package.json +7 -21
- package/scripts/sync-shadcn.mjs +4 -6
- package/src/ai/confirmation/Confirmation.svelte +2 -2
- package/src/ai/confirmation/{ConfirmationRequest.svelte → ConfirmationState.svelte} +6 -3
- package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
- package/src/ai/confirmation/confirmation-parts.test.svelte +17 -0
- package/src/ai/confirmation/confirmation-parts.test.ts +23 -0
- package/src/ai/confirmation/index.ts +6 -21
- package/src/ai/index.ts +3 -13
- package/src/ai/typing-dots/TypingDots.svelte +32 -12
- package/src/ai/typing-dots/typing-dots.test.ts +36 -0
- 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 +44 -1
- package/src/charts/README.md +2 -2
- package/src/charts/area-chart.svelte +5 -12
- package/src/charts/bar-chart.svelte +6 -14
- package/src/charts/chart-tokens.css +19 -0
- package/src/charts/dashboard/data-table.svelte +21 -21
- package/src/charts/dashboard/index.ts +1 -3
- package/src/charts/dashboard/rating-chip.svelte +9 -11
- package/src/charts/dashboard/stat-tile.svelte +4 -5
- package/src/charts/donut-chart.svelte +1 -1
- package/src/charts/gauge-chart.svelte +1 -1
- package/src/charts/line-chart.svelte +5 -12
- package/src/charts/number-chart.svelte +1 -1
- package/src/charts/primitives/chart-utils.test.ts +17 -0
- package/src/charts/primitives/chart-utils.ts +16 -0
- 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 +9 -5
- package/src/components/chip/count-chip.svelte +35 -0
- package/src/components/chip/index.ts +1 -0
- package/src/components/data-table/data-table.ts +1 -1
- package/src/components/data-table/row-context-menu.svelte +3 -4
- package/src/components/data-table/sortable-head.svelte +45 -10
- package/src/components/language-flag/flags.ts +5 -0
- package/src/components/language-flag/index.ts +1 -0
- package/src/components/language-flag/language-flag.svelte +3 -11
- package/src/components/list-page/empty-state.svelte +90 -12
- package/src/components/list-page/filter-popover.svelte +2 -5
- package/src/components/list-page/grouped-list.svelte +2 -4
- package/src/components/list-page/index.ts +1 -3
- package/src/components/section-header/section-header.svelte +2 -6
- package/src/components/segmented/segment.svelte +13 -19
- package/src/components/segmented/segmented-filter.svelte +1 -2
- package/src/components/table-view/table-view.svelte +31 -45
- package/src/components/table-view/table-view.test.ts +6 -3
- package/src/components/work-parts/group-head.svelte +1 -1
- package/src/components/work-parts/index.ts +1 -1
- package/src/editor/doc-blocks.ts +2 -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/effects/animated-beam/path-calculator.svelte.ts +19 -5
- package/src/effects/marquee/Marquee.css +6 -3
- package/src/effects/marquee/Marquee.svelte +6 -2
- package/src/inbox/thread-inbox.svelte +6 -4
- package/src/lib/dates.test.ts +63 -0
- package/src/lib/dates.ts +63 -0
- package/src/lib/panel.svelte +116 -0
- package/src/lib/panel.ts +2 -2
- package/src/lib/tone.ts +15 -0
- package/src/modules.test.ts +13 -1
- package/src/panel/side-panel.svelte +4 -4
- package/src/record/format.ts +3 -15
- package/src/record/logic.ts +2 -6
- package/src/record/record-stats.svelte +4 -3
- package/src/record/steps-rail.svelte +7 -4
- 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/status/index.ts +1 -1
- package/src/status/status-pill.svelte +2 -8
- package/src/thread/thread.svelte +3 -6
- 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/README.md +0 -1
- package/src/work/dates.ts +3 -3
- package/src/work/demo.svelte +9 -1
- package/src/work/detail/detail-demo.svelte +30 -27
- package/src/work/detail/detail-types.ts +1 -29
- package/src/work/detail/task-detail.svelte +18 -17
- package/src/work/docs/doc-menu.svelte +2 -1
- package/src/work/docs/edited.ts +4 -7
- package/src/work/inbox/inbox-card.svelte +4 -9
- package/src/work/inbox/inbox-logic.ts +4 -16
- package/src/work/index.ts +1 -3
- package/src/work/kanban-column.svelte +5 -3
- package/src/work/time/duration.ts +2 -3
- package/src/work/time/time-chip.svelte +1 -3
- package/dist/ai/thinking-dots/index.js +0 -3
- package/dist/components/icon/index.js +0 -1
- package/dist/components/locale-switch/index.js +0 -1
- package/dist/icons/trades.js +0 -49
- package/dist/status/pill-tint.js +0 -13
- package/dist/work/presets/sales.js +0 -39
- package/src/ai/confirmation/ConfirmationAccepted.svelte +0 -23
- package/src/ai/confirmation/ConfirmationFailed.svelte +0 -23
- package/src/ai/confirmation/ConfirmationRejected.svelte +0 -23
- package/src/ai/thinking-dots/ThinkingDots.svelte +0 -42
- package/src/ai/thinking-dots/index.ts +0 -5
- package/src/charts/dashboard/dashboard-empty.svelte +0 -55
- package/src/charts/dashboard/data-panel.svelte +0 -81
- package/src/charts/dashboard/data-sort-head.svelte +0 -102
- package/src/components/icon/icon.svelte +0 -23
- package/src/components/icon/index.ts +0 -1
- package/src/components/list-page/detail-empty.svelte +0 -45
- package/src/components/list-page/detail-panel.svelte +0 -82
- package/src/components/list-page/rail-empty.svelte +0 -44
- package/src/components/locale-switch/index.ts +0 -1
- package/src/components/locale-switch/locale-switch.svelte +0 -59
- package/src/components/work-parts/count-chip.svelte +0 -20
- package/src/icons/TradeIcon.svelte +0 -16
- package/src/icons/trades.ts +0 -89
- package/src/status/pill-tint.ts +0 -16
- package/src/work/detail/task-property.svelte +0 -17
- package/src/work/detail/task-thread.svelte +0 -221
- package/src/work/presets/sales.ts +0 -16
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
|
+
}
|
|
@@ -34,12 +34,26 @@ export function usePathCalculator() {
|
|
|
34
34
|
const rectA = fromRef.getBoundingClientRect();
|
|
35
35
|
const rectB = toRef.getBoundingClientRect();
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// A scaled ancestor shrinks every rect, but the SVG draws in the container's own pixels.
|
|
38
|
+
// Divide the scale out, or the beams stop short of their nodes.
|
|
39
|
+
const scaleX = containerRef.offsetWidth ? containerRect.width / containerRef.offsetWidth : 1;
|
|
40
|
+
const scaleY = containerRef.offsetHeight ? containerRect.height / containerRef.offsetHeight : 1;
|
|
41
|
+
const local = (rect: DOMRect) =>
|
|
42
|
+
new DOMRect(
|
|
43
|
+
(rect.left - containerRect.left) / scaleX,
|
|
44
|
+
(rect.top - containerRect.top) / scaleY,
|
|
45
|
+
rect.width / scaleX,
|
|
46
|
+
rect.height / scaleY,
|
|
47
|
+
);
|
|
48
|
+
const a = local(rectA);
|
|
49
|
+
const b = local(rectB);
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
51
|
+
svgDimensions = { width: containerRect.width / scaleX, height: containerRect.height / scaleY };
|
|
52
|
+
|
|
53
|
+
const startX = anchorX(a, 0, fromAnchor, startXOffset);
|
|
54
|
+
const startY = a.top + a.height / 2 + startYOffset;
|
|
55
|
+
const endX = anchorX(b, 0, toAnchor, endXOffset);
|
|
56
|
+
const endY = b.top + b.height / 2 + endYOffset;
|
|
43
57
|
|
|
44
58
|
// A cubic with horizontal handles, not a quadratic through a raised midpoint.
|
|
45
59
|
|
|
@@ -23,9 +23,10 @@
|
|
|
23
23
|
.marquee__track--reverse {
|
|
24
24
|
animation-direction: reverse;
|
|
25
25
|
}
|
|
26
|
+
/* fit-content already stops at the parent's width. No max-width here: this sheet is unlayered, so
|
|
27
|
+
it would beat a caller's max-w-* utility and the line would never overflow its box. */
|
|
26
28
|
.marquee[data-overflow-only] {
|
|
27
29
|
width: fit-content;
|
|
28
|
-
max-width: 100%;
|
|
29
30
|
padding: 0;
|
|
30
31
|
}
|
|
31
32
|
.marquee[data-still] .marquee__track {
|
|
@@ -49,8 +50,10 @@
|
|
|
49
50
|
transparent
|
|
50
51
|
);
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
@media (hover: hover) and (pointer: fine) {
|
|
54
|
+
.marquee[data-pause-hover]:hover .marquee__track {
|
|
55
|
+
animation-play-state: paused;
|
|
56
|
+
}
|
|
54
57
|
}
|
|
55
58
|
.marquee[data-paused] .marquee__track {
|
|
56
59
|
animation-play-state: paused;
|
|
@@ -114,8 +114,12 @@
|
|
|
114
114
|
const track = firstTrack.getBoundingClientRect();
|
|
115
115
|
const padStart = parseFloat(vertical ? computed.paddingTop : computed.paddingLeft) || 0;
|
|
116
116
|
const padEnd = parseFloat(vertical ? computed.paddingBottom : computed.paddingRight) || 0;
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// Rects carry an ancestor's CSS scale, padding and speed don't. Back to the box's own
|
|
118
|
+
// pixels, or a scaled box loops at the wrong speed.
|
|
119
|
+
const own = vertical ? root.offsetHeight : root.offsetWidth;
|
|
120
|
+
const scale = own ? (vertical ? box.height : box.width) / own : 1;
|
|
121
|
+
boxSize = (vertical ? box.height : box.width) / scale - padStart - padEnd;
|
|
122
|
+
trackSize = (vertical ? track.height : track.width) / scale;
|
|
119
123
|
gapSize = parseFloat(vertical ? computed.rowGap : computed.columnGap) || 0;
|
|
120
124
|
};
|
|
121
125
|
measure();
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
UserPlus,
|
|
16
16
|
} from "phosphor-svelte";
|
|
17
17
|
import DetailHeader from "../components/list-page/detail-header.svelte";
|
|
18
|
-
import DetailPanel from "../
|
|
18
|
+
import DetailPanel from "../lib/panel.svelte";
|
|
19
19
|
import FilterItem from "../components/list-page/filter-item.svelte";
|
|
20
20
|
import FilterPopover from "../components/list-page/filter-popover.svelte";
|
|
21
21
|
import MasterDetail from "../components/list-page/master-detail.svelte";
|
|
22
22
|
import RailBody from "../components/list-page/rail-body.svelte";
|
|
23
|
-
import
|
|
23
|
+
import EmptyState from "../components/list-page/empty-state.svelte";
|
|
24
24
|
import RailHead from "../components/list-page/rail-head.svelte";
|
|
25
25
|
import RailRow from "../components/list-page/rail-row.svelte";
|
|
26
26
|
import { filterSectionClass } from "../components/list-page/filter-section.js";
|
|
@@ -427,7 +427,9 @@
|
|
|
427
427
|
</li>
|
|
428
428
|
{:else}
|
|
429
429
|
<li class="flex h-full flex-col items-center">
|
|
430
|
-
<
|
|
430
|
+
<EmptyState
|
|
431
|
+
size="sm"
|
|
432
|
+
class="h-full"
|
|
431
433
|
title={filtered
|
|
432
434
|
? filter.needsYou
|
|
433
435
|
? labels.emptyNeedsYou
|
|
@@ -531,7 +533,7 @@
|
|
|
531
533
|
</DetailPanel>
|
|
532
534
|
{/if}
|
|
533
535
|
{:else}
|
|
534
|
-
<
|
|
536
|
+
<EmptyState size="sm" class="h-full" title={labels.selectThread} />
|
|
535
537
|
{/if}
|
|
536
538
|
{/snippet}
|
|
537
539
|
</MasterDetail>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { formatAge } from "../record/format.js";
|
|
3
|
+
import { localDay as recordDay } from "../record/logic.js";
|
|
4
|
+
import { whenOf } from "../work/inbox/inbox-logic.js";
|
|
5
|
+
import { editStamp, editedLabel, shortDate as editedDate } from "../work/docs/edited.js";
|
|
6
|
+
import { todayIso } from "../work/dates.js";
|
|
7
|
+
import { localDay as timeDay } from "../work/time/duration.js";
|
|
8
|
+
|
|
9
|
+
// The modules that kept their own date names, pinned so the shared helpers can't change a string.
|
|
10
|
+
// 10:00 UTC is the same calendar day from Hawaii to Auckland, so whenOf's date holds in any zone.
|
|
11
|
+
const NOW = Date.parse("2026-10-23T10:00:00Z");
|
|
12
|
+
const ago = (seconds: number) => new Date(NOW - seconds * 1000).toISOString();
|
|
13
|
+
|
|
14
|
+
describe("relative time", () => {
|
|
15
|
+
it("formatAge says it in long words", () => {
|
|
16
|
+
expect(formatAge(ago(20), NOW)).toBe("now");
|
|
17
|
+
expect(formatAge(ago(3 * 86_400), NOW)).toBe("3 days ago");
|
|
18
|
+
expect(formatAge(ago(-14 * 86_400), NOW)).toBe("in 2 weeks");
|
|
19
|
+
expect(formatAge(ago(70 * 86_400), NOW)).toBe("2 months ago");
|
|
20
|
+
expect(formatAge(ago(400 * 86_400), NOW)).toBe("last year");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("whenOf says it short, then turns to a date at four weeks", () => {
|
|
24
|
+
expect(whenOf(ago(3 * 3_600), NOW)).toBe("3 hr ago");
|
|
25
|
+
expect(whenOf(ago(86_400), NOW)).toBe("yesterday");
|
|
26
|
+
expect(whenOf(ago(40 * 86_400), NOW)).toBe("13 Sept");
|
|
27
|
+
expect(whenOf(ago(40 * 86_400), NOW, "en-US")).toBe("Sep 13");
|
|
28
|
+
expect(whenOf("Tuesday", NOW)).toBe("Tuesday");
|
|
29
|
+
expect(whenOf("2026-13-45T00:00:00Z", NOW)).toBe("");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("short date", () => {
|
|
34
|
+
const options = { now: new Date(NOW), timeZone: "UTC" };
|
|
35
|
+
|
|
36
|
+
it("adds the year only when it isn't this one", () => {
|
|
37
|
+
expect(editedDate("2026-10-23T08:00:00Z", options)).toBe("Oct 23");
|
|
38
|
+
expect(editedDate("2025-10-23T08:00:00Z", options)).toBe("Oct 23, 2025");
|
|
39
|
+
expect(editedDate("not a date", options)).toBe("");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("builds the edited chip and stamp on it", () => {
|
|
43
|
+
expect(editStamp("2026-10-23T14:44:00Z", options)).toBe("Oct 23, 14:44");
|
|
44
|
+
expect(editedLabel("2026-10-23T14:44:00Z", "Edited", options)).toBe("Edited Oct 23");
|
|
45
|
+
expect(editedLabel(null, "Edited", options)).toBe("Edited");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("local day", () => {
|
|
50
|
+
it("reads a timestamp's day in the given zone", () => {
|
|
51
|
+
expect(recordDay("2026-10-23T23:30:00Z", "Europe/Paris")).toBe("2026-10-24");
|
|
52
|
+
expect(recordDay("2026-10-23T23:30:00Z", "UTC")).toBe("2026-10-23");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reads a date's day in the viewer's zone", () => {
|
|
56
|
+
expect(timeDay(new Date(2026, 0, 5, 23, 59))).toBe("2026-01-05");
|
|
57
|
+
expect(recordDay(new Date(2026, 0, 5, 0, 1).toISOString())).toBe("2026-01-05");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("today matches across the modules", () => {
|
|
61
|
+
expect(todayIso()).toBe(timeDay());
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/lib/dates.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The date strings the library shows, through Intl so a locale and a zone are all a caller passes.
|
|
2
|
+
|
|
3
|
+
type At = Date | string | number;
|
|
4
|
+
|
|
5
|
+
export type ShortDateOptions = {
|
|
6
|
+
locale?: string;
|
|
7
|
+
timeZone?: string;
|
|
8
|
+
/** Adds the year: "Oct 23, 2025". */
|
|
9
|
+
year?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** "Oct 23" in the caller's locale. */
|
|
13
|
+
export function shortDate(at: At, { locale, timeZone, year }: ShortDateOptions = {}): string {
|
|
14
|
+
return new Intl.DateTimeFormat(locale, {
|
|
15
|
+
month: "short",
|
|
16
|
+
day: "numeric",
|
|
17
|
+
year: year ? "numeric" : undefined,
|
|
18
|
+
timeZone,
|
|
19
|
+
}).format(new Date(at));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const UNITS: [number, Intl.RelativeTimeFormatUnit][] = [
|
|
23
|
+
[31_536_000, "year"],
|
|
24
|
+
[2_592_000, "month"],
|
|
25
|
+
[604_800, "week"],
|
|
26
|
+
[86_400, "day"],
|
|
27
|
+
[3_600, "hour"],
|
|
28
|
+
[60, "minute"],
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export type RelativeTimeOptions = {
|
|
32
|
+
locale?: string;
|
|
33
|
+
/** `long` says "3 hours ago", `short` "3 hr ago". */
|
|
34
|
+
style?: Intl.RelativeTimeFormatStyle;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** "3 days ago", "in 2 weeks", "now": the largest whole unit between `at` and `now`. */
|
|
38
|
+
export function relativeTime(
|
|
39
|
+
at: At,
|
|
40
|
+
now: At = Date.now(),
|
|
41
|
+
{ locale, style = "long" }: RelativeTimeOptions = {},
|
|
42
|
+
): string {
|
|
43
|
+
const seconds = Math.round((new Date(at).getTime() - new Date(now).getTime()) / 1000);
|
|
44
|
+
const age = Math.abs(seconds);
|
|
45
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style });
|
|
46
|
+
for (const [size, unit] of UNITS) {
|
|
47
|
+
if (age >= size) return rtf.format(Math.round(seconds / size), unit);
|
|
48
|
+
}
|
|
49
|
+
return rtf.format(0, "second");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The calendar day of a moment as `YYYY-MM-DD`, in the given zone or the viewer's own. Today by default. */
|
|
53
|
+
export function localDay(at: At = new Date(), timeZone?: string): string {
|
|
54
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
55
|
+
year: "numeric",
|
|
56
|
+
month: "2-digit",
|
|
57
|
+
day: "2-digit",
|
|
58
|
+
timeZone,
|
|
59
|
+
}).formatToParts(new Date(at));
|
|
60
|
+
const part = (type: Intl.DateTimeFormatPartTypes) =>
|
|
61
|
+
parts.find((p) => p.type === type)?.value ?? "";
|
|
62
|
+
return `${part("year")}-${part("month")}-${part("day")}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* The one titled panel, exported as `DataPanel` from the dashboard and `DetailPanel`
|
|
4
|
+
* from list-page. Both drew the same box from `panel.ts` with different props.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// =========================
|
|
8
|
+
// IMPORTS
|
|
9
|
+
// =========================
|
|
10
|
+
import type { Snippet } from "svelte";
|
|
11
|
+
import SectionLabel from "../components/section-header/section-label.svelte";
|
|
12
|
+
import { cn } from "./utils.js";
|
|
13
|
+
import { panelHeaderClass, panelShellClass } from "./panel.js";
|
|
14
|
+
import type { PhosphorIcon } from "./icon-types.js";
|
|
15
|
+
|
|
16
|
+
// =========================
|
|
17
|
+
// TYPES
|
|
18
|
+
// =========================
|
|
19
|
+
interface Props {
|
|
20
|
+
/** What this panel is, drawn as the app's one panel label. No label, no actions: no header. */
|
|
21
|
+
label?: string;
|
|
22
|
+
labelIcon?: PhosphorIcon;
|
|
23
|
+
/** A mark the icon set lacks (a platform logo), drawn ahead of the label at the same size. */
|
|
24
|
+
labelLead?: Snippet;
|
|
25
|
+
/** A line under the label, for the scope a number was measured over. */
|
|
26
|
+
sublabel?: string;
|
|
27
|
+
/**
|
|
28
|
+
* A row count, total or freshness stamp. A prop, not an action, because
|
|
29
|
+
* hand-placed hints ended up at three type sizes on one screen.
|
|
30
|
+
*/
|
|
31
|
+
hint?: string;
|
|
32
|
+
/** Controls that act on this panel. Pinned right, after the hint. */
|
|
33
|
+
actions?: Snippet;
|
|
34
|
+
/** Off for a body that supplies its own padding: a table, a media grid, a full-bleed list. */
|
|
35
|
+
pad?: boolean;
|
|
36
|
+
/** The body scrolls sideways rather than squeezing, for a table wider than the window. */
|
|
37
|
+
scroll?: boolean;
|
|
38
|
+
/** Takes the height the panels above it left, inside a `fill` stack. One per stack. */
|
|
39
|
+
grow?: boolean;
|
|
40
|
+
children: Snippet;
|
|
41
|
+
class?: string;
|
|
42
|
+
bodyClass?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// =========================
|
|
46
|
+
// PROPS
|
|
47
|
+
// =========================
|
|
48
|
+
let {
|
|
49
|
+
label,
|
|
50
|
+
labelIcon,
|
|
51
|
+
labelLead,
|
|
52
|
+
sublabel,
|
|
53
|
+
hint,
|
|
54
|
+
actions,
|
|
55
|
+
pad = true,
|
|
56
|
+
scroll = false,
|
|
57
|
+
grow = false,
|
|
58
|
+
children,
|
|
59
|
+
class: className,
|
|
60
|
+
bodyClass,
|
|
61
|
+
}: Props = $props();
|
|
62
|
+
|
|
63
|
+
// =========================
|
|
64
|
+
// DERIVED
|
|
65
|
+
// =========================
|
|
66
|
+
let titled = $derived(Boolean(label || labelLead || sublabel));
|
|
67
|
+
let hasHeader = $derived(titled || Boolean(hint || actions));
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<!-- =========================== -->
|
|
71
|
+
<!-- MARKUP -->
|
|
72
|
+
<!-- =========================== -->
|
|
73
|
+
<section class={cn(panelShellClass, grow && "flex min-h-0 flex-1 flex-col", className)}>
|
|
74
|
+
{#if hasHeader}
|
|
75
|
+
<div class={panelHeaderClass}>
|
|
76
|
+
{#if titled}
|
|
77
|
+
<div class="flex min-w-0 flex-col gap-0.5">
|
|
78
|
+
{#if label || labelLead}
|
|
79
|
+
<SectionLabel icon={labelIcon}>
|
|
80
|
+
{@render labelLead?.()}
|
|
81
|
+
{label}
|
|
82
|
+
</SectionLabel>
|
|
83
|
+
{/if}
|
|
84
|
+
{#if sublabel}
|
|
85
|
+
<span class="truncate text-xs text-muted-foreground">{sublabel}</span>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
{:else}
|
|
89
|
+
<!-- Holds the left slot so the actions stay pinned right. -->
|
|
90
|
+
<span></span>
|
|
91
|
+
{/if}
|
|
92
|
+
{#if hint || actions}
|
|
93
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
94
|
+
{#if hint}
|
|
95
|
+
<span class="text-xs tabular-nums text-muted-foreground">{hint}</span>
|
|
96
|
+
{/if}
|
|
97
|
+
{#if actions}
|
|
98
|
+
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2">
|
|
99
|
+
{@render actions()}
|
|
100
|
+
</div>
|
|
101
|
+
{/if}
|
|
102
|
+
</div>
|
|
103
|
+
{/if}
|
|
104
|
+
</div>
|
|
105
|
+
{/if}
|
|
106
|
+
<div
|
|
107
|
+
class={cn(
|
|
108
|
+
pad && "p-4",
|
|
109
|
+
scroll && "overflow-x-auto",
|
|
110
|
+
grow && "flex min-h-0 flex-1 flex-col",
|
|
111
|
+
bodyClass,
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
{@render children()}
|
|
115
|
+
</div>
|
|
116
|
+
</section>
|
package/src/lib/panel.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The one panel shell: a titled box holding a table, a chart or a stack. */
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Tokens beside `panel.svelte`, because skeletons and hand-built cards draw this
|
|
4
|
+
// same edge without the panel's header.
|
|
5
5
|
|
|
6
6
|
// Dashboards used `Card`, so /ads had a ring and a shadow where /social had a
|
|
7
7
|
// hairline. Flat, one hairline, `rounded-xl`.
|
package/src/lib/tone.ts
CHANGED
|
@@ -15,6 +15,21 @@ export const TONE_CLASS: Record<Tone, string> = {
|
|
|
15
15
|
destructive: "border-destructive/30 bg-destructive/10 text-destructive-ink",
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The scale as a quiet tint with no border: the icon and the text carry the tone, the fill only
|
|
20
|
+
* groups them. Neutral reads off the foreground, not `bg-muted`, because muted sits 0.005 L from
|
|
21
|
+
* the canvas and vanishes there (tokens board, "what fights the new scale").
|
|
22
|
+
*/
|
|
23
|
+
export const TONE_TINT: Record<Tone, string> = {
|
|
24
|
+
neutral: "bg-foreground/6 text-muted-foreground",
|
|
25
|
+
accent:
|
|
26
|
+
"bg-[color-mix(in_oklab,var(--tone-accent,var(--color-primary))_12%,transparent)] text-[color:var(--tone-accent,var(--color-primary))]",
|
|
27
|
+
info: "bg-info/12 text-info-800 dark:text-info-400",
|
|
28
|
+
success: "bg-success/12 text-success-800 dark:text-success-400",
|
|
29
|
+
warning: "bg-warning/14 text-warning-800 dark:text-warning-400",
|
|
30
|
+
destructive: "bg-destructive/10 text-destructive-ink",
|
|
31
|
+
};
|
|
32
|
+
|
|
18
33
|
/** The same scale as text, for a verdict inside a line of muted facts. */
|
|
19
34
|
export const TONE_TEXT: Record<Tone, string> = {
|
|
20
35
|
neutral: "text-muted-foreground",
|
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([]);
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import * as Sheet from "../shadcn/sheet/index.js";
|
|
12
12
|
import { OpenIn, type OpenInTool } from "../components/open-in/index.js";
|
|
13
13
|
import { CopyButton } from "../components/copy/index.js";
|
|
14
|
-
import type
|
|
14
|
+
import { TONE_TEXT, type Tone } from "../lib/tone.js";
|
|
15
15
|
import { cn } from "../lib/utils.js";
|
|
16
16
|
import StatusBadge from "../status/status-badge.svelte";
|
|
17
17
|
import PopValue from "../charts/pop-value.svelte";
|
|
@@ -75,9 +75,9 @@
|
|
|
75
75
|
let hasRow = $derived(badges.length > 0 || openIn.length > 0 || Boolean(handoff || actions));
|
|
76
76
|
const STAT_TONE = {
|
|
77
77
|
default: "",
|
|
78
|
-
muted:
|
|
79
|
-
good:
|
|
80
|
-
bad:
|
|
78
|
+
muted: TONE_TEXT.neutral,
|
|
79
|
+
good: TONE_TEXT.success,
|
|
80
|
+
bad: TONE_TEXT.destructive,
|
|
81
81
|
} as const;
|
|
82
82
|
const WIDTH: Record<SidePanelWidth, string> = {
|
|
83
83
|
sm: "sm:max-w-sm",
|
package/src/record/format.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Display formatting through Intl, so a locale string is all a host passes.
|
|
2
2
|
// A date-only value ("2026-10-26") is a calendar day, read in UTC so no zone shifts it.
|
|
3
3
|
|
|
4
|
+
import { relativeTime } from "../lib/dates.js";
|
|
5
|
+
|
|
4
6
|
const DAY_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
5
7
|
|
|
6
8
|
function parse(iso: string): { date: Date; timeZone?: string } {
|
|
@@ -61,21 +63,7 @@ export function formatTime(iso: string, locale = "en-GB", timeZone?: string): st
|
|
|
61
63
|
|
|
62
64
|
/** "3 days ago", "in 2 weeks", "now". */
|
|
63
65
|
export function formatAge(iso: string, now: Date | number = Date.now(), locale = "en-GB"): string {
|
|
64
|
-
|
|
65
|
-
const abs = Math.abs(seconds);
|
|
66
|
-
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "long" });
|
|
67
|
-
const units: [number, Intl.RelativeTimeFormatUnit][] = [
|
|
68
|
-
[31_536_000, "year"],
|
|
69
|
-
[2_592_000, "month"],
|
|
70
|
-
[604_800, "week"],
|
|
71
|
-
[86_400, "day"],
|
|
72
|
-
[3_600, "hour"],
|
|
73
|
-
[60, "minute"],
|
|
74
|
-
];
|
|
75
|
-
for (const [size, unit] of units) {
|
|
76
|
-
if (abs >= size) return rtf.format(Math.round(seconds / size), unit);
|
|
77
|
-
}
|
|
78
|
-
return rtf.format(0, "second");
|
|
66
|
+
return relativeTime(iso, now, { locale });
|
|
79
67
|
}
|
|
80
68
|
|
|
81
69
|
/** "€12,000". `compact` gives "€12K". */
|
package/src/record/logic.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// The record's pure logic: grouping, merging, picking. No DOM, no clock of its own.
|
|
2
|
+
import { localDay as dayOf } from "../lib/dates.js";
|
|
2
3
|
import type {
|
|
3
4
|
RecordEvent,
|
|
4
5
|
RecordFilterDef,
|
|
@@ -16,12 +17,7 @@ import type {
|
|
|
16
17
|
|
|
17
18
|
/** The calendar day of a timestamp as `YYYY-MM-DD`, in the given zone or the viewer's own. */
|
|
18
19
|
export function localDay(iso: string, timeZone?: string): string {
|
|
19
|
-
return
|
|
20
|
-
year: "numeric",
|
|
21
|
-
month: "2-digit",
|
|
22
|
-
day: "2-digit",
|
|
23
|
-
timeZone,
|
|
24
|
-
}).format(new Date(iso));
|
|
20
|
+
return dayOf(iso, timeZone);
|
|
25
21
|
}
|
|
26
22
|
|
|
27
23
|
export type DayGroup = { day: string; events: RecordEvent[] };
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// =========================
|
|
3
3
|
// IMPORTS
|
|
4
4
|
// =========================
|
|
5
|
+
import { TONE_TEXT } from "../lib/tone.js";
|
|
5
6
|
import { cn } from "../lib/utils.js";
|
|
6
7
|
import PopValue from "../charts/pop-value.svelte";
|
|
7
8
|
import type { PanelStat } from "../panel/side-panel-types.js";
|
|
@@ -23,9 +24,9 @@
|
|
|
23
24
|
// =========================
|
|
24
25
|
const TONE = {
|
|
25
26
|
default: "",
|
|
26
|
-
muted:
|
|
27
|
-
good:
|
|
28
|
-
bad:
|
|
27
|
+
muted: TONE_TEXT.neutral,
|
|
28
|
+
good: TONE_TEXT.success,
|
|
29
|
+
bad: TONE_TEXT.destructive,
|
|
29
30
|
} as const;
|
|
30
31
|
</script>
|
|
31
32
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
} from "phosphor-svelte";
|
|
15
15
|
import { cn } from "../lib/utils.js";
|
|
16
16
|
import { Button } from "../shadcn/button/index.js";
|
|
17
|
+
import CountChip from "../components/chip/count-chip.svelte";
|
|
17
18
|
import { Input } from "../shadcn/input/index.js";
|
|
18
19
|
import { Skeleton } from "../shadcn/skeleton/index.js";
|
|
19
20
|
import * as Popover from "../shadcn/popover/index.js";
|
|
@@ -279,10 +280,12 @@
|
|
|
279
280
|
>
|
|
280
281
|
<DotsThreeVertical class="size-4" weight="bold" />
|
|
281
282
|
{#if hiddenCount > 0}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
283
|
+
<!-- Opaque, since it sits over the icon's corner. -->
|
|
284
|
+
<CountChip
|
|
285
|
+
count={hiddenCount}
|
|
286
|
+
size="sm"
|
|
287
|
+
class="absolute -top-0.5 -right-0.5 bg-muted"
|
|
288
|
+
/>
|
|
286
289
|
{/if}
|
|
287
290
|
</Button>
|
|
288
291
|
{/snippet}
|
|
@@ -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
|
});
|