@frockbot/plugin-flock 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/plugin-flock",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,20 +27,20 @@
27
27
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
28
28
  },
29
29
  "dependencies": {
30
- "@frockbot/client-core": "0.3.11",
31
- "@frockbot/client-ui": "0.3.11",
32
- "@frockbot/configuration-core": "0.3.11",
33
- "@frockbot/kernel-contracts": "0.3.11",
34
- "@frockbot/plugin-shell": "0.3.11",
30
+ "@frockbot/client-core": "0.3.12",
31
+ "@frockbot/client-ui": "0.3.12",
32
+ "@frockbot/configuration-core": "0.3.12",
33
+ "@frockbot/kernel-contracts": "0.3.12",
34
+ "@frockbot/plugin-shell": "0.3.12",
35
35
  "cordis": "4.0.0-rc.8",
36
36
  "vue": "3.5.41"
37
37
  },
38
38
  "devDependencies": {
39
- "@frockbot/plugin-testkit": "0.3.11",
39
+ "@frockbot/plugin-testkit": "0.3.12",
40
40
  "@types/bun": "1.4.0",
41
41
  "@vitejs/plugin-vue": "6.0.8",
42
42
  "css-tree": "2.3.1",
43
- "typescript": "5.9.3",
43
+ "typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
44
44
  "vite": "8.2.2",
45
45
  "vue-tsc": "3.3.10"
46
46
  },
@@ -2,13 +2,18 @@
2
2
  /**
3
3
  * What a Bot looks like. The Flock's generated sheep recipe is the avatar.
4
4
  *
5
- * `working` draws the same activity ring the thread puts on the Bot it is
6
- * talking to, so a person reading one conversation can see another Bot still
7
- * going on a list row and on a pinned tile alike. The sidebar knows only that
8
- * a Turn is running; the step count is the open conversation's to show, so a
9
- * ring out here pulses and does not tick.
5
+ * `working` draws the same language the thread uses for the Bot it is talking
6
+ * to particles drifting off the avatar's right so a person reading one
7
+ * conversation can see another Bot still going, on a list row and on a pinned
8
+ * tile alike.
9
+ *
10
+ * It is three CSS dots rather than the thread's canvas. A sidebar can hold
11
+ * twenty rows, and twenty `requestAnimationFrame` loops running full particle
12
+ * fields to say one bit each — "this Bot is busy" — is a cost the list should
13
+ * not pay. Out here that is genuinely all there is to say: the sidebar knows a
14
+ * Turn is running and nothing about its pace, which is the open
15
+ * conversation's to show.
10
16
  */
11
- import { UiActivityRing } from "@frockbot/client-ui";
12
17
  import SheepAvatar from "./SheepAvatar.vue";
13
18
  import type { SheepRecipeV1 } from "../shared.js";
14
19
 
@@ -26,8 +31,12 @@ withDefaults(
26
31
  <template>
27
32
  <span class="flock-avatar-slot">
28
33
  <SheepAvatar :sheep="sheep" :label="label" :size="size" />
29
- <Transition name="flock-avatar-ring">
30
- <UiActivityRing v-if="working" :progress="0" />
34
+ <Transition name="flock-avatar-drift">
35
+ <span v-if="working" class="flock-avatar-drift" aria-hidden="true">
36
+ <i />
37
+ <i />
38
+ <i />
39
+ </span>
31
40
  </Transition>
32
41
  </span>
33
42
  </template>
@@ -40,13 +49,86 @@ withDefaults(
40
49
  place-items: center;
41
50
  }
42
51
 
43
- .flock-avatar-ring-enter-active,
44
- .flock-avatar-ring-leave-active {
52
+ /*
53
+ * Clear of the avatar rather than over it, in the strip of row to its right.
54
+ * `pointer-events` keeps the whole thing out of the way of the row's click,
55
+ * and it lives inside the gap the row already leaves between the avatar and the
56
+ * name, so it never runs under the text.
57
+ */
58
+ .flock-avatar-drift {
59
+ position: absolute;
60
+ z-index: 1;
61
+ top: 50%;
62
+ left: 100%;
63
+ width: 12px;
64
+ height: 6px;
65
+ margin-left: 2px;
66
+ pointer-events: none;
67
+ transform: translateY(-50%);
68
+ }
69
+
70
+ .flock-avatar-drift i {
71
+ position: absolute;
72
+ top: 2px;
73
+ width: 2px;
74
+ height: 2px;
75
+ border-radius: 50%;
76
+ background: var(--frock-action-primary);
77
+ animation: frock-drift 2400ms linear infinite;
78
+ }
79
+
80
+ .flock-avatar-drift i:nth-child(2) {
81
+ animation-delay: 800ms;
82
+ }
83
+
84
+ .flock-avatar-drift i:nth-child(3) {
85
+ animation-delay: 1600ms;
86
+ }
87
+
88
+ /*
89
+ * The same journey the thread's particles make — born at the avatar's edge,
90
+ * out to the right, fading as they go — at a size a list can afford.
91
+ */
92
+ @keyframes frock-drift {
93
+ 0% {
94
+ opacity: 0;
95
+ transform: translate(0, 0);
96
+ }
97
+
98
+ 20% {
99
+ opacity: 0.9;
100
+ }
101
+
102
+ 100% {
103
+ opacity: 0;
104
+ transform: translate(10px, -2px);
105
+ }
106
+ }
107
+
108
+ .flock-avatar-drift-enter-active,
109
+ .flock-avatar-drift-leave-active {
45
110
  transition: opacity 420ms ease-out;
46
111
  }
47
112
 
48
- .flock-avatar-ring-enter-from,
49
- .flock-avatar-ring-leave-to {
113
+ .flock-avatar-drift-enter-from,
114
+ .flock-avatar-drift-leave-to {
50
115
  opacity: 0;
51
116
  }
117
+
118
+ /*
119
+ * Reduced motion keeps the fact and drops the movement: one still dot beside
120
+ * the avatar, which is the whole of what this was saying.
121
+ */
122
+ @media (prefers-reduced-motion: reduce) {
123
+ .flock-avatar-drift i {
124
+ animation: none;
125
+ opacity: 0.9;
126
+ transform: translate(4px, 0);
127
+ }
128
+
129
+ .flock-avatar-drift i:nth-child(2),
130
+ .flock-avatar-drift i:nth-child(3) {
131
+ display: none;
132
+ }
133
+ }
52
134
  </style>
@@ -109,17 +109,39 @@ onMounted(() => void flock.value.load());
109
109
 
110
110
  <template>
111
111
  <div class="flock-list-actions">
112
- <button type="button" class="flock-manage" @click="flock.toggleArchived">
113
- {{ flock.showArchived ? "Hide archived" : "Manage" }}
112
+ <button
113
+ type="button"
114
+ class="flock-manage"
115
+ :aria-pressed="flock.showArchived"
116
+ @click="flock.toggleArchived"
117
+ >
118
+ {{ flock.showArchived ? "Done" : "Manage" }}
114
119
  </button>
115
120
  </div>
121
+ <!--
122
+ An unreadable list is not an empty one, and it is not a loading one either.
123
+ Offering to add a first Bot to a User whose Bots simply did not load is the
124
+ worst thing this column can say; leaving the skeleton up forever is the
125
+ second worst, because it says the read is still coming when it has already
126
+ failed. So the failure takes the slot first, and offers the read again.
127
+ -->
128
+ <p
129
+ v-if="flock.error && !flock.directory.bots.length"
130
+ class="flock-error"
131
+ role="alert"
132
+ >
133
+ {{ flock.error }}
134
+ <button type="button" class="flock-retry" @click="flock.load()">
135
+ Retry
136
+ </button>
137
+ </p>
116
138
  <!--
117
139
  The skeleton is for a list nobody has yet, not for every request: the first
118
140
  paint happens before `load()` is even called, and a reload after creating a
119
141
  Bot must keep the list already on screen rather than blanking it. "No Bots
120
142
  yet." is a fact about the account, so it waits for an answer.
121
143
  -->
122
- <div v-if="!flock.loaded" class="flock-skeleton" aria-busy="true">
144
+ <div v-else-if="!flock.loaded" class="flock-skeleton" aria-busy="true">
123
145
  <span class="flock-skeleton-label">Loading your flock…</span>
124
146
  <div v-for="row in 3" :key="row" class="flock-skeleton-row">
125
147
  <UiSkeleton shape="circle" />
@@ -326,5 +348,13 @@ onMounted(() => void flock.value.load());
326
348
  </TransitionGroup>
327
349
  </div>
328
350
  </template>
329
- <p v-if="flock.error" class="flock-error" role="alert">{{ flock.error }}</p>
351
+ <!-- The same failure over a list that still has rows: a banner, not a
352
+ replacement, because what is on screen is still the last thing known. -->
353
+ <p
354
+ v-if="flock.error && flock.directory.bots.length"
355
+ class="flock-error"
356
+ role="alert"
357
+ >
358
+ {{ flock.error }}
359
+ </p>
330
360
  </template>
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ claimNotificationDeliveryV1,
4
+ deliveredNotificationKeyV1,
5
+ DELIVERED_NOTIFICATIONS_KEY,
6
+ DELIVERED_NOTIFICATIONS_LIMIT,
7
+ releaseNotificationDeliveryV1,
8
+ } from "./delivered-notifications.js";
9
+
10
+ /** The half of `localStorage` the ledger uses, shared as a browser shares it. */
11
+ function storage(initial?: string): Pick<Storage, "getItem" | "setItem"> {
12
+ const values = new Map<string, string>(
13
+ initial === undefined ? [] : [[DELIVERED_NOTIFICATIONS_KEY, initial]],
14
+ );
15
+ return {
16
+ getItem: (key) => values.get(key) ?? null,
17
+ setItem: (key, value) => {
18
+ values.set(key, value);
19
+ },
20
+ };
21
+ }
22
+
23
+ describe("the delivered-notification ledger", () => {
24
+ test("the first claim wins and every later one loses", () => {
25
+ const shared = storage();
26
+ const key = deliveredNotificationKeyV1("beta", "run-1");
27
+ expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
28
+ expect(claimNotificationDeliveryV1(key, shared)).toBe(false);
29
+ // A second tab reads the same storage, so it loses too — which is the
30
+ // whole point: one notification per message, not one per tab.
31
+ expect(claimNotificationDeliveryV1(key, shared)).toBe(false);
32
+ // A different message is still news.
33
+ expect(
34
+ claimNotificationDeliveryV1(
35
+ deliveredNotificationKeyV1("beta", "run-2"),
36
+ shared,
37
+ ),
38
+ ).toBe(true);
39
+ });
40
+
41
+ test("a claim survives the reload that empties the page's own set", () => {
42
+ const values = new Map<string, string>();
43
+ const persistent = (): Pick<Storage, "getItem" | "setItem"> => ({
44
+ getItem: (key) => values.get(key) ?? null,
45
+ setItem: (key, value) => {
46
+ values.set(key, value);
47
+ },
48
+ });
49
+ const key = deliveredNotificationKeyV1("beta", "run-1");
50
+ expect(claimNotificationDeliveryV1(key, persistent())).toBe(true);
51
+ // A brand-new page, the same browser.
52
+ expect(claimNotificationDeliveryV1(key, persistent())).toBe(false);
53
+ });
54
+
55
+ test("a notification that could not be shown gives its claim back", () => {
56
+ const shared = storage();
57
+ const key = deliveredNotificationKeyV1("beta", "run-1");
58
+ expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
59
+ releaseNotificationDeliveryV1(key, shared);
60
+ expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
61
+ });
62
+
63
+ test("the ledger is bounded, oldest first", () => {
64
+ const shared = storage();
65
+ for (let index = 0; index <= DELIVERED_NOTIFICATIONS_LIMIT; index += 1) {
66
+ claimNotificationDeliveryV1(
67
+ deliveredNotificationKeyV1("beta", `run-${index}`),
68
+ shared,
69
+ );
70
+ }
71
+ // The oldest fell off; the newest is still remembered.
72
+ expect(
73
+ claimNotificationDeliveryV1(
74
+ deliveredNotificationKeyV1("beta", "run-0"),
75
+ shared,
76
+ ),
77
+ ).toBe(true);
78
+ expect(
79
+ claimNotificationDeliveryV1(
80
+ deliveredNotificationKeyV1(
81
+ "beta",
82
+ `run-${DELIVERED_NOTIFICATIONS_LIMIT}`,
83
+ ),
84
+ shared,
85
+ ),
86
+ ).toBe(false);
87
+ });
88
+
89
+ test("junk in storage is not a reason to go silent", () => {
90
+ for (const junk of ["", "{", "null", '{"not":"an array"}', "[1,2,3]"]) {
91
+ const key = deliveredNotificationKeyV1("beta", "run-1");
92
+ const shared = storage(junk);
93
+ expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
94
+ expect(claimNotificationDeliveryV1(key, shared)).toBe(false);
95
+ }
96
+ });
97
+
98
+ test("no storage at all still shows the notification", () => {
99
+ const key = deliveredNotificationKeyV1("beta", "run-1");
100
+ expect(claimNotificationDeliveryV1(key, undefined)).toBe(true);
101
+ expect(claimNotificationDeliveryV1(key, undefined)).toBe(true);
102
+ });
103
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Which notification intents this browser has already shown.
3
+ *
4
+ * "One notification per message" is a promise about the *person*, not about
5
+ * the page. The durable acknowledgement the Bot records is what closes an
6
+ * intent, but it lands after the notification is shown, and in that window a
7
+ * second tab polling the same fan-out — or the same tab after a reload —
8
+ * showed the identical intent again. A set on the page could not see either.
9
+ *
10
+ * `localStorage` can, because both tabs of one browser share it. It is a
11
+ * ledger of ids, never of content: an id already here is one this browser has
12
+ * spoken. It is bounded and oldest-first, so a long-lived session cannot grow
13
+ * it without limit; an id that falls off the end is one whose acknowledgement
14
+ * settled long ago.
15
+ */
16
+
17
+ export const DELIVERED_NOTIFICATIONS_KEY =
18
+ "frockbot.flock.delivered-notifications.v1";
19
+
20
+ /** How many ids one browser remembers. Comfortably past any in-flight burst. */
21
+ export const DELIVERED_NOTIFICATIONS_LIMIT = 200;
22
+
23
+ type WritableStorage = Pick<Storage, "getItem" | "setItem">;
24
+
25
+ function browserStorage(): Storage | undefined {
26
+ try {
27
+ return typeof localStorage === "undefined" ? undefined : localStorage;
28
+ } catch {
29
+ // A browser with storage denied still shows notifications; it only loses
30
+ // the cross-tab half of the promise.
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ /** The key one intent is remembered under. */
36
+ export function deliveredNotificationKeyV1(
37
+ botId: string,
38
+ notificationId: string,
39
+ ): string {
40
+ return `${botId}:${notificationId}`;
41
+ }
42
+
43
+ function parse(raw: string | null): string[] {
44
+ if (!raw) return [];
45
+ try {
46
+ const value: unknown = JSON.parse(raw);
47
+ return Array.isArray(value)
48
+ ? value.filter((entry): entry is string => typeof entry === "string")
49
+ : [];
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Claims the right to show the intent behind `key`, returning `false` when
57
+ * some other tab — or this one, before a reload — already has it.
58
+ *
59
+ * Check-and-write in one call, and the write happens *before* the notification
60
+ * is shown, because the gap between showing and recording is exactly where the
61
+ * duplicate got in.
62
+ */
63
+ export function claimNotificationDeliveryV1(
64
+ key: string,
65
+ storage: WritableStorage | undefined = browserStorage(),
66
+ ): boolean {
67
+ if (!storage) return true;
68
+ try {
69
+ const existing = parse(storage.getItem(DELIVERED_NOTIFICATIONS_KEY));
70
+ if (existing.includes(key)) return false;
71
+ const next = [...existing, key].slice(-DELIVERED_NOTIFICATIONS_LIMIT);
72
+ storage.setItem(DELIVERED_NOTIFICATIONS_KEY, JSON.stringify(next));
73
+ return true;
74
+ } catch {
75
+ // Storage that will not take the ledger must not silence the notification.
76
+ return true;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Gives a claim back, for the one case where the notification was never
82
+ * actually shown: no permission yet. Without this the intent would be
83
+ * remembered as spoken and stay silent after the User granted it.
84
+ */
85
+ export function releaseNotificationDeliveryV1(
86
+ key: string,
87
+ storage: WritableStorage | undefined = browserStorage(),
88
+ ): void {
89
+ if (!storage) return;
90
+ try {
91
+ const existing = parse(storage.getItem(DELIVERED_NOTIFICATIONS_KEY));
92
+ if (!existing.includes(key)) return;
93
+ storage.setItem(
94
+ DELIVERED_NOTIFICATIONS_KEY,
95
+ JSON.stringify(existing.filter((entry) => entry !== key)),
96
+ );
97
+ } catch {
98
+ // Nothing to undo that anybody can see.
99
+ }
100
+ }
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import type { ClientPluginContext } from "@frockbot/client-core";
3
3
  import type { FrockBotWebData } from "@frockbot/plugin-shell/shared";
4
- import { ref, type Ref } from "vue";
4
+ import { nextTick, ref, type Ref } from "vue";
5
5
  import { randomSheepRecipeV1 } from "../shared.js";
6
6
  import { flockClientPlugin } from "./index.js";
7
7
  import { pendingCreateKey, pendingSheepKey } from "./pending-create.js";
@@ -281,7 +281,8 @@ describe("Flock client reconciliation", () => {
281
281
 
282
282
  await state.value.load();
283
283
 
284
- expect(state.value.error).toBe("Directory changed");
284
+ // The sidebar says what failed, not what the deployment called it.
285
+ expect(state.value.error).toBe("Couldn't load your Bots.");
285
286
  expect(storage.has(pendingCreateKey("user-a"))).toBe(false);
286
287
  });
287
288
 
@@ -481,6 +482,7 @@ describe("Flock client reconciliation", () => {
481
482
  return Promise.reject(new Error(`unexpected request: ${path}`));
482
483
  });
483
484
  const selected: string[] = [];
485
+ const forgotten: (string | undefined)[] = [];
484
486
  state.value.bindShell(
485
487
  ref({
486
488
  activeBotId: "alpha",
@@ -488,6 +490,11 @@ describe("Flock client reconciliation", () => {
488
490
  selected.push(botId);
489
491
  return Promise.resolve();
490
492
  },
493
+ transcripts: {
494
+ rememberViewport: () => undefined,
495
+ viewportFor: () => undefined,
496
+ forget: (botId?: string) => forgotten.push(botId),
497
+ },
491
498
  }) as unknown as Ref<FrockBotWebData>,
492
499
  );
493
500
  state.value.openArchive("alpha");
@@ -496,6 +503,9 @@ describe("Flock client reconciliation", () => {
496
503
  expect(state.value.lifecycles.alpha).toBe("archived");
497
504
  expect(selected.at(-1)).toBe("beta");
498
505
  expect(new URL(location.href).searchParams.get("bot")).toBe("beta");
506
+ // The archived Bot's held transcript goes with it, so restoring it later
507
+ // reads from the Bot rather than redrawing what the cache still had.
508
+ expect(forgotten).toEqual(["alpha"]);
499
509
  });
500
510
 
501
511
  test("reconciles a lost sheep response and clears the exact pending command", async () => {
@@ -532,3 +542,88 @@ describe("Flock client reconciliation", () => {
532
542
  expect(storage.has(pendingSheepKey("user-a", "alpha"))).toBe(false);
533
543
  });
534
544
  });
545
+
546
+ describe("Flock sidebar rows follow the transcript", () => {
547
+ test("a settled Turn updates the row without waiting for the poll", async () => {
548
+ installStorage();
549
+ const reads: string[] = [];
550
+ let reply: string | undefined;
551
+ const state = mount((path) => {
552
+ if (path !== "/api/bots/unread")
553
+ return Promise.reject(new Error(`unexpected request: ${path}`));
554
+ reads.push(path);
555
+ return Promise.resolve({
556
+ schemaVersion: 1,
557
+ unread: [
558
+ {
559
+ schemaVersion: 1,
560
+ botId: "alpha",
561
+ count: 0,
562
+ capped: false,
563
+ unread: false,
564
+ manuallyUnread: false,
565
+ ...(reply === undefined
566
+ ? {}
567
+ : {
568
+ lastMessage: {
569
+ schemaVersion: 1,
570
+ text: reply,
571
+ at: "2026-08-31T00:00:01.000Z",
572
+ role: "assistant",
573
+ },
574
+ }),
575
+ },
576
+ ],
577
+ });
578
+ });
579
+ const shell = ref({
580
+ activeBotId: "alpha",
581
+ activeRunId: "run-1",
582
+ messages: [
583
+ {
584
+ id: "m1",
585
+ runId: "run-1",
586
+ role: "user",
587
+ text: "hello",
588
+ status: "completed",
589
+ },
590
+ ],
591
+ } as unknown as FrockBotWebData);
592
+ state.value.bindShell(shell as unknown as Ref<FrockBotWebData>);
593
+ // Binding a Shell is not itself a beat: nothing has settled, so nothing is
594
+ // re-read.
595
+ await nextTick();
596
+ expect(reads).toHaveLength(0);
597
+ expect(state.value.unread.alpha).toBeUndefined();
598
+
599
+ // The Turn settles: its reply is in the transcript, no run is in flight.
600
+ reply = "Ollama reply";
601
+ shell.value = {
602
+ ...shell.value,
603
+ activeRunId: undefined,
604
+ messages: [
605
+ ...shell.value.messages,
606
+ {
607
+ id: "m2",
608
+ runId: "run-1",
609
+ role: "assistant",
610
+ text: reply,
611
+ status: "completed",
612
+ },
613
+ ],
614
+ } as unknown as FrockBotWebData;
615
+ await nextTick();
616
+ // Draining the read the watcher started. A rendered frame does this on its
617
+ // own; the claim under test is that no 15-second poll was involved.
618
+ await Promise.resolve();
619
+ await Promise.resolve();
620
+ await Promise.resolve();
621
+
622
+ expect(reads).toHaveLength(1);
623
+ expect(state.value.unread.alpha?.lastMessage).toMatchObject({
624
+ text: "Ollama reply",
625
+ at: "2026-08-31T00:00:01.000Z",
626
+ role: "assistant",
627
+ });
628
+ });
629
+ });
Binary file
@@ -24,8 +24,10 @@ export interface FlockWebData {
24
24
  * Whether a directory read has ever completed.
25
25
  *
26
26
  * "No Bots yet." is a fact about the User's account, and it can only be
27
- * stated once the account has been read. Before that — the first paint, and
28
- * the reload after a Bot is created — the list is unknown, not empty.
27
+ * stated once the account has been read. Before that — the first paint, the
28
+ * reload after a Bot is created, and every read that failed — the list is
29
+ * unknown, not empty, and offering to create a first Bot to someone who
30
+ * already has several is the worst thing this column can say.
29
31
  */
30
32
  loaded: boolean;
31
33
  error?: string;
@@ -357,6 +357,24 @@
357
357
  color: var(--frock-danger-text);
358
358
  }
359
359
 
360
+ /* The way to try the read again, beside the sentence that says it failed. */
361
+ .flock-retry {
362
+ display: inline-block;
363
+ margin-top: 6px;
364
+ border: 1px solid var(--frock-border);
365
+ border-radius: var(--frock-radius-control);
366
+ background: transparent;
367
+ color: var(--frock-text);
368
+ padding: 2px 8px;
369
+ font: inherit;
370
+ font-size: var(--frock-text-xs);
371
+ cursor: pointer;
372
+ }
373
+
374
+ .flock-retry:hover {
375
+ background: var(--frock-fill-hover);
376
+ }
377
+
360
378
  /* Header identity */
361
379
 
362
380
  .flock-identity-button {