@frockbot/plugin-flock 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +8 -1
  2. package/assets/amber-glasses.webp +0 -0
  3. package/assets/background-bright-orange.webp +0 -0
  4. package/assets/background-canary-yellow.webp +0 -0
  5. package/assets/background-electric-blue.webp +0 -0
  6. package/assets/background-hot-pink.webp +0 -0
  7. package/assets/background-lime-green.webp +0 -0
  8. package/assets/background-vivid-purple.webp +0 -0
  9. package/assets/blue-sailor-cap.webp +0 -0
  10. package/assets/bronze-aviator-goggles.webp +0 -0
  11. package/assets/burgundy-beret.webp +0 -0
  12. package/assets/canary-party-hat.webp +0 -0
  13. package/assets/canary-rain-cap.webp +0 -0
  14. package/assets/canonical.webp +0 -0
  15. package/assets/chef-toque.webp +0 -0
  16. package/assets/cobalt-round-glasses.webp +0 -0
  17. package/assets/coral-bucket-hat.webp +0 -0
  18. package/assets/emerald-necktie.webp +0 -0
  19. package/assets/forest-ranger-fedora.webp +0 -0
  20. package/assets/gold-royal-crown.webp +0 -0
  21. package/assets/gold-sun-medallion.webp +0 -0
  22. package/assets/graduation-cap.webp +0 -0
  23. package/assets/green-square-glasses.webp +0 -0
  24. package/assets/layers.css +159 -0
  25. package/assets/leather-bell.webp +0 -0
  26. package/assets/lilac-flower-crown.webp +0 -0
  27. package/assets/manifest.json +769 -0
  28. package/assets/matte-black-top-hat.webp +0 -0
  29. package/assets/navy-wizard-hat.webp +0 -0
  30. package/assets/orange-hard-hat.webp +0 -0
  31. package/assets/plum-glasses.webp +0 -0
  32. package/assets/purple-scarf.webp +0 -0
  33. package/assets/purple-witch-hat.webp +0 -0
  34. package/assets/rainbow-propeller-cap.webp +0 -0
  35. package/assets/rainbow-visor.webp +0 -0
  36. package/assets/ranger-fedora-feather.webp +0 -0
  37. package/assets/red-polka-bowtie.webp +0 -0
  38. package/assets/retro-3d-glasses.webp +0 -0
  39. package/assets/rose-heart-sunglasses.webp +0 -0
  40. package/assets/royal-crown-rubies.webp +0 -0
  41. package/assets/sailor-cap-anchor.webp +0 -0
  42. package/assets/silver-space-helmet.webp +0 -0
  43. package/assets/straw-boater.webp +0 -0
  44. package/assets/teal-bowtie.webp +0 -0
  45. package/assets/teal-glasses.webp +0 -0
  46. package/assets/terracotta-beanie.webp +0 -0
  47. package/assets/tortoiseshell-readers.webp +0 -0
  48. package/assets/turquoise-bandana.webp +0 -0
  49. package/assets/violet-masquerade-mask.webp +0 -0
  50. package/assets/white-snow-goggles.webp +0 -0
  51. package/assets/white-tennis-visor.webp +0 -0
  52. package/assets/witch-hat-moon.webp +0 -0
  53. package/assets/yellow-star-glasses.webp +0 -0
  54. package/frockbot.json +38 -0
  55. package/package.json +47 -6
  56. package/src/agent.test.ts +401 -0
  57. package/src/agent.ts +571 -0
  58. package/src/assets.test.ts +31 -0
  59. package/src/backend.test.ts +205 -0
  60. package/src/backend.ts +283 -0
  61. package/src/bot.test.ts +190 -0
  62. package/src/bot.ts +255 -0
  63. package/src/client/BotAvatar.vue +20 -0
  64. package/src/client/FlockAvatar.vue +33 -0
  65. package/src/client/FlockAvatarEditor.vue +44 -0
  66. package/src/client/FlockCreateButton.vue +18 -0
  67. package/src/client/FlockIdentity.vue +31 -0
  68. package/src/client/FlockOverlay.vue +209 -0
  69. package/src/client/FlockSidebar.vue +254 -0
  70. package/src/client/SheepAvatar.vue +28 -0
  71. package/src/client/dialog-focus.test.ts +15 -0
  72. package/src/client/dialog-focus.ts +13 -0
  73. package/src/client/index.test.ts +502 -0
  74. package/src/client/index.ts +619 -0
  75. package/src/client/mobile-dialog-layout.test.ts +50 -0
  76. package/src/client/pending-create.test.ts +71 -0
  77. package/src/client/pending-create.ts +93 -0
  78. package/src/client/sidebar.test.ts +77 -0
  79. package/src/client/sidebar.ts +77 -0
  80. package/src/client/state.ts +53 -0
  81. package/src/client/styles.css +611 -0
  82. package/src/env.d.ts +9 -0
  83. package/src/index.ts +4 -0
  84. package/src/manifest.ts +3 -0
  85. package/src/package.test.ts +12 -0
  86. package/src/shared.test.ts +197 -0
  87. package/src/shared.ts +735 -0
  88. package/src/user.test.ts +455 -0
  89. package/src/user.ts +465 -0
  90. package/tsconfig.json +15 -0
  91. package/validate_readability.py +64 -0
  92. package/vite.config.ts +30 -0
@@ -0,0 +1,254 @@
1
+ <script setup lang="ts">
2
+ import { UiSkeleton } from "@frockbot/client-ui";
3
+ import { computed, inject, onMounted } from "vue";
4
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
5
+ import { flockWebDataKey } from "./state.js";
6
+ import { formatSidebarMessageTimeV1, groupSidebarBotsV1 } from "./sidebar.js";
7
+ import BotAvatar from "./BotAvatar.vue";
8
+
9
+ const injectedFlock = inject(flockWebDataKey);
10
+ const injectedShell = inject(frockBotWebDataKey);
11
+ if (!injectedFlock || !injectedShell)
12
+ throw new Error("Flock client data was not provided");
13
+ const flock = injectedFlock;
14
+ const shell = injectedShell;
15
+ flock.value.bindShell(shell);
16
+ const active = computed(() => shell.value.activeBotId);
17
+
18
+ /*
19
+ * Hidden and archived are different states. Archiving stops a Bot working;
20
+ * hiding only takes it out of this list, so a hidden Bot stays selectable and
21
+ * the "Hidden" group is how a User reaches it again.
22
+ */
23
+ function isHidden(botId: string): boolean {
24
+ return flock.value.profiles[botId]?.hiddenFromSidebar === true;
25
+ }
26
+ const listedBots = computed(() =>
27
+ flock.value.directory.bots.filter(
28
+ (bot) =>
29
+ flock.value.showArchived ||
30
+ flock.value.lifecycles[bot.botId] !== "archived",
31
+ ),
32
+ );
33
+ const visibleBots = computed(() =>
34
+ listedBots.value.filter((bot) => !isHidden(bot.botId)),
35
+ );
36
+ const groupedVisibleBots = computed(() =>
37
+ groupSidebarBotsV1(visibleBots.value, flock.value.profiles),
38
+ );
39
+ const hiddenBots = computed(() =>
40
+ listedBots.value.filter((bot) => isHidden(bot.botId)),
41
+ );
42
+
43
+ /** The live name the Bot's own settings hold, not the registration seed. */
44
+ function botName(botId: string, fallback: string): string {
45
+ return flock.value.profiles[botId]?.name ?? fallback;
46
+ }
47
+ function botSubtitle(botId: string): string {
48
+ return flock.value.profiles[botId]?.title ?? botId;
49
+ }
50
+ function previewText(botId: string): string {
51
+ return flock.value.unread[botId]?.lastMessage?.text ?? botSubtitle(botId);
52
+ }
53
+ function previewAt(botId: string): string | undefined {
54
+ return flock.value.unread[botId]?.lastMessage?.at;
55
+ }
56
+ function previewTime(botId: string): string {
57
+ const at = previewAt(botId);
58
+ return at ? formatSidebarMessageTimeV1(at) : "";
59
+ }
60
+
61
+ /*
62
+ * Unread is backend state: the Bot Durable Object derives the count and the
63
+ * gateway fans it out. Nothing here computes one — these read the projection.
64
+ */
65
+ function isUnread(botId: string): boolean {
66
+ return flock.value.unread[botId]?.unread === true;
67
+ }
68
+ function unreadLabel(botId: string): string | undefined {
69
+ const view = flock.value.unread[botId];
70
+ if (!view?.unread || view.count === 0) return undefined;
71
+ return view.capped ? `${view.count}+` : String(view.count);
72
+ }
73
+ /** Hidden Bots contribute no row, so their unread arrives as one badge. */
74
+ const hiddenUnreadCount = computed(() =>
75
+ hiddenBots.value.reduce(
76
+ (total, bot) => total + (flock.value.unread[bot.botId]?.count ?? 0),
77
+ 0,
78
+ ),
79
+ );
80
+ const hiddenUnread = computed(() =>
81
+ hiddenBots.value.some((bot) => isUnread(bot.botId)),
82
+ );
83
+ onMounted(() => void flock.value.load());
84
+ </script>
85
+
86
+ <template>
87
+ <div class="flock-list-actions">
88
+ <button type="button" class="flock-manage" @click="flock.toggleArchived">
89
+ {{ flock.showArchived ? "Hide archived" : "Manage" }}
90
+ </button>
91
+ </div>
92
+ <div v-if="flock.loading" class="flock-skeleton" aria-busy="true">
93
+ <span class="flock-skeleton-label">Loading your flock…</span>
94
+ <div v-for="row in 3" :key="row" class="flock-skeleton-row">
95
+ <UiSkeleton shape="circle" />
96
+ <span class="flock-skeleton-copy">
97
+ <UiSkeleton :width="row === 2 ? '48%' : '62%'" />
98
+ <UiSkeleton width="36%" />
99
+ </span>
100
+ </div>
101
+ </div>
102
+ <p v-else-if="!flock.directory.bots.length" class="flock-empty">
103
+ No Bots yet. Add your first sheep.
104
+ </p>
105
+ <template v-else>
106
+ <div class="flock-groups">
107
+ <section
108
+ v-for="group in groupedVisibleBots.groups"
109
+ :key="group.key"
110
+ class="flock-group"
111
+ >
112
+ <h2 v-if="groupedVisibleBots.showHeadings" class="flock-group-heading">
113
+ {{ group.label }}
114
+ </h2>
115
+ <TransitionGroup name="flock-row" tag="div" class="flock-rows">
116
+ <div
117
+ v-for="bot in group.bots"
118
+ :key="bot.botId"
119
+ class="flock-bot-row"
120
+ :class="{
121
+ active: active === bot.botId,
122
+ archived: flock.lifecycles[bot.botId] === 'archived',
123
+ unread: isUnread(bot.botId),
124
+ }"
125
+ >
126
+ <button
127
+ type="button"
128
+ class="flock-bot-select"
129
+ :disabled="flock.lifecycles[bot.botId] === 'archived'"
130
+ :aria-current="active === bot.botId ? 'true' : undefined"
131
+ @click="flock.select(bot.botId)"
132
+ >
133
+ <BotAvatar
134
+ :bot-id="bot.botId"
135
+ :sheep="flock.identities[bot.botId]?.sheep ?? bot.sheep"
136
+ :label="`${botName(bot.botId, bot.initialName)} avatar`"
137
+ />
138
+ <span class="flock-bot-copy">
139
+ <span class="flock-bot-primary">
140
+ <strong>{{ botName(bot.botId, bot.initialName) }}</strong>
141
+ <time
142
+ v-if="previewAt(bot.botId)"
143
+ :datetime="previewAt(bot.botId)"
144
+ >
145
+ {{ previewTime(bot.botId) }}
146
+ </time>
147
+ </span>
148
+ <small>{{ previewText(bot.botId) }}</small>
149
+ </span>
150
+ <span
151
+ v-if="unreadLabel(bot.botId) || active === bot.botId"
152
+ class="flock-bot-indicators"
153
+ >
154
+ <span
155
+ v-if="unreadLabel(bot.botId)"
156
+ class="flock-unread-badge"
157
+ :aria-label="`${unreadLabel(bot.botId)} unread`"
158
+ >{{ unreadLabel(bot.botId) }}</span
159
+ >
160
+ <i
161
+ v-if="active === bot.botId"
162
+ class="flock-bot-dot"
163
+ aria-hidden="true"
164
+ />
165
+ </span>
166
+ </button>
167
+ <button
168
+ v-if="
169
+ flock.showArchived && flock.lifecycles[bot.botId] === 'archived'
170
+ "
171
+ type="button"
172
+ class="flock-lifecycle"
173
+ @click="flock.restore(bot.botId)"
174
+ >
175
+ Restore
176
+ </button>
177
+ <button
178
+ v-else-if="flock.showArchived"
179
+ type="button"
180
+ class="flock-lifecycle"
181
+ @click="flock.openArchive(bot.botId)"
182
+ >
183
+ Archive
184
+ </button>
185
+ </div>
186
+ </TransitionGroup>
187
+ </section>
188
+ </div>
189
+ <div v-if="hiddenBots.length" class="flock-hidden-group">
190
+ <button
191
+ type="button"
192
+ class="flock-manage flock-hidden-toggle"
193
+ :aria-expanded="flock.showHidden ? 'true' : 'false'"
194
+ @click="flock.toggleHidden"
195
+ >
196
+ {{
197
+ flock.showHidden
198
+ ? `Hide ${hiddenBots.length} hidden`
199
+ : `Show ${hiddenBots.length} hidden`
200
+ }}
201
+ <span
202
+ v-if="hiddenUnread"
203
+ class="flock-unread-badge"
204
+ :aria-label="`${hiddenUnreadCount} unread in hidden Bots`"
205
+ >{{ hiddenUnreadCount > 0 ? hiddenUnreadCount : "" }}</span
206
+ >
207
+ </button>
208
+ <TransitionGroup
209
+ v-if="flock.showHidden"
210
+ name="flock-row"
211
+ tag="div"
212
+ class="flock-rows"
213
+ >
214
+ <div
215
+ v-for="bot in hiddenBots"
216
+ :key="bot.botId"
217
+ class="flock-bot-row"
218
+ :class="{
219
+ active: active === bot.botId,
220
+ archived: flock.lifecycles[bot.botId] === 'archived',
221
+ unread: isUnread(bot.botId),
222
+ }"
223
+ >
224
+ <button
225
+ type="button"
226
+ class="flock-bot-select"
227
+ :disabled="flock.lifecycles[bot.botId] === 'archived'"
228
+ :aria-current="active === bot.botId ? 'true' : undefined"
229
+ @click="flock.select(bot.botId)"
230
+ >
231
+ <BotAvatar
232
+ :bot-id="bot.botId"
233
+ :sheep="flock.identities[bot.botId]?.sheep ?? bot.sheep"
234
+ :label="`${botName(bot.botId, bot.initialName)} avatar`"
235
+ />
236
+ <span class="flock-bot-copy">
237
+ <span class="flock-bot-primary">
238
+ <strong>{{ botName(bot.botId, bot.initialName) }}</strong>
239
+ <time
240
+ v-if="previewAt(bot.botId)"
241
+ :datetime="previewAt(bot.botId)"
242
+ >
243
+ {{ previewTime(bot.botId) }}
244
+ </time>
245
+ </span>
246
+ <small>{{ previewText(bot.botId) }}</small>
247
+ </span>
248
+ </button>
249
+ </div>
250
+ </TransitionGroup>
251
+ </div>
252
+ </template>
253
+ <p v-if="flock.error" class="flock-error" role="alert">{{ flock.error }}</p>
254
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import { sheepLayerIds, type SheepRecipeV1 } from "../shared.js";
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ sheep: SheepRecipeV1;
7
+ label?: string;
8
+ size?: "mini" | "small" | "large";
9
+ }>(),
10
+ { label: "Bot sheep", size: "small" },
11
+ );
12
+ const layers = computed(() => sheepLayerIds(props.sheep));
13
+ </script>
14
+ <template>
15
+ <span
16
+ class="flock-avatar"
17
+ :class="`flock-avatar--${size}`"
18
+ role="img"
19
+ :aria-label="label"
20
+ >
21
+ <i
22
+ v-for="layer in layers"
23
+ :key="layer"
24
+ class="flock-art"
25
+ :class="`flock-art--${layer}`"
26
+ />
27
+ </span>
28
+ </template>
@@ -0,0 +1,15 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { dialogFocusWrapTarget } from "./dialog-focus.js";
3
+
4
+ describe("Flock dialog focus trap", () => {
5
+ test("wraps forward and reverse Tab only at dialog boundaries", () => {
6
+ const first = { id: "first" };
7
+ const middle = { id: "middle" };
8
+ const last = { id: "last" };
9
+ const controls = [first, middle, last];
10
+ expect(dialogFocusWrapTarget(controls, last, false)).toBe(first);
11
+ expect(dialogFocusWrapTarget(controls, first, true)).toBe(last);
12
+ expect(dialogFocusWrapTarget(controls, middle, false)).toBeUndefined();
13
+ expect(dialogFocusWrapTarget([], null, false)).toBeUndefined();
14
+ });
15
+ });
@@ -0,0 +1,13 @@
1
+ /** Return the wrapped focus target, or undefined when Tab stays within the dialog. */
2
+ export function dialogFocusWrapTarget<T>(
3
+ controls: readonly T[],
4
+ active: T | null,
5
+ reverse: boolean,
6
+ ): T | undefined {
7
+ const first = controls[0];
8
+ const last = controls.at(-1);
9
+ if (!first || !last) return undefined;
10
+ if (reverse && active === first) return last;
11
+ if (!reverse && active === last) return first;
12
+ return undefined;
13
+ }