eleven / ocho Public

web+server+admin: Helpers catalog — members can discover admin-vetted bots (#50)

Under the connections-only roster (#43) a new bot shares no thread, so it
was invisible to members. Per docs/bots.md, agents the admin marks
available_to_members are the one exemption from the connections rule:

- bot_configs gains description + available_to_members (additive ALTERs);
  the admin "Add an agent" form sets both (availability default-on there).
- connections() unions in available, joined (keyed) agents, and roster
  entries carry the admin's one-line description for agents.
- Pickers (new chat / new group / add people) split People vs a "Helpers"
  section, with the blurb on each helper row as the consent hint; the
  members list now badges bots too. addPeople also skips keyless entries
  (pending invitees have no pubkey to wrap the thread key to).
- Catalog metadata stays in clear, consistent with roster labels (open
  decision in docs/bots.md resolved; noted there).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a1e2922370eee1869339fb656925eb25e526fa57 parent 91b1262
8 files changed, +257 −34
  • bots_test.go +140 −0
  • db.go +33 −8
  • docs/bots.md +9 −10
  • main.go +6 −2
  • web/pages/admin.html +5 −0
  • web/static/admin.js +4 −0
  • web/static/chat.js +54 −14
  • web/static/style.css +6 −0
diff --git a/bots_test.go b/bots_test.go
new file mode 100644
index 0000000..8dffaf6
--- /dev/null
+++ b/bots_test.go
@@ -0,0 +1,140 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+)
+
+// The Helpers catalog (docs/bots.md): agents the admin marked available appear in
+// EVERY member's connections — the one exemption from the connections-only roster,
+// so a new bot is discoverable before anyone shares a thread with it.
+func TestHelpersCatalogInConnections(t *testing.T) {
+ db := newTestDB(t)
+ mk := func(name string) string {
+ id, err := db.createUser(name)
+ if err != nil {
+ t.Fatalf("createUser %s: %v", name, err)
+ }
+ if err := db.setUserPublicKey(id, "pub-"+name); err != nil {
+ t.Fatalf("setUserPublicKey %s: %v", name, err)
+ }
+ return id
+ }
+ paul := mk("Paul")
+ bot := mk("Sunny")
+ if err := db.setUserAgent(bot); err != nil {
+ t.Fatalf("setUserAgent: %v", err)
+ }
+ if err := db.setBotConfig(bot, "", "", "", "Answers questions and summarises chats", true); err != nil {
+ t.Fatalf("setBotConfig: %v", err)
+ }
+
+ find := func(conns []RosterEntry, id string) *RosterEntry {
+ for i := range conns {
+ if conns[i].UserID == id {
+ return &conns[i]
+ }
+ }
+ return nil
+ }
+
+ // Paul shares no thread with the bot, yet it's in his roster — with the badge
+ // and the admin's blurb.
+ conns, err := db.connections(paul)
+ if err != nil {
+ t.Fatalf("connections: %v", err)
+ }
+ e := find(conns, bot)
+ if e == nil {
+ t.Fatalf("an available bot should appear in every member's connections: %+v", conns)
+ }
+ if !e.IsAgent || e.Description != "Answers questions and summarises chats" {
+ t.Fatalf("catalog entry should carry is_agent + description: %+v", e)
+ }
+
+ // Admin flips availability off → the bot drops back out (still no shared thread).
+ if err := db.setBotConfig(bot, "", "", "", "Answers questions and summarises chats", false); err != nil {
+ t.Fatalf("setBotConfig off: %v", err)
+ }
+ conns, _ = db.connections(paul)
+ if find(conns, bot) != nil {
+ t.Fatal("an unavailable bot must NOT be visible without a shared thread")
+ }
+
+ // A keyless bot (invite minted, sidecar hasn't claimed it) never shows, even
+ // when available — there's no public key to wrap a thread key to.
+ ghost, err := db.createUser("Ghost")
+ if err != nil {
+ t.Fatalf("createUser ghost: %v", err)
+ }
+ if err := db.setUserAgent(ghost); err != nil {
+ t.Fatalf("setUserAgent ghost: %v", err)
+ }
+ if err := db.setBotConfig(ghost, "", "", "", "not joined yet", true); err != nil {
+ t.Fatalf("setBotConfig ghost: %v", err)
+ }
+ conns, _ = db.connections(paul)
+ if find(conns, ghost) != nil {
+ t.Fatal("a keyless (unclaimed) bot must not appear in the catalog")
+ }
+}
+
+// End to end over HTTP: the admin's "Add an agent" carries the catalog metadata,
+// and once the sidecar joins, a stranger member sees the helper in /api/roster.
+func TestHelpersCatalogHTTP(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ // A member with no connection to the bot.
+ paul, _ := srv.db.createUser("Paul")
+ srv.db.createKeyInvite("inv-paul", paul, "", 0)
+ tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "")
+ srv.db.setUserPublicKey(paul, "pub-paul")
+
+ // Admin mints an agent invite with a description, shown to members.
+ r := adminPost(t, ts.URL+"/api/admin/key-invites",
+ `{"label":"Sunny","id":"inv-bot","wrapped":"","agent":true,
+ "model":"","system_prompt":"","docs":"",
+ "description":"A friendly helper","available_to_members":true}`)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("create agent invite: got %d", r.StatusCode)
+ }
+
+ // Not joined yet → not in anyone's roster.
+ fetchRoster := func() []RosterEntry {
+ rr, _ := http.Get(ts.URL + "/api/roster?token=" + tok)
+ var out struct {
+ Roster []RosterEntry `json:"roster"`
+ }
+ json.NewDecoder(rr.Body).Decode(&out)
+ return out.Roster
+ }
+ for _, e := range fetchRoster() {
+ if e.Label == "Sunny" {
+ t.Fatal("unclaimed bot should not be in the roster yet")
+ }
+ }
+
+ // The sidecar claims the invite and registers its key (as agent/provision.mjs
+ // does) → the helper appears for the unconnected member, badge + blurb intact.
+ botTok, ok, err := srv.db.claimKeyInvite("inv-bot", "Sunny", "")
+ if err != nil || !ok {
+ t.Fatalf("bot claim: ok=%v err=%v", ok, err)
+ }
+ botID, _ := srv.db.deviceTokenUser(botTok)
+ srv.db.setUserPublicKey(botID, "pub-sunny")
+
+ var helper *RosterEntry
+ roster := fetchRoster()
+ for i := range roster {
+ if roster[i].UserID == botID {
+ helper = &roster[i]
+ }
+ }
+ if helper == nil {
+ t.Fatalf("joined available bot should be in a stranger's roster: %+v", roster)
+ }
+ if !helper.IsAgent || helper.Description != "A friendly helper" || helper.PublicKey != "pub-sunny" {
+ t.Fatalf("helper entry should carry badge, blurb and key: %+v", helper)
+ }
+}
diff --git a/db.go b/db.go
index ff06c6b..b92504b 100644
--- a/db.go
+++ b/db.go
@@ -215,6 +215,11 @@ CREATE TABLE IF NOT EXISTS bot_configs (
// Bot flag: a user driven by an agent (see agent/), not a person. Only
// used to badge them in the UI — it grants no privilege.
"ALTER TABLE users ADD COLUMN is_agent INTEGER NOT NULL DEFAULT 0",
+ // Helpers catalog (docs/bots.md): admin-authored one-line description shown
+ // to members, and the "show in members' Helpers list" flag. Catalog metadata
+ // like the roster label — in clear, never message content.
+ "ALTER TABLE bot_configs ADD COLUMN description TEXT NOT NULL DEFAULT ''",
+ "ALTER TABLE bot_configs ADD COLUMN available_to_members INTEGER NOT NULL DEFAULT 0",
} {
if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -684,6 +689,9 @@ type RosterEntry struct {
PublicKey string `json:"public_key"`
Avatar string `json:"avatar,omitempty"`
IsAgent bool `json:"is_agent,omitempty"` // an agent/bot, not a person
+ // One-line admin-authored blurb for agents (the Helpers catalog entry —
+ // doubles as the consent hint). Always "" for people.
+ Description string `json:"description,omitempty"`
}
func (db *DB) setUserPublicKey(userID, pub string) error {
@@ -827,11 +835,16 @@ type BotConfig struct {
Docs string `json:"docs"`
}
-func (db *DB) setBotConfig(userID, model, systemPrompt, docs string) error {
+func (db *DB) setBotConfig(userID, model, systemPrompt, docs, description string, available bool) error {
+ av := 0
+ if available {
+ av = 1
+ }
_, err := db.sql.Exec(
- `INSERT INTO bot_configs(user_id, model, system_prompt, docs, updated_at) VALUES(?, ?, ?, ?, ?)
- ON CONFLICT(user_id) DO UPDATE SET model = excluded.model, system_prompt = excluded.system_prompt, docs = excluded.docs, updated_at = excluded.updated_at`,
- userID, model, systemPrompt, docs, time.Now().Unix(),
+ `INSERT INTO bot_configs(user_id, model, system_prompt, docs, description, available_to_members, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET model = excluded.model, system_prompt = excluded.system_prompt, docs = excluded.docs,
+ description = excluded.description, available_to_members = excluded.available_to_members, updated_at = excluded.updated_at`,
+ userID, model, systemPrompt, docs, description, av, time.Now().Unix(),
)
return err
}
@@ -861,7 +874,9 @@ func (db *DB) setAvatar(userID, blobID string) error {
// they share a thread with). Kept for admin/back-end use and tests.
func (db *DB) roster() ([]RosterEntry, error) {
rows, err := db.sql.Query(
- "SELECT id, label, public_key, COALESCE(avatar_blob, ''), COALESCE(is_agent, 0) FROM users WHERE public_key IS NOT NULL AND public_key <> '' ORDER BY label",
+ `SELECT u.id, u.label, u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '')
+ FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id
+ WHERE u.public_key IS NOT NULL AND u.public_key <> '' ORDER BY u.label`,
)
if err != nil {
return nil, err
@@ -875,16 +890,23 @@ func (db *DB) roster() ([]RosterEntry, error) {
// connected to, or by inviting someone new. Keyless people (a pending invitee who
// hasn't joined yet) are included so a pending DM can still show their name; the
// client omits keyless entries from the "start a new chat" picker.
+//
+// The one exemption is the Helpers catalog (docs/bots.md): agents the admin has
+// marked available_to_members are shown to everyone — bots share no thread when
+// new, so they can't ride the connections rule for discovery. People stay
+// connections-only; only vetted, clearly-badged helpers are instance-visible.
func (db *DB) connections(userID string) ([]RosterEntry, error) {
rows, err := db.sql.Query(`
- SELECT u.id, u.label, COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0)
- FROM users u
+ SELECT u.id, u.label, COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '')
+ FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id
WHERE u.id = ?
OR u.id IN (
SELECT m2.user_id FROM thread_members m1
JOIN thread_members m2 ON m2.thread_id = m1.thread_id
WHERE m1.user_id = ? AND m2.user_id <> ?
)
+ OR (COALESCE(u.is_agent, 0) = 1 AND COALESCE(b.available_to_members, 0) = 1
+ AND u.public_key IS NOT NULL AND u.public_key <> '')
ORDER BY u.label`, userID, userID, userID)
if err != nil {
return nil, err
@@ -898,10 +920,13 @@ func scanRoster(rows *sql.Rows) ([]RosterEntry, error) {
for rows.Next() {
var e RosterEntry
var isAgent int
- if err := rows.Scan(&e.UserID, &e.Label, &e.PublicKey, &e.Avatar, &isAgent); err != nil {
+ if err := rows.Scan(&e.UserID, &e.Label, &e.PublicKey, &e.Avatar, &isAgent, &e.Description); err != nil {
return nil, err
}
e.IsAgent = isAgent != 0
+ if !e.IsAgent {
+ e.Description = "" // description is Helpers-catalog metadata, agents only
+ }
out = append(out, e)
}
return out, rows.Err()
diff --git a/docs/bots.md b/docs/bots.md
index b20951d..aa366cd 100644
--- a/docs/bots.md
+++ b/docs/bots.md
@@ -286,16 +286,15 @@ groups**: the prominent affordance is "Add a helper" in the group create /
add-member flow (where the value is); "DM a helper" is the secondary sandbox
lane. Show only bots the admin marked available (see governance).
-Server shape: a catalog query `is_agent = 1 AND available_to_members = 1` with a
-`description`. **Open decision:** bot name/description are admin-authored catalog
-metadata — store consistent with how the instance stores roster labels (in clear
-today), or encrypt to members via the instance key if labels are to be kept from
-the server.
-
-**Integration note:** the `is_agent` badge is on `agent-sidecar` (off `main`);
-the connections model is on `worktree-user-invites-and-connections`. When they
-converge, move the `is_agent` SELECT/scan onto `connections()` / `scanRoster`,
-and add the catalog surface — it's new, not a tweak to the roster.
+**Built (helpers-catalog):** `bot_configs` gained `description` +
+`available_to_members` (set from the admin's "Add an agent" form, availability
+default-on there, off in the schema). `connections()` unions in agents matching
+`is_agent = 1 AND available_to_members = 1` that have registered a public key —
+that one exemption *is* the catalog; the client just renders roster entries with
+`is_agent` under a "Helpers" heading (new-chat and add-to-group pickers), with
+the description as the row's second line. The open decision on name/description
+storage was resolved toward **in clear**, consistent with roster labels today —
+revisit both together if labels ever move under the instance key.
## Governance
diff --git a/main.go b/main.go
index 1ead4e4..1d34449 100644
--- a/main.go
+++ b/main.go
@@ -1069,6 +1069,10 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) {
Model string `json:"model"`
SystemPrompt string `json:"system_prompt"`
Docs string `json:"docs"`
+ // Helpers catalog metadata (only when Agent): the one-line blurb members
+ // see, and whether the bot shows in everyone's Helpers list (docs/bots.md).
+ Description string `json:"description"`
+ Available bool `json:"available_to_members"`
}
if json.NewDecoder(r.Body).Decode(&body) != nil {
httpError(w, http.StatusBadRequest, "bad request")
@@ -1077,7 +1081,7 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) {
// Wrapped is optional now: a fresh-person invite carries no key (the joiner
// generates their own user keypair). Only an add-device invite wraps the
// user's private key. See docs/threads-and-dms.md.
- if body.ID == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 {
+ if body.ID == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 || len(body.Description) > 300 {
httpError(w, http.StatusBadRequest, "missing id")
return
}
@@ -1098,7 +1102,7 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "db error")
return
}
- if err := s.db.setBotConfig(userID, body.Model, body.SystemPrompt, body.Docs); err != nil {
+ if err := s.db.setBotConfig(userID, body.Model, body.SystemPrompt, body.Docs, strings.TrimSpace(body.Description), body.Available); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
}
diff --git a/web/pages/admin.html b/web/pages/admin.html
index ad97285..fdb7bfd 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -67,6 +67,7 @@
<p class="sub" style="margin:0 0 8px">Adds an AI bot as a member. This mints a one-time link the agent sidecar uses to set up the bot's identity; it then appears in chats as a member with a BOT badge. The sidecar for this instance must be running.</p>
<form id="agentForm" autocomplete="off">
<input id="agentLabel" placeholder="Bot name (e.g. Claude)" maxlength="60" style="display:block;width:100%;margin:0 0 8px" />
+ <input id="agentDescription" placeholder="What this helper does, in one line — members see this in the Helpers list" maxlength="200" style="display:block;width:100%;margin:0 0 8px" />
<select id="agentModel" style="display:block;width:100%;margin:0 0 8px">
<option value="">Default model (Opus)</option>
<option value="claude-opus-4-8">Opus — most capable</option>
@@ -75,6 +76,10 @@
</select>
<textarea id="agentPrompt" rows="4" placeholder="Personality / instructions (system prompt). Leave blank for a friendly default. A safety + privacy preamble is always applied on top." style="display:block;width:100%;margin:0 0 8px"></textarea>
<textarea id="agentDocs" rows="3" placeholder="Reference the bot should always know — house rules, an FAQ, etc. Optional." style="display:block;width:100%;margin:0 0 8px"></textarea>
+ <label class="toggle-row" style="margin:0 0 8px">
+ <input type="checkbox" id="agentAvailable" checked />
+ <span>Show in everyone's Helpers list (untick to add it to chats yourself)</span>
+ </label>
<button type="submit">Add agent</button>
</form>
</section>
diff --git a/web/static/admin.js b/web/static/admin.js
index f234c57..93b130d 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -113,13 +113,17 @@ function wireCreateAgent() {
model: document.getElementById("agentModel").value,
system_prompt: document.getElementById("agentPrompt").value.trim(),
docs: document.getElementById("agentDocs").value.trim(),
+ description: document.getElementById("agentDescription").value.trim(),
+ available_to_members: document.getElementById("agentAvailable").checked,
};
const minted = await mintInvite(name, null, true, config);
if (!minted) return;
document.getElementById("agentLabel").value = "";
+ document.getElementById("agentDescription").value = "";
document.getElementById("agentModel").value = "";
document.getElementById("agentPrompt").value = "";
document.getElementById("agentDocs").value = "";
+ document.getElementById("agentAvailable").checked = true;
showInviteModal(minted.link, name || "New agent");
loadUsers();
};
diff --git a/web/static/chat.js b/web/static/chat.js
index b54ed23..ca93325 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -366,6 +366,31 @@ function agentTag() {
return t;
}
+// A picker row's name: the label, plus for agents the BOT tag and the admin's
+// one-line description (the Helpers catalog blurb — the "what am I adding?" hint).
+function pickerName(e) {
+ const nm = document.createElement("span");
+ nm.textContent = e.label;
+ if (e.is_agent) {
+ nm.appendChild(agentTag());
+ if (e.description) {
+ const d = document.createElement("span");
+ d.className = "helper-desc";
+ d.textContent = e.description;
+ nm.appendChild(d);
+ }
+ }
+ return nm;
+}
+
+// Section heading inside a pick-list (People / Helpers).
+function pickerSection(title) {
+ const s = document.createElement("div");
+ s.className = "pick-section";
+ s.textContent = title;
+ return s;
+}
+
// The instance key is a single AES key shared by all members, used only for
// all-members content (profile photos). It's wrapped to each member's user
// pubkey (server-blind, reusing the thread-key wrap). On boot: adopt our copy if
@@ -3017,6 +3042,9 @@ function showMembers() {
main.appendChild(avatarEl(name, () => avatarFor(uid)));
const lbl = document.createElement("span");
lbl.textContent = me ? `${name} (you)` : name;
+ // Transparency: a bot in the room stays visibly a bot, not just in the picker.
+ const entry = roster.find((x) => x.user_id === uid);
+ if (entry && entry.is_agent) lbl.appendChild(agentTag());
main.appendChild(lbl);
if (!me)
main.onclick = async () => {
@@ -3101,7 +3129,8 @@ function addPeople() {
const t = threads.find((x) => x.id === activeId);
if (!t || t.kind !== "group") return;
const here = new Set(t.members || []);
- const candidates = roster.filter((e) => !here.has(e.user_id));
+ // Joined connections only (keyless = pending invitee, no pubkey to wrap to).
+ const candidates = roster.filter((e) => !here.has(e.user_id) && e.public_key);
const backdrop = modal();
const card = backdrop.querySelector(".sheet");
const h = document.createElement("h2");
@@ -3128,7 +3157,8 @@ function addPeople() {
const renderList = () => {
const q = filter.value.trim().toLowerCase();
list.innerHTML = "";
- for (const e of candidates.filter((x) => x.label.toLowerCase().includes(q))) {
+ const matches = candidates.filter((x) => x.label.toLowerCase().includes(q));
+ const addRow = (e) => {
const row = document.createElement("label");
row.className = "list-row";
const cb = document.createElement("input");
@@ -3138,12 +3168,18 @@ function addPeople() {
cb.checked ? selected.add(e.user_id) : selected.delete(e.user_id);
add.disabled = selected.size === 0;
};
- const nm = document.createElement("span");
- nm.textContent = e.label;
row.appendChild(cb);
- row.appendChild(nm);
+ row.appendChild(pickerName(e));
list.appendChild(row);
- }
+ };
+ // Same People/Helpers split as the new-chat picker: adding a bot to a group
+ // is a distinct, visible act — never something buried among the people.
+ const people = matches.filter((e) => !e.is_agent);
+ const helpers = matches.filter((e) => e.is_agent);
+ if (people.length && helpers.length) list.appendChild(pickerSection("People"));
+ for (const e of people) addRow(e);
+ if (helpers.length) list.appendChild(pickerSection("Helpers"));
+ for (const e of helpers) addRow(e);
};
filter.addEventListener("input", renderList);
renderList();
@@ -3364,7 +3400,7 @@ function showNewChat() {
list.appendChild(p);
return;
}
- for (const e of matches) {
+ const addRow = (e) => {
if (mode === "group") {
const row = document.createElement("label");
row.className = "list-row";
@@ -3375,17 +3411,13 @@ function showNewChat() {
cb.checked ? selected.add(e.user_id) : selected.delete(e.user_id);
create.disabled = selected.size === 0;
};
- const nm = document.createElement("span");
- nm.textContent = e.label;
- if (e.is_agent) nm.appendChild(agentTag());
row.appendChild(cb);
- row.appendChild(nm);
+ row.appendChild(pickerName(e));
list.appendChild(row);
} else {
const b = document.createElement("button");
b.className = "list-row";
- b.textContent = e.label;
- if (e.is_agent) b.appendChild(agentTag());
+ b.appendChild(pickerName(e));
b.onclick = () => {
const dm = existingDM(e.user_id);
if (dm) {
@@ -3397,7 +3429,15 @@ function showNewChat() {
};
list.appendChild(b);
}
- }
+ };
+ // People first, then bots under their own "Helpers" heading — distinct from
+ // People on purpose ("not a person"), with the admin's blurb as consent hint.
+ const people = matches.filter((e) => !e.is_agent);
+ const helpers = matches.filter((e) => e.is_agent);
+ if (people.length && helpers.length) list.appendChild(pickerSection("People"));
+ for (const e of people) addRow(e);
+ if (helpers.length) list.appendChild(pickerSection("Helpers"));
+ for (const e of helpers) addRow(e);
};
filter.addEventListener("input", renderList);
renderList();
diff --git a/web/static/style.css b/web/static/style.css
index 7ee0426..bc442d1 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1220,6 +1220,12 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.list-row:hover:not(.static) { background: var(--bubble-them); }
.list-row.static { cursor: default; }
.list-row input { width: auto; }
+/* Helpers catalog: bots sit under their own picker heading, with a one-line blurb */
+.pick-section {
+ padding: 16px 28px 6px; font-size: 12px; font-weight: 700; color: var(--muted);
+ text-transform: uppercase; letter-spacing: 0.06em;
+}
+.helper-desc { display: block; font-size: 13px; font-weight: 400; color: var(--muted); margin-top: 2px; }
/* member row: tappable identity on the left, a Remove action on the right */
.member-main {
flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px;