@frockbot/plugin-flock 0.3.10 → 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.
@@ -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;
@@ -16,6 +16,13 @@
16
16
  border-radius: 9px;
17
17
  }
18
18
 
19
+ /* The pinned sidebar tile: round, so a pin reads as a destination not a row. */
20
+ .flock-avatar--tile {
21
+ width: var(--frock-avatar-lg);
22
+ height: var(--frock-avatar-lg);
23
+ border-radius: 50%;
24
+ }
25
+
19
26
  .flock-avatar--large {
20
27
  width: 188px;
21
28
  height: 188px;
@@ -63,6 +70,88 @@
63
70
  color: var(--frock-text);
64
71
  }
65
72
 
73
+ /*
74
+ * Pinned Bots. A wrapping row of large round tiles above the labelled groups,
75
+ * each one a whole Bot rather than a line of text about it.
76
+ */
77
+ .flock-pinned {
78
+ display: flex;
79
+ flex-wrap: wrap;
80
+ gap: 6px;
81
+ padding: 4px 6px 10px;
82
+ border-bottom: 1px solid var(--frock-border);
83
+ margin-bottom: 8px;
84
+ }
85
+
86
+ .flock-pinned-tile {
87
+ display: flex;
88
+ width: 72px;
89
+ flex: 0 0 auto;
90
+ flex-direction: column;
91
+ align-items: center;
92
+ gap: 6px;
93
+ padding: 6px 2px;
94
+ border: 0;
95
+ border-radius: var(--frock-radius-card);
96
+ background: transparent;
97
+ color: inherit;
98
+ font: inherit;
99
+ cursor: pointer;
100
+ transition: background-color var(--frock-motion-fast);
101
+ }
102
+
103
+ .flock-pinned-tile:hover:not(:disabled) {
104
+ background: var(--frock-fill-hover);
105
+ }
106
+
107
+ .flock-pinned-tile.active {
108
+ background: var(--frock-fill-pressed);
109
+ }
110
+
111
+ .flock-pinned-tile.archived {
112
+ opacity: 0.72;
113
+ cursor: default;
114
+ }
115
+
116
+ .flock-pinned-tile:focus-visible {
117
+ outline: 2px solid var(--frock-focus-ring);
118
+ outline-offset: 2px;
119
+ }
120
+
121
+ .flock-pinned-art {
122
+ position: relative;
123
+ display: inline-flex;
124
+ }
125
+
126
+ /* The tile has no preview line, so unread is a dot rather than bold text. */
127
+ .flock-pinned-dot {
128
+ position: absolute;
129
+ right: 0;
130
+ bottom: 2px;
131
+ width: 10px;
132
+ height: 10px;
133
+ border-radius: 999px;
134
+ background: var(--frock-action-primary);
135
+ box-shadow: 0 0 0 2px var(--frock-surface);
136
+ }
137
+
138
+ .flock-pinned-name {
139
+ max-width: 100%;
140
+ overflow: hidden;
141
+ color: var(--frock-text-muted);
142
+ font-size: var(--frock-text-xs);
143
+ line-height: var(--frock-leading-tight);
144
+ text-align: center;
145
+ text-overflow: ellipsis;
146
+ white-space: nowrap;
147
+ }
148
+
149
+ .flock-pinned-tile.active .flock-pinned-name,
150
+ .flock-pinned-tile.unread .flock-pinned-name {
151
+ color: var(--frock-text);
152
+ font-weight: 600;
153
+ }
154
+
66
155
  .flock-groups,
67
156
  .flock-group,
68
157
  .flock-rows {
@@ -268,6 +357,24 @@
268
357
  color: var(--frock-danger-text);
269
358
  }
270
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
+
271
378
  /* Header identity */
272
379
 
273
380
  .flock-identity-button {
@@ -184,6 +184,28 @@ describe("Flock v1 contracts", () => {
184
184
  }),
185
185
  ).toThrow("duplicate IDs");
186
186
  });
187
+
188
+ test("carries the pin instant, and refuses one that is not an instant", () => {
189
+ const identity = {
190
+ schemaVersion: 1 as const,
191
+ botId: "alpha",
192
+ name: "Atlas",
193
+ namedBy: "user" as const,
194
+ hiddenFromSidebar: false,
195
+ pinnedAt: "2026-09-03T10:15:00.000Z",
196
+ };
197
+ expect(decodeBotIdentityViewV1(structuredClone(identity))).toEqual(
198
+ identity,
199
+ );
200
+ // Absent stays absent: every identity written before pinning existed.
201
+ const { pinnedAt: _pinnedAt, ...unpinned } = identity;
202
+ expect(decodeBotIdentityViewV1(structuredClone(unpinned))).toEqual(
203
+ unpinned,
204
+ );
205
+ expect(() =>
206
+ decodeBotIdentityViewV1({ ...identity, pinnedAt: "someday" }),
207
+ ).toThrow("pinnedAt is invalid");
208
+ });
187
209
  });
188
210
 
189
211
  describe("stored Bot directory migration", () => {
package/src/shared.ts CHANGED
@@ -137,6 +137,12 @@ export interface BotIdentityViewV1 {
137
137
  /** Purely organisational sidebar group; never part of Bot instructions. */
138
138
  label?: string;
139
139
  title?: string;
140
+ /**
141
+ * When the User pinned this Bot, as an ISO 8601 instant. A pinned Bot is
142
+ * shown as a tile above the list instead of a row inside it, earliest pin
143
+ * first. Absent means not pinned.
144
+ */
145
+ pinnedAt?: string;
140
146
  }
141
147
 
142
148
  export interface BotIdentityDirectoryViewV1 {
@@ -600,7 +606,7 @@ export function decodeBotIdentityViewV1(input: unknown): BotIdentityViewV1 {
600
606
  exact(
601
607
  value,
602
608
  ["schemaVersion", "botId", "name", "namedBy", "hiddenFromSidebar"],
603
- ["label", "title"],
609
+ ["label", "title", "pinnedAt"],
604
610
  );
605
611
  if (value.schemaVersion !== 1 || typeof value.hiddenFromSidebar !== "boolean")
606
612
  throw new FlockDecodeError("Bot identity is invalid");
@@ -616,9 +622,20 @@ export function decodeBotIdentityViewV1(input: unknown): BotIdentityViewV1 {
616
622
  ...(value.title === undefined
617
623
  ? {}
618
624
  : { title: boundedText(value.title, "title", 120) }),
625
+ ...(value.pinnedAt === undefined
626
+ ? {}
627
+ : { pinnedAt: timestampText(value.pinnedAt, "pinnedAt") }),
619
628
  };
620
629
  }
621
630
 
631
+ /** An ISO 8601 instant. The sidebar orders pinned Bots by it. */
632
+ function timestampText(value: unknown, label: string): string {
633
+ const candidate = boundedText(value, label, 64);
634
+ if (!Number.isFinite(Date.parse(candidate)))
635
+ throw new FlockDecodeError(`${label} is invalid`);
636
+ return candidate;
637
+ }
638
+
622
639
  export function decodeBotIdentityDirectoryViewV1(
623
640
  input: unknown,
624
641
  ): BotIdentityDirectoryViewV1 {