@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 +3 -3
- package/src/UiActivityTrail.vue +281 -0
- package/src/UiSidebarOverlay.vue +29 -0
- package/src/activity-trail-field.test.ts +231 -0
- package/src/activity-trail-field.ts +269 -0
- package/src/index.ts +6 -1
- package/src/UiActivityRing.vue +0 -156
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/client-ui",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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": "
|
|
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>
|
package/src/UiSidebarOverlay.vue
CHANGED
|
@@ -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
|
|
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";
|
package/src/UiActivityRing.vue
DELETED
|
@@ -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>
|