@frockbot/client-ui 0.3.11 → 0.3.12

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.11",
3
+ "version": "0.3.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,13 +12,13 @@
12
12
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "dependencies": {
15
- "@frockbot/client-core": "0.3.11",
15
+ "@frockbot/client-core": "0.3.12",
16
16
  "markdown-it": "15.0.1",
17
17
  "vue": "3.5.41"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/bun": "1.4.0",
21
- "typescript": "5.9.3",
21
+ "typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
22
22
  "vue-tsc": "3.3.10"
23
23
  },
24
24
  "publishConfig": {
@@ -0,0 +1,281 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A comet trail streaming off the right of a working Bot's avatar.
4
+ *
5
+ * It says two things without words: that the Bot is working, and how hard —
6
+ * the density of the stream is the rate work is actually arriving at, and a
7
+ * burst is a discrete thing having happened. It never names what happened; the
8
+ * point is that the transcript stays a conversation.
9
+ *
10
+ * The caller owns the meaning. This takes a `rate` in particles a second and a
11
+ * log of `bursts`, and draws them. What maps a Turn's chunks, tool calls and
12
+ * sends onto those numbers lives with the Turn, in the shell's
13
+ * `activity-trail.ts`.
14
+ *
15
+ * Bursts arrive as an append-only log rather than an event, because a Vue
16
+ * prop is a value: the component remembers the last sequence number it fired
17
+ * and fires everything newer, so a re-render never replays a burst and a
18
+ * burst never goes missing between frames.
19
+ */
20
+ import { onBeforeUnmount, onMounted, ref, watch } from "vue";
21
+ import {
22
+ ACTIVITY_TRAIL_ACCENT_V1,
23
+ ACTIVITY_TRAIL_PALE_V1,
24
+ activityTrailColourV1,
25
+ activityTrailRadiusV1,
26
+ advanceActivityTrailFieldV1,
27
+ burstActivityTrailFieldV1,
28
+ createActivityTrailFieldV1,
29
+ parseActivityTrailColourV1,
30
+ type ActivityTrailBurstEventV1,
31
+ type ActivityTrailGeometryV1,
32
+ } from "./activity-trail-field.js";
33
+
34
+ const props = withDefaults(
35
+ defineProps<{
36
+ /** Steady particles a second. Zero drains the field and stops. */
37
+ rate?: number;
38
+ /** Append-only; everything past the last fired `seq` is fired. */
39
+ bursts?: readonly ActivityTrailBurstEventV1[];
40
+ /**
41
+ * What the trail is saying — `running`, `waiting`, or `ended`. Drawn only
42
+ * as a data attribute, for specs and for anyone styling around it.
43
+ */
44
+ state?: string;
45
+ /** Where particles are born, measured from the canvas's left edge. */
46
+ originX?: number;
47
+ }>(),
48
+ { rate: 0, bursts: () => [], state: "running", originX: 6 },
49
+ );
50
+
51
+ const canvas = ref<HTMLCanvasElement | null>(null);
52
+ const field = createActivityTrailFieldV1();
53
+ let frame = 0;
54
+ let lastFrameAt = 0;
55
+ let firedSeq = -1;
56
+ let palette = {
57
+ pale: ACTIVITY_TRAIL_PALE_V1 as readonly [number, number, number],
58
+ accent: ACTIVITY_TRAIL_ACCENT_V1 as readonly [number, number, number],
59
+ };
60
+
61
+ /**
62
+ * Whether the person asked for less motion. Read once at mount and watched, so
63
+ * flipping the setting stops a trail that is already running.
64
+ */
65
+ const stillness =
66
+ typeof window === "undefined" || window.matchMedia === undefined
67
+ ? null
68
+ : window.matchMedia("(prefers-reduced-motion: reduce)");
69
+ const still = ref(stillness?.matches === true);
70
+
71
+ function geometryOf(element: HTMLCanvasElement): ActivityTrailGeometryV1 {
72
+ const box = element.getBoundingClientRect();
73
+ return {
74
+ originX: props.originX,
75
+ centreY: box.height / 2,
76
+ width: box.width,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The canvas's backing store, matched to the device's pixels.
82
+ *
83
+ * A canvas sized only in CSS is drawn at one device pixel per CSS pixel and a
84
+ * two-pixel particle is a smear on a retina screen. The transform means every
85
+ * coordinate below stays in CSS pixels.
86
+ */
87
+ function resize(): void {
88
+ const element = canvas.value;
89
+ if (element === null) return;
90
+ const box = element.getBoundingClientRect();
91
+ const ratio = window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
92
+ const width = Math.max(1, Math.round(box.width * ratio));
93
+ const height = Math.max(1, Math.round(box.height * ratio));
94
+ if (element.width !== width) element.width = width;
95
+ if (element.height !== height) element.height = height;
96
+ const context = element.getContext("2d");
97
+ if (context !== null) context.setTransform(ratio, 0, 0, ratio, 0, 0);
98
+ }
99
+
100
+ /** The theme's own colours, where they resolve to something this can parse. */
101
+ function readPalette(): void {
102
+ const element = canvas.value;
103
+ if (element === null || typeof window === "undefined") return;
104
+ const computed = window.getComputedStyle(element);
105
+ const pale = parseActivityTrailColourV1(
106
+ computed.getPropertyValue("--frock-text"),
107
+ );
108
+ const accent = parseActivityTrailColourV1(
109
+ computed.getPropertyValue("--frock-action-primary"),
110
+ );
111
+ palette = {
112
+ pale: pale ?? ACTIVITY_TRAIL_PALE_V1,
113
+ accent: accent ?? ACTIVITY_TRAIL_ACCENT_V1,
114
+ };
115
+ }
116
+
117
+ function draw(element: HTMLCanvasElement): void {
118
+ const context = element.getContext("2d");
119
+ if (context === null) return;
120
+ const box = element.getBoundingClientRect();
121
+ context.clearRect(0, 0, box.width, box.height);
122
+ for (const particle of field.particles) {
123
+ context.fillStyle = activityTrailColourV1(particle, palette);
124
+ context.beginPath();
125
+ context.arc(
126
+ particle.x,
127
+ particle.y,
128
+ activityTrailRadiusV1(particle),
129
+ 0,
130
+ Math.PI * 2,
131
+ );
132
+ context.fill();
133
+ }
134
+ }
135
+
136
+ /** Bursts the caller has added since the last time this ran. */
137
+ function fireNewBursts(geometry: ActivityTrailGeometryV1): boolean {
138
+ let fired = false;
139
+ for (const burst of props.bursts) {
140
+ if (burst.seq <= firedSeq) continue;
141
+ firedSeq = burst.seq;
142
+ burstActivityTrailFieldV1(field, {
143
+ count: burst.count,
144
+ speed: burst.speed,
145
+ brightness: burst.brightness,
146
+ geometry,
147
+ random: Math.random,
148
+ });
149
+ fired = true;
150
+ }
151
+ return fired;
152
+ }
153
+
154
+ function tick(at: number): void {
155
+ frame = 0;
156
+ const element = canvas.value;
157
+ if (element === null) return;
158
+ const seconds = lastFrameAt === 0 ? 0 : (at - lastFrameAt) / 1000;
159
+ lastFrameAt = at;
160
+ resize();
161
+ const geometry = geometryOf(element);
162
+ fireNewBursts(geometry);
163
+ advanceActivityTrailFieldV1(field, {
164
+ seconds,
165
+ rate: props.rate,
166
+ geometry,
167
+ random: Math.random,
168
+ });
169
+ draw(element);
170
+ // The loop stops the moment there is nothing left to say: a settled Turn
171
+ // drains its particles and then costs no frames at all.
172
+ if (props.rate > 0 || field.particles.length > 0) {
173
+ frame = window.requestAnimationFrame(tick);
174
+ } else {
175
+ lastFrameAt = 0;
176
+ }
177
+ }
178
+
179
+ function start(): void {
180
+ if (still.value || frame !== 0 || typeof window === "undefined") return;
181
+ frame = window.requestAnimationFrame(tick);
182
+ }
183
+
184
+ function stop(): void {
185
+ if (frame !== 0) window.cancelAnimationFrame(frame);
186
+ frame = 0;
187
+ lastFrameAt = 0;
188
+ }
189
+
190
+ /**
191
+ * Reduced motion keeps the information and drops the movement: no loop, and
192
+ * one still puff drawn where a burst would have been, replacing the last. It
193
+ * is a mark that something happened, which is what the animation was for.
194
+ */
195
+ function puff(): void {
196
+ const element = canvas.value;
197
+ if (element === null) return;
198
+ resize();
199
+ const geometry = geometryOf(element);
200
+ field.particles = [];
201
+ const latest = props.bursts.at(-1);
202
+ burstActivityTrailFieldV1(field, {
203
+ count: 8,
204
+ speed: latest?.speed ?? 1,
205
+ brightness: latest?.brightness ?? 1,
206
+ geometry,
207
+ random: Math.random,
208
+ });
209
+ // Spread the puff along the row so it reads as a trail rather than a dot,
210
+ // without a frame ever running.
211
+ field.particles.forEach((particle, index) => {
212
+ particle.x = geometry.originX + index * 7;
213
+ particle.life = 1 - index * 0.08;
214
+ });
215
+ draw(element);
216
+ }
217
+
218
+ onMounted(() => {
219
+ readPalette();
220
+ if (still.value) {
221
+ firedSeq = props.bursts.at(-1)?.seq ?? -1;
222
+ puff();
223
+ } else {
224
+ start();
225
+ }
226
+ stillness?.addEventListener("change", onStillnessChange);
227
+ });
228
+
229
+ onBeforeUnmount(() => {
230
+ stop();
231
+ stillness?.removeEventListener("change", onStillnessChange);
232
+ });
233
+
234
+ function onStillnessChange(event: MediaQueryListEvent): void {
235
+ still.value = event.matches;
236
+ if (still.value) {
237
+ stop();
238
+ puff();
239
+ } else {
240
+ start();
241
+ }
242
+ }
243
+
244
+ watch(
245
+ () => [props.rate, props.bursts] as const,
246
+ () => {
247
+ if (still.value) {
248
+ const latest = props.bursts.at(-1)?.seq ?? -1;
249
+ if (latest > firedSeq) {
250
+ firedSeq = latest;
251
+ puff();
252
+ }
253
+ return;
254
+ }
255
+ start();
256
+ },
257
+ );
258
+ </script>
259
+
260
+ <template>
261
+ <span class="ui-activity-trail" :data-state="state" aria-hidden="true">
262
+ <canvas ref="canvas" />
263
+ </span>
264
+ </template>
265
+
266
+ <style scoped>
267
+ .ui-activity-trail {
268
+ position: relative;
269
+ display: block;
270
+ min-width: 0;
271
+ height: 100%;
272
+ flex: 1 1 auto;
273
+ pointer-events: none;
274
+ }
275
+
276
+ .ui-activity-trail canvas {
277
+ display: block;
278
+ width: 100%;
279
+ height: 100%;
280
+ }
281
+ </style>
@@ -38,6 +38,26 @@ onBeforeUnmount(() => restoreFocus?.focus());
38
38
  </script>
39
39
 
40
40
  <template>
41
+ <!--
42
+ The layer under the panel.
43
+
44
+ A surface with no scrim read as a rendering glitch: it covered the sidebar
45
+ and half the conversation, cut the composer's rounded pill clean in two,
46
+ and left the chat behind it fully lit, so nothing on screen said which of
47
+ the two was the live one. Dimming what the panel is over says it, and gives
48
+ the pointer the dismissal every other overlay in the product has.
49
+
50
+ It dims and nothing else: these surfaces are not modal. The workspace
51
+ behind one stays live — the composer takes a message while Plugins is
52
+ open, a Package page runs beside its own surface — so a layer that ate the
53
+ pointer would change what the app is, not just how it looks. Escape and
54
+ the panel's own Close button are the ways out, and they are enough.
55
+
56
+ It is a `v-if` rather than a transition for the same reason a fade would be
57
+ wrong: an element that outlives its panel is one nobody can see and
58
+ everybody's clicks land on.
59
+ -->
60
+ <div v-if="open" class="ui-sidebar-overlay__scrim" aria-hidden="true"></div>
41
61
  <Transition name="ui-surface">
42
62
  <aside
43
63
  v-if="open"
@@ -64,6 +84,15 @@ onBeforeUnmount(() => restoreFocus?.focus());
64
84
  </template>
65
85
 
66
86
  <style scoped>
87
+ .ui-sidebar-overlay__scrim {
88
+ position: absolute;
89
+ z-index: var(--frock-layer-surface);
90
+ inset: 0;
91
+ background: var(--frock-overlay-tint);
92
+ /* Decoration, not a control: the workspace underneath stays usable. */
93
+ pointer-events: none;
94
+ }
95
+
67
96
  .ui-sidebar-overlay {
68
97
  position: absolute;
69
98
  z-index: var(--frock-layer-surface);
@@ -0,0 +1,231 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ACTIVITY_TRAIL_ACCENT_V1,
4
+ ACTIVITY_TRAIL_MAX_PARTICLES_V1,
5
+ ACTIVITY_TRAIL_PALE_V1,
6
+ activityTrailColourV1,
7
+ activityTrailRadiusV1,
8
+ advanceActivityTrailFieldV1,
9
+ burstActivityTrailFieldV1,
10
+ createActivityTrailFieldV1,
11
+ parseActivityTrailColourV1,
12
+ type ActivityTrailGeometryV1,
13
+ } from "./activity-trail-field.js";
14
+
15
+ const geometry: ActivityTrailGeometryV1 = {
16
+ originX: 6,
17
+ centreY: 22,
18
+ width: 200,
19
+ };
20
+
21
+ /** Every random draw lands in the middle of its range. */
22
+ const middling = () => 0.5;
23
+
24
+ describe("the comet trail's particle field", () => {
25
+ test("particles are born at the avatar's edge and flow right", () => {
26
+ const field = createActivityTrailFieldV1();
27
+ burstActivityTrailFieldV1(field, {
28
+ count: 3,
29
+ speed: 1,
30
+ brightness: 1,
31
+ geometry,
32
+ random: middling,
33
+ });
34
+ expect(field.particles).toHaveLength(3);
35
+ for (const particle of field.particles) {
36
+ expect(particle.x).toBe(geometry.originX);
37
+ expect(particle.vx).toBeGreaterThan(0);
38
+ }
39
+
40
+ advanceActivityTrailFieldV1(field, {
41
+ seconds: 0.1,
42
+ rate: 0,
43
+ geometry,
44
+ random: middling,
45
+ });
46
+ for (const particle of field.particles) {
47
+ expect(particle.x).toBeGreaterThan(geometry.originX);
48
+ }
49
+ });
50
+
51
+ test("a fractional rate still emits, one particle at a time", () => {
52
+ // Six a second at sixty frames a second is a particle every tenth of a
53
+ // second, not a particle every frame and not none at all.
54
+ const field = createActivityTrailFieldV1();
55
+ for (let frame = 0; frame < 5; frame += 1) {
56
+ advanceActivityTrailFieldV1(field, {
57
+ seconds: 1 / 60,
58
+ rate: 6,
59
+ geometry,
60
+ random: middling,
61
+ });
62
+ }
63
+ expect(field.particles).toHaveLength(0);
64
+ for (let frame = 0; frame < 7; frame += 1) {
65
+ advanceActivityTrailFieldV1(field, {
66
+ seconds: 1 / 60,
67
+ rate: 6,
68
+ geometry,
69
+ random: middling,
70
+ });
71
+ }
72
+ expect(field.particles).toHaveLength(1);
73
+ });
74
+
75
+ test("a rate of zero drains the field rather than freezing it", () => {
76
+ const field = createActivityTrailFieldV1();
77
+ burstActivityTrailFieldV1(field, {
78
+ count: 10,
79
+ speed: 1,
80
+ brightness: 1,
81
+ geometry,
82
+ random: middling,
83
+ });
84
+ for (let frame = 0; frame < 200; frame += 1) {
85
+ advanceActivityTrailFieldV1(field, {
86
+ seconds: 1 / 60,
87
+ rate: 0,
88
+ geometry,
89
+ random: middling,
90
+ });
91
+ }
92
+ expect(field.particles).toHaveLength(0);
93
+ });
94
+
95
+ test("particles are purged before they can be drawn dead", () => {
96
+ const field = createActivityTrailFieldV1();
97
+ burstActivityTrailFieldV1(field, {
98
+ count: 4,
99
+ speed: 1,
100
+ brightness: 1,
101
+ geometry,
102
+ random: middling,
103
+ });
104
+ advanceActivityTrailFieldV1(field, {
105
+ seconds: 0.1,
106
+ rate: 0,
107
+ geometry,
108
+ random: middling,
109
+ });
110
+ for (const particle of field.particles) {
111
+ expect(particle.life).toBeGreaterThan(0);
112
+ expect(particle.x).toBeLessThanOrEqual(geometry.width + particle.radius);
113
+ }
114
+ });
115
+
116
+ test("a particle past the right edge is gone", () => {
117
+ const field = createActivityTrailFieldV1();
118
+ burstActivityTrailFieldV1(field, {
119
+ count: 1,
120
+ speed: 1,
121
+ brightness: 1,
122
+ geometry,
123
+ random: middling,
124
+ });
125
+ const narrow = { ...geometry, width: 8 };
126
+ advanceActivityTrailFieldV1(field, {
127
+ seconds: 0.1,
128
+ rate: 0,
129
+ geometry: narrow,
130
+ random: middling,
131
+ });
132
+ expect(field.particles).toHaveLength(0);
133
+ });
134
+
135
+ test("the field never grows without bound", () => {
136
+ const field = createActivityTrailFieldV1();
137
+ burstActivityTrailFieldV1(field, {
138
+ count: 5000,
139
+ speed: 1,
140
+ brightness: 1,
141
+ geometry,
142
+ random: middling,
143
+ });
144
+ expect(field.particles).toHaveLength(ACTIVITY_TRAIL_MAX_PARTICLES_V1);
145
+ });
146
+
147
+ test("a background tab's enormous frame does not teleport the field", () => {
148
+ const field = createActivityTrailFieldV1();
149
+ advanceActivityTrailFieldV1(field, {
150
+ seconds: 600,
151
+ rate: 40,
152
+ geometry,
153
+ random: middling,
154
+ });
155
+ expect(field.particles.length).toBeLessThanOrEqual(
156
+ ACTIVITY_TRAIL_MAX_PARTICLES_V1,
157
+ );
158
+ for (const particle of field.particles) {
159
+ expect(particle.x).toBeLessThanOrEqual(geometry.width + particle.radius);
160
+ }
161
+ });
162
+
163
+ test("the drawn radius is never negative", () => {
164
+ // `arc` throws on a negative radius, and life crosses zero on the frame
165
+ // before the purge sees it.
166
+ expect(
167
+ activityTrailRadiusV1({
168
+ x: 0,
169
+ y: 0,
170
+ vx: 0,
171
+ vy: 0,
172
+ life: -3,
173
+ decay: 1,
174
+ radius: 2,
175
+ tone: 0,
176
+ brightness: 1,
177
+ }),
178
+ ).toBeGreaterThan(0);
179
+ });
180
+
181
+ test("colour blends between the app's text colour and its accent", () => {
182
+ const particle = {
183
+ x: 0,
184
+ y: 0,
185
+ vx: 0,
186
+ vy: 0,
187
+ life: 1,
188
+ decay: 1,
189
+ radius: 1,
190
+ tone: 0.5,
191
+ brightness: 1,
192
+ };
193
+ const blended = activityTrailColourV1(particle);
194
+ expect(blended).toMatch(/^rgba\(\d+,\d+,\d+,[\d.]+\)$/);
195
+
196
+ // Fully pale and fully accent are the ends the blend runs between.
197
+ const flat = { pale: [0, 0, 0], accent: [0, 0, 0] } as const;
198
+ expect(activityTrailColourV1(particle, flat)).toContain("rgba(0,0,0,");
199
+
200
+ // A brighter burst is more opaque than the steady stream.
201
+ const alphaOf = (value: string) =>
202
+ Number(value.split(",")[3]?.slice(0, -1));
203
+ expect(
204
+ alphaOf(activityTrailColourV1({ ...particle, brightness: 1.9 })),
205
+ ).toBeGreaterThan(alphaOf(blended));
206
+
207
+ // …and never past opaque.
208
+ expect(
209
+ alphaOf(activityTrailColourV1({ ...particle, brightness: 40 })),
210
+ ).toBe(1);
211
+ });
212
+
213
+ test("theme colours are read where they parse, and fall back where they do not", () => {
214
+ expect(parseActivityTrailColourV1("#f4f2f6")).toEqual([
215
+ ...ACTIVITY_TRAIL_PALE_V1,
216
+ ]);
217
+ expect(parseActivityTrailColourV1(" #EC386B ")).toEqual([
218
+ ...ACTIVITY_TRAIL_ACCENT_V1,
219
+ ]);
220
+ expect(parseActivityTrailColourV1("rgb(236, 56, 107)")).toEqual([
221
+ ...ACTIVITY_TRAIL_ACCENT_V1,
222
+ ]);
223
+ expect(parseActivityTrailColourV1("rgba(236 56 107 / 40%)")).toEqual([
224
+ ...ACTIVITY_TRAIL_ACCENT_V1,
225
+ ]);
226
+ expect(
227
+ parseActivityTrailColourV1("color-mix(in oklab, red, blue)"),
228
+ ).toBeUndefined();
229
+ expect(parseActivityTrailColourV1("")).toBeUndefined();
230
+ });
231
+ });
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The particle field behind `UiActivityTrail`.
3
+ *
4
+ * The component owns the canvas, the frame clock and the device pixel ratio;
5
+ * this owns the particles — where they are born, how they move, when they stop
6
+ * existing. Kept separate because the interesting failures are arithmetic
7
+ * ones: a radius that goes negative and throws inside `arc`, a field that
8
+ * grows without bound because nothing purges it, a stream that keeps emitting
9
+ * after the Turn ended. All three are testable without a browser.
10
+ *
11
+ * Randomness is injected so a test can pin every particle it spawns.
12
+ */
13
+
14
+ /** How fast the steady stream flows to the right, in CSS pixels a second. */
15
+ export const ACTIVITY_TRAIL_SPEED_V1 = 60;
16
+
17
+ /** The wobble's amplitude, in pixels a second at full swing. */
18
+ export const ACTIVITY_TRAIL_WOBBLE_V1 = 6;
19
+
20
+ /** Cycles of wobble per pixel travelled. */
21
+ export const ACTIVITY_TRAIL_WOBBLE_FREQUENCY_V1 = 0.08;
22
+
23
+ /**
24
+ * The most particles the field will hold. A tab left in the background can
25
+ * hand back a huge frame delta, and the spawn loop must not try to make a
26
+ * thousand particles out of it.
27
+ */
28
+ export const ACTIVITY_TRAIL_MAX_PARTICLES_V1 = 240;
29
+
30
+ /** The app's text colour and accent, as the trail blends between them. */
31
+ export const ACTIVITY_TRAIL_PALE_V1 = [244, 242, 246] as const;
32
+ export const ACTIVITY_TRAIL_ACCENT_V1 = [236, 56, 107] as const;
33
+
34
+ /**
35
+ * How pink the trail is, `0 … 1`. The prototype settled on mostly pink: the
36
+ * stream reads as the app's own accent rather than as dust.
37
+ */
38
+ export const ACTIVITY_TRAIL_HUE_V1 = 0.7;
39
+
40
+ export interface ActivityTrailParticleV1 {
41
+ x: number;
42
+ y: number;
43
+ /** Rightward speed, in pixels a second. */
44
+ vx: number;
45
+ /** Vertical drift, before the wobble. */
46
+ vy: number;
47
+ /** `1` at birth, down to `0`. */
48
+ life: number;
49
+ /** Life lost a second. */
50
+ decay: number;
51
+ /** Radius at birth. The drawn radius shrinks with life. */
52
+ radius: number;
53
+ /** `0 … 1`, this particle's place in the pale-to-accent blend. */
54
+ tone: number;
55
+ /** Opacity multiplier. A burst's particles are brighter. */
56
+ brightness: number;
57
+ }
58
+
59
+ /**
60
+ * One shot of particles as `UiActivityTrail` takes it: a burst, plus the
61
+ * sequence number that makes it unique.
62
+ *
63
+ * The component is handed an append-only log rather than an event, because a
64
+ * Vue prop is a value. It remembers the last `seq` it fired and fires
65
+ * everything newer, so a re-render never replays a burst and a burst never
66
+ * goes missing between two frames.
67
+ */
68
+ export interface ActivityTrailBurstEventV1 {
69
+ seq: number;
70
+ count: number;
71
+ speed: number;
72
+ brightness: number;
73
+ }
74
+
75
+ export interface ActivityTrailFieldV1 {
76
+ particles: ActivityTrailParticleV1[];
77
+ /**
78
+ * Fractional particles owed by the steady stream. A rate of six a second at
79
+ * sixty frames a second spawns one particle every tenth of a second rather
80
+ * than none, ever.
81
+ */
82
+ carry: number;
83
+ }
84
+
85
+ /** A source of `0 … 1` numbers. `Math.random` in the component. */
86
+ export type ActivityTrailRandomV1 = () => number;
87
+
88
+ export interface ActivityTrailGeometryV1 {
89
+ /** Where particles are born: the avatar's right edge. */
90
+ originX: number;
91
+ /** The row's vertical middle. */
92
+ centreY: number;
93
+ /** The canvas width, in CSS pixels. Particles past it are purged. */
94
+ width: number;
95
+ }
96
+
97
+ export function createActivityTrailFieldV1(): ActivityTrailFieldV1 {
98
+ return { particles: [], carry: 0 };
99
+ }
100
+
101
+ function between(random: ActivityTrailRandomV1, low: number, high: number) {
102
+ return low + random() * (high - low);
103
+ }
104
+
105
+ /**
106
+ * One particle, born at the origin.
107
+ *
108
+ * `speed` and `brightness` are the burst's multipliers; the steady stream
109
+ * passes one for both.
110
+ */
111
+ export function spawnActivityTrailParticleV1(
112
+ field: ActivityTrailFieldV1,
113
+ input: {
114
+ geometry: ActivityTrailGeometryV1;
115
+ speed: number;
116
+ brightness: number;
117
+ random: ActivityTrailRandomV1;
118
+ },
119
+ ): void {
120
+ if (field.particles.length >= ACTIVITY_TRAIL_MAX_PARTICLES_V1) return;
121
+ const { random } = input;
122
+ field.particles.push({
123
+ x: input.geometry.originX,
124
+ y: input.geometry.centreY + between(random, -3, 3),
125
+ vx: ACTIVITY_TRAIL_SPEED_V1 * input.speed * between(random, 0.7, 1.3),
126
+ vy: between(random, -8, 8),
127
+ life: 1,
128
+ decay: between(random, 0.8, 1.3),
129
+ radius: between(random, 0.8, 2),
130
+ tone: random(),
131
+ brightness: input.brightness,
132
+ });
133
+ }
134
+
135
+ /** A burst: `count` particles at once, all sharing its speed and brightness. */
136
+ export function burstActivityTrailFieldV1(
137
+ field: ActivityTrailFieldV1,
138
+ input: {
139
+ count: number;
140
+ speed: number;
141
+ brightness: number;
142
+ geometry: ActivityTrailGeometryV1;
143
+ random: ActivityTrailRandomV1;
144
+ },
145
+ ): void {
146
+ for (let index = 0; index < input.count; index += 1) {
147
+ spawnActivityTrailParticleV1(field, {
148
+ geometry: input.geometry,
149
+ speed: input.speed,
150
+ brightness: input.brightness,
151
+ random: input.random,
152
+ });
153
+ }
154
+ }
155
+
156
+ /**
157
+ * The field, one frame on.
158
+ *
159
+ * Spawning comes first so a burst fired this frame is visible in it, then
160
+ * every particle moves, then the dead and the escaped are purged — before the
161
+ * caller draws, so a frame never renders a particle with no life left in it.
162
+ * `seconds` is clamped: a tab that was in the background hands back a delta of
163
+ * minutes, and teleporting the whole field off the right edge is a worse
164
+ * answer than one slow frame.
165
+ */
166
+ export function advanceActivityTrailFieldV1(
167
+ field: ActivityTrailFieldV1,
168
+ input: {
169
+ seconds: number;
170
+ /** Steady particles a second. Zero once the Turn has ended. */
171
+ rate: number;
172
+ geometry: ActivityTrailGeometryV1;
173
+ random: ActivityTrailRandomV1;
174
+ },
175
+ ): void {
176
+ const seconds = Math.min(0.1, Math.max(0, input.seconds));
177
+
178
+ field.carry += seconds * Math.max(0, input.rate);
179
+ while (field.carry >= 1) {
180
+ field.carry -= 1;
181
+ spawnActivityTrailParticleV1(field, {
182
+ geometry: input.geometry,
183
+ speed: 1,
184
+ brightness: 1,
185
+ random: input.random,
186
+ });
187
+ }
188
+
189
+ for (const particle of field.particles) {
190
+ particle.x += particle.vx * seconds;
191
+ particle.y +=
192
+ particle.vy * seconds +
193
+ Math.sin(particle.x * ACTIVITY_TRAIL_WOBBLE_FREQUENCY_V1) *
194
+ ACTIVITY_TRAIL_WOBBLE_V1 *
195
+ seconds;
196
+ particle.life -= particle.decay * seconds;
197
+ }
198
+
199
+ field.particles = field.particles.filter(
200
+ (particle) =>
201
+ particle.life > 0 && particle.x <= input.geometry.width + particle.radius,
202
+ );
203
+ }
204
+
205
+ /** The radius to draw a particle at. Never negative, so `arc` never throws. */
206
+ export function activityTrailRadiusV1(
207
+ particle: ActivityTrailParticleV1,
208
+ ): number {
209
+ return Math.max(0.05, particle.radius * Math.max(0, particle.life));
210
+ }
211
+
212
+ /**
213
+ * A particle's colour, blended between the app's text colour and its accent.
214
+ *
215
+ * `pale` and `accent` come from the theme's own custom properties where the
216
+ * component can read them, and fall back to the tokens' values otherwise.
217
+ */
218
+ export function activityTrailColourV1(
219
+ particle: ActivityTrailParticleV1,
220
+ palette: {
221
+ pale: readonly [number, number, number];
222
+ accent: readonly [number, number, number];
223
+ } = { pale: ACTIVITY_TRAIL_PALE_V1, accent: ACTIVITY_TRAIL_ACCENT_V1 },
224
+ ): string {
225
+ const pinkness = Math.min(
226
+ 1,
227
+ Math.max(0, ACTIVITY_TRAIL_HUE_V1 * 0.9 + (particle.tone - 0.5) * 0.4),
228
+ );
229
+ const channel = (index: 0 | 1 | 2) =>
230
+ Math.round(
231
+ palette.pale[index] +
232
+ (palette.accent[index] - palette.pale[index]) * pinkness,
233
+ );
234
+ const alpha = Math.min(
235
+ 1,
236
+ Math.max(0, particle.life) * 0.9 * particle.brightness,
237
+ );
238
+ return `rgba(${channel(0)},${channel(1)},${channel(2)},${alpha})`;
239
+ }
240
+
241
+ /**
242
+ * A CSS colour as `[r, g, b]`, or `undefined` when it is not a form this
243
+ * understands. Only the two shapes a theme custom property actually resolves
244
+ * to — `#rrggbb` and `rgb(…)` — because the fallback is the token's own value
245
+ * and a wrong guess would be worse than it.
246
+ */
247
+ export function parseActivityTrailColourV1(
248
+ value: string,
249
+ ): [number, number, number] | undefined {
250
+ const text = value.trim();
251
+ const hex = /^#([\da-f]{6})$/i.exec(text);
252
+ if (hex?.[1] !== undefined) {
253
+ const digits = hex[1];
254
+ return [
255
+ Number.parseInt(digits.slice(0, 2), 16),
256
+ Number.parseInt(digits.slice(2, 4), 16),
257
+ Number.parseInt(digits.slice(4, 6), 16),
258
+ ];
259
+ }
260
+ const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(text);
261
+ if (rgb?.[1] !== undefined && rgb[2] !== undefined && rgb[3] !== undefined) {
262
+ return [
263
+ Math.round(Number(rgb[1])),
264
+ Math.round(Number(rgb[2])),
265
+ Math.round(Number(rgb[3])),
266
+ ];
267
+ }
268
+ return undefined;
269
+ }
package/src/index.ts CHANGED
@@ -6,7 +6,12 @@ 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
+ export { default as UiActivityTrail } from "./UiActivityTrail.vue";
10
+ export {
11
+ ACTIVITY_TRAIL_MAX_PARTICLES_V1,
12
+ ACTIVITY_TRAIL_SPEED_V1,
13
+ type ActivityTrailBurstEventV1,
14
+ } from "./activity-trail-field.js";
10
15
  export { default as UiButton } from "./UiButton.vue";
11
16
  export { default as UiField } from "./UiField.vue";
12
17
  export { default as UiIcon } from "./UiIcon.vue";
@@ -1,156 +0,0 @@
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>