@frockbot/client-ui 0.3.9 → 0.3.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/client-ui",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,7 +12,7 @@
12
12
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "dependencies": {
15
- "@frockbot/client-core": "0.3.9",
15
+ "@frockbot/client-core": "0.3.11",
16
16
  "markdown-it": "15.0.1",
17
17
  "vue": "3.5.41"
18
18
  },
@@ -0,0 +1,156 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A thin stroke drawn around an avatar while that Bot is working.
4
+ *
5
+ * It says two things without words: that something is still happening — it
6
+ * breathes — and that steps are going by, because the stroke advances as the
7
+ * caller's `progress` does. It never names what those steps were; the point of
8
+ * the ring is that the transcript stays a conversation.
9
+ *
10
+ * The element positions itself over its parent, which must be `position:
11
+ * relative`, and sits behind pointer events. `laps` draws a faint completed
12
+ * ring behind the live one so a long Turn reads as further along without the
13
+ * ring growing.
14
+ */
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ /** Fraction of the ring the live stroke draws, `0 … 1`. */
18
+ progress?: number;
19
+ /** Whether the ring breathes. A settled ring is still, and full. */
20
+ running?: boolean;
21
+ /** Faint rings behind the live stroke. Bounded by the caller. */
22
+ laps?: number;
23
+ /** What a screen reader is told this ring means. */
24
+ label?: string;
25
+ }>(),
26
+ { progress: 0, running: true, laps: 0, label: "Working" },
27
+ );
28
+
29
+ /*
30
+ * A 100-unit viewBox makes the geometry readable: the circumference below is
31
+ * the only number the stroke maths needs, and `stroke-dasharray` splits it
32
+ * into the drawn arc and the gap.
33
+ */
34
+ const RADIUS = 46;
35
+ const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
36
+
37
+ const arc = () => {
38
+ const fraction = Math.min(1, Math.max(0, props.progress));
39
+ // A ring with nothing drawn on it is a ring nobody sees, and the first
40
+ // moment of a Turn — before a single step has settled — is exactly when
41
+ // somebody needs to see one. The minimum arc is a fifth of the circle: big
42
+ // enough to read as a moving head, small enough that the first real tick is
43
+ // still an advance.
44
+ const drawn = Math.max(0.2, fraction) * CIRCUMFERENCE;
45
+ return `${drawn} ${CIRCUMFERENCE - drawn}`;
46
+ };
47
+ </script>
48
+
49
+ <template>
50
+ <span
51
+ class="ui-activity-ring"
52
+ :class="{ 'ui-activity-ring--running': running }"
53
+ role="status"
54
+ :aria-label="label"
55
+ >
56
+ <svg viewBox="0 0 100 100" aria-hidden="true" focusable="false">
57
+ <circle
58
+ v-if="laps > 0"
59
+ class="ui-activity-ring__lap"
60
+ cx="50"
61
+ cy="50"
62
+ :r="RADIUS"
63
+ />
64
+ <circle class="ui-activity-ring__track" cx="50" cy="50" :r="RADIUS" />
65
+ <circle
66
+ class="ui-activity-ring__arc"
67
+ cx="50"
68
+ cy="50"
69
+ :r="RADIUS"
70
+ :stroke-dasharray="arc()"
71
+ />
72
+ </svg>
73
+ </span>
74
+ </template>
75
+
76
+ <style scoped>
77
+ .ui-activity-ring {
78
+ position: absolute;
79
+
80
+ /*
81
+ * Clear of the avatar rather than on top of it: an avatar is usually a
82
+ * rounded square, and a circle inscribed in its own box would run under the
83
+ * art at the sides and vanish. `z-index` keeps it above a positioned avatar,
84
+ * which is what the sidebar rows use.
85
+ */
86
+ z-index: 1;
87
+ inset: -7px;
88
+ pointer-events: none;
89
+ }
90
+
91
+ .ui-activity-ring svg {
92
+ width: 100%;
93
+ height: 100%;
94
+ /* Twelve o'clock, so the stroke advances the way a clock hand does. */
95
+ transform: rotate(-90deg);
96
+ overflow: visible;
97
+ }
98
+
99
+ /*
100
+ * The viewBox is 100 units across and the element is roughly 36px, so 6 units
101
+ * of stroke is the ~2px hairline this is meant to be at the size an avatar is
102
+ * actually drawn.
103
+ */
104
+ .ui-activity-ring circle {
105
+ fill: none;
106
+ stroke-width: 6;
107
+ stroke-linecap: round;
108
+ }
109
+
110
+ /* The unfilled part of the ring. Present enough to read as a circle. */
111
+ .ui-activity-ring__track {
112
+ stroke: var(--frock-border-strong);
113
+ opacity: 0.75;
114
+ }
115
+
116
+ .ui-activity-ring__lap {
117
+ stroke: var(--frock-action-primary);
118
+ opacity: 0.28;
119
+ }
120
+
121
+ .ui-activity-ring__arc {
122
+ stroke: var(--frock-action-primary);
123
+ /*
124
+ * The tick itself: a step settles, `progress` changes, and the dash grows
125
+ * into its new length rather than jumping there.
126
+ */
127
+ transition: stroke-dasharray 420ms ease-out;
128
+ }
129
+
130
+ .ui-activity-ring--running .ui-activity-ring__arc {
131
+ animation: frock-ring-pulse 2200ms ease-in-out infinite;
132
+ }
133
+
134
+ @keyframes frock-ring-pulse {
135
+ 0%,
136
+ 100% {
137
+ opacity: 0.7;
138
+ }
139
+
140
+ 50% {
141
+ opacity: 1;
142
+ }
143
+ }
144
+
145
+ /*
146
+ * Reduced motion keeps the information and drops the movement: the ring still
147
+ * ticks forward for every step, it simply does not breathe or animate there.
148
+ */
149
+ @media (prefers-reduced-motion: reduce) {
150
+ .ui-activity-ring__arc {
151
+ transition: none;
152
+ animation: none;
153
+ opacity: 1;
154
+ }
155
+ }
156
+ </style>
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export {
6
6
  UI_ANCHOR_HIGHLIGHT_MS,
7
7
  type UiAnchorEvent,
8
8
  } from "./anchors.js";
9
+ export { default as UiActivityRing } from "./UiActivityRing.vue";
9
10
  export { default as UiButton } from "./UiButton.vue";
10
11
  export { default as UiField } from "./UiField.vue";
11
12
  export { default as UiIcon } from "./UiIcon.vue";
@@ -15,3 +16,11 @@ export { default as UiMarkdown } from "./UiMarkdown.vue";
15
16
  export { renderMarkdown } from "./markdown.js";
16
17
  export { default as UiSkeleton } from "./UiSkeleton.vue";
17
18
  export { createClientSurfaceRegistry } from "./surfaces.js";
19
+ export {
20
+ browserTimeZoneV1,
21
+ formatDayV1,
22
+ formatMomentV1,
23
+ formatRelativeMomentV1,
24
+ formatTimeOfDayV1,
25
+ type UiMomentOptionsV1,
26
+ } from "./time.js";
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ browserTimeZoneV1,
4
+ formatDayV1,
5
+ formatMomentV1,
6
+ formatRelativeMomentV1,
7
+ formatTimeOfDayV1,
8
+ } from "./time.js";
9
+
10
+ const SYDNEY = { timeZone: "Australia/Sydney" } as const;
11
+
12
+ describe("formatMomentV1", () => {
13
+ test("renders a durable ISO moment in the reader's zone, day first", () => {
14
+ // The panel used to render this exact string verbatim.
15
+ expect(formatMomentV1("2026-09-03T12:08:28.834Z", SYDNEY)).toBe(
16
+ "3 Sep 2026, 10:08pm",
17
+ );
18
+ });
19
+
20
+ test("never renders the US month-first order", () => {
21
+ const formatted = formatMomentV1("2026-09-03T11:59:51.000Z", SYDNEY);
22
+ expect(formatted).not.toContain("9/3/2026");
23
+ expect(formatted.startsWith("3 Sep 2026")).toBe(true);
24
+ });
25
+
26
+ test("renders the same characters on every ICU build", () => {
27
+ // The month abbreviation and the am/pm marker are CLDR data, so a locale's
28
+ // own rendering of them moves between platform versions: the same instant
29
+ // read out of `en-AU` is "3 Sep 2026" under one ICU and "3 Sept 2026"
30
+ // under the next, with a narrow no-break space before the marker on some
31
+ // builds. This module assembles the string itself so two people reading
32
+ // the same Bot see the same text.
33
+ const formatted = formatMomentV1("2026-09-03T12:08:28.834Z", SYDNEY);
34
+ expect(formatted).toBe("3 Sep 2026, 10:08pm");
35
+ expect(formatted).not.toContain("Sept");
36
+ expect(formatted).toMatch(
37
+ /^\d{1,2} [A-Z][a-z]{2} \d{4}, \d{1,2}:\d{2}(am|pm)$/u,
38
+ );
39
+ // No exotic whitespace anywhere: one plain space after the day and the
40
+ // month, and nothing at all before the marker.
41
+ expect(/[^\u0020\w:,]/u.test(formatted)).toBe(false);
42
+ });
43
+
44
+ test("returns unparseable text untouched rather than 'Invalid Date'", () => {
45
+ expect(formatMomentV1("not a moment", SYDNEY)).toBe("not a moment");
46
+ expect(formatRelativeMomentV1("", SYDNEY)).toBe("");
47
+ });
48
+ });
49
+
50
+ describe("formatTimeOfDayV1", () => {
51
+ test("reads midnight and noon as 12, not as 0", () => {
52
+ // A 24-hour clock reads midnight as `0` and some ICU builds as `24`;
53
+ // neither is an hour anybody writes down.
54
+ expect(formatTimeOfDayV1("2026-09-03T14:00:00.000Z", SYDNEY)).toBe(
55
+ "12:00am",
56
+ );
57
+ expect(formatTimeOfDayV1("2026-09-03T02:00:00.000Z", SYDNEY)).toBe(
58
+ "12:00pm",
59
+ );
60
+ });
61
+
62
+ test("is 12-hour with a lowercase marker and no leading zero", () => {
63
+ expect(formatTimeOfDayV1("2026-09-03T23:30:00.000Z", SYDNEY)).toBe(
64
+ "9:30am",
65
+ );
66
+ expect(formatTimeOfDayV1("2026-09-03T04:05:00.000Z", SYDNEY)).toBe(
67
+ "2:05pm",
68
+ );
69
+ });
70
+ });
71
+
72
+ describe("formatDayV1", () => {
73
+ test("abbreviates the month", () => {
74
+ expect(formatDayV1("2026-09-03T12:00:00.000Z", SYDNEY)).toBe("3 Sep 2026");
75
+ });
76
+ });
77
+
78
+ describe("formatRelativeMomentV1", () => {
79
+ const now = new Date("2026-09-03T12:00:00.000Z");
80
+
81
+ test("reads a fresh run relatively and an old one absolutely", () => {
82
+ expect(
83
+ formatRelativeMomentV1("2026-09-03T11:59:30.000Z", { ...SYDNEY, now }),
84
+ ).toBe("just now");
85
+ expect(
86
+ formatRelativeMomentV1("2026-09-03T11:45:00.000Z", { ...SYDNEY, now }),
87
+ ).toBe("15 min ago");
88
+ expect(
89
+ formatRelativeMomentV1("2026-09-03T09:00:00.000Z", { ...SYDNEY, now }),
90
+ ).toBe("3 hours ago");
91
+ expect(
92
+ formatRelativeMomentV1("2026-08-30T09:00:00.000Z", { ...SYDNEY, now }),
93
+ ).toBe("30 Aug 2026, 7:00pm");
94
+ });
95
+
96
+ test("a future moment is absolute — 'in a while' is not a next run", () => {
97
+ expect(
98
+ formatRelativeMomentV1("2026-09-03T12:30:00.000Z", { ...SYDNEY, now }),
99
+ ).toBe("3 Sep 2026, 10:30pm");
100
+ });
101
+ });
102
+
103
+ describe("browserTimeZoneV1", () => {
104
+ test("names a zone the form can default to", () => {
105
+ expect(browserTimeZoneV1().length).toBeGreaterThan(0);
106
+ });
107
+ });
package/src/time.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * One place the whole client turns a durable ISO moment into something a
3
+ * person reads.
4
+ *
5
+ * Every durable record carries UTC ISO text, because that is the only form two
6
+ * machines can agree on. A person reading the panel is not two machines: they
7
+ * want the moment in their own day. Panels used to render the ISO string
8
+ * verbatim — "Next run 2026-09-03T12:08:28.834Z" — which is precise and
9
+ * unreadable, and the one panel that did format dates picked the US order for
10
+ * an Australian reader. So this module, and nothing else, decides the shape.
11
+ *
12
+ * The shape is assembled here from numeric parts rather than taken from a
13
+ * locale's own rendering. `Intl` is asked only for the two things it alone
14
+ * knows — which calendar day and clock hour a UTC instant falls on in a given
15
+ * zone — and every visible character after that is ours. It has to be: the
16
+ * month abbreviation and the am/pm marker are CLDR data, so the same code
17
+ * renders "3 Sep 2026" under one ICU version and "3 Sept 2026" under the next,
18
+ * and a narrow no-break space before the marker under some builds and a plain
19
+ * one under others. That is invisible until a test written on one machine
20
+ * fails on another, and worse, it means two people reading the same Bot see
21
+ * different text. A house style that varies by machine is not a house style.
22
+ */
23
+
24
+ /** The month names this module renders. Ours, not the platform's. */
25
+ const MONTHS = [
26
+ "Jan",
27
+ "Feb",
28
+ "Mar",
29
+ "Apr",
30
+ "May",
31
+ "Jun",
32
+ "Jul",
33
+ "Aug",
34
+ "Sep",
35
+ "Oct",
36
+ "Nov",
37
+ "Dec",
38
+ ] as const;
39
+
40
+ /**
41
+ * A locale chosen for one property only: it yields plain numeric parts for
42
+ * `year`/`month`/`day`/`hour`/`minute` in every ICU build. Nothing of its
43
+ * ordering, separators or names reaches the screen — `formatToParts` is read
44
+ * by name and the string is rebuilt below.
45
+ */
46
+ const PARTS_LOCALE = "en-GB";
47
+
48
+ /** The reader's own zone, or UTC where the environment will not say. */
49
+ export function browserTimeZoneV1(): string {
50
+ try {
51
+ const zone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
52
+ return typeof zone === "string" && zone.length > 0 ? zone : "UTC";
53
+ } catch {
54
+ return "UTC";
55
+ }
56
+ }
57
+
58
+ export interface UiMomentOptionsV1 {
59
+ /** The zone to read the moment in. Defaults to the reader's own. */
60
+ timeZone?: string;
61
+ }
62
+
63
+ interface MomentPartsV1 {
64
+ day: number;
65
+ month: number;
66
+ year: number;
67
+ hour: number;
68
+ minute: number;
69
+ }
70
+
71
+ /**
72
+ * The calendar day and clock time one UTC instant falls on in one zone.
73
+ *
74
+ * Read as a 24-hour clock and converted below, because `hour12` is where the
75
+ * am/pm marker and its spacing come from, and those are exactly the parts that
76
+ * differ between ICU builds.
77
+ */
78
+ function momentParts(
79
+ iso: string,
80
+ options: UiMomentOptionsV1,
81
+ ): MomentPartsV1 | undefined {
82
+ const parsed = Date.parse(iso);
83
+ if (!Number.isFinite(parsed)) return undefined;
84
+ let parts: Intl.DateTimeFormatPart[];
85
+ try {
86
+ parts = new Intl.DateTimeFormat(PARTS_LOCALE, {
87
+ year: "numeric",
88
+ month: "numeric",
89
+ day: "numeric",
90
+ hour: "numeric",
91
+ minute: "2-digit",
92
+ hour12: false,
93
+ timeZone: options.timeZone ?? browserTimeZoneV1(),
94
+ }).formatToParts(new Date(parsed));
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ const read = (type: Intl.DateTimeFormatPartTypes): number => {
99
+ const found = parts.find((part) => part.type === type);
100
+ return found === undefined ? Number.NaN : Number(found.value);
101
+ };
102
+ const moment = {
103
+ day: read("day"),
104
+ month: read("month"),
105
+ year: read("year"),
106
+ // Some builds render midnight under a 24-hour clock as `24`.
107
+ hour: read("hour") % 24,
108
+ minute: read("minute"),
109
+ };
110
+ return Object.values(moment).every((value) => Number.isFinite(value))
111
+ ? moment
112
+ : undefined;
113
+ }
114
+
115
+ /** `9:30am`, lowercase, no leading zero on the hour. */
116
+ export function formatTimeOfDayV1(
117
+ iso: string,
118
+ options: UiMomentOptionsV1 = {},
119
+ ): string {
120
+ const moment = momentParts(iso, options);
121
+ if (!moment) return iso;
122
+ const marker = moment.hour < 12 ? "am" : "pm";
123
+ const hour = moment.hour % 12 === 0 ? 12 : moment.hour % 12;
124
+ return `${hour}:${String(moment.minute).padStart(2, "0")}${marker}`;
125
+ }
126
+
127
+ /** `3 Sep 2026` — day first, never the US month-first order. */
128
+ export function formatDayV1(
129
+ iso: string,
130
+ options: UiMomentOptionsV1 = {},
131
+ ): string {
132
+ const moment = momentParts(iso, options);
133
+ if (!moment) return iso;
134
+ const month = MONTHS[moment.month - 1];
135
+ if (month === undefined) return iso;
136
+ return `${moment.day} ${month} ${moment.year}`;
137
+ }
138
+
139
+ /**
140
+ * `3 Sep 2026, 9:30am`. An unparseable value is returned untouched rather than
141
+ * rendered as "Invalid Date": the durable text is at least true.
142
+ */
143
+ export function formatMomentV1(
144
+ iso: string,
145
+ options: UiMomentOptionsV1 = {},
146
+ ): string {
147
+ const moment = momentParts(iso, options);
148
+ if (!moment) return iso;
149
+ return `${formatDayV1(iso, options)}, ${formatTimeOfDayV1(iso, options)}`;
150
+ }
151
+
152
+ /**
153
+ * A moment relative to now while that is the more useful reading, and the
154
+ * absolute one after. A run log is scanned for "did it just fire?"; a run from
155
+ * last month is scanned for which day it was.
156
+ */
157
+ export function formatRelativeMomentV1(
158
+ iso: string,
159
+ options: UiMomentOptionsV1 & { now?: Date } = {},
160
+ ): string {
161
+ const parsed = Date.parse(iso);
162
+ if (!Number.isFinite(parsed)) return iso;
163
+ const now = (options.now ?? new Date()).getTime();
164
+ const elapsed = now - parsed;
165
+ if (elapsed < 0) return formatMomentV1(iso, options);
166
+ if (elapsed < 60_000) return "just now";
167
+ if (elapsed < 3_600_000) {
168
+ const minutes = Math.floor(elapsed / 60_000);
169
+ return `${minutes} min ago`;
170
+ }
171
+ if (elapsed < 86_400_000) {
172
+ const hours = Math.floor(elapsed / 3_600_000);
173
+ return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
174
+ }
175
+ return formatMomentV1(iso, options);
176
+ }