···11import { ArrowRight, Copy, Webhook } from "../../icons.ts";
22import { nsidToDomain } from "../../../lib/lexicons/resolver.ts";
33import { isRecordProducingAction, type Action } from "../../../lib/db/schema.ts";
44-import { FOLLOW_TARGET_META } from "../../../lib/automations/action-catalogue.ts";
44+import { FOLLOW_TARGETS } from "../../../lib/automations/follow-targets.ts";
55import { Button } from "../Button/index.tsx";
66import { Favicon, NsidCode } from "../NsidCode/index.tsx";
77import * as s from "./styles.css.ts";
···2424 if (pds?.$type === "bsky-post") return <Favicon domain="bsky.app" class={s.targetFavicon} />;
2525 if (pds?.$type === "bookmark") return <Favicon domain="margin.at" class={s.targetFavicon} />;
2626 if (pds?.$type === "follow")
2727- return (
2828- <Favicon domain={FOLLOW_TARGET_META[pds.target].faviconDomain} class={s.targetFavicon} />
2929- );
2727+ return <Favicon domain={FOLLOW_TARGETS[pds.target].faviconDomain} class={s.targetFavicon} />;
3028 if (pds?.$type === "record" || pds?.$type === "patch-record")
3129 return <Favicon domain={nsidToDomain(pds.targetCollection)} class={s.targetFavicon} />;
3230 return <Webhook size={14} />;
+2-2
app/islands/AutomationForm.tsx
···99} from "../../lib/db/schema.js";
1010import {
1111 ACTION_CATALOGUE,
1212- FOLLOW_TARGET_META,
1312 actionTypeKey,
1413 type AddableActionId,
1514} from "../../lib/automations/action-catalogue.js";
1515+import { FOLLOW_TARGETS } from "../../lib/automations/follow-targets.js";
1616import { SCOPE_INSUFFICIENT, redirectToScopeUpgrade } from "../../lib/auth/scope-errors.js";
1717import RecordFormBuilder from "./RecordFormBuilder.js";
1818import { ActionHeader } from "../components/ActionHeader/index.js";
···652652 action: FollowDraft;
653653 onChange: (a: FollowDraft) => void;
654654}) {
655655- const meta = FOLLOW_TARGET_META[action.target];
655655+ const meta = FOLLOW_TARGETS[action.target];
656656 return (
657657 <>
658658 <div class={s.fieldGroup}>
+4-9
app/routes/dashboard/automations/[rkey].tsx
···1111 Activity,
1212} from "../../../icons.js";
1313import { getRateLimitCounts } from "@/jetstream/rate-limit.js";
1414-import {
1515- opLabels,
1616- actionTypeLabels,
1717- operationLabels,
1818- followTargetLabels,
1919-} from "@/automations/labels.js";
1414+import { opLabels, actionTypeLabels, operationLabels } from "@/automations/labels.js";
2015import { actionTypeKey } from "@/automations/action-catalogue.js";
2121-import { FOLLOW_TARGET_COLLECTION } from "@/db/schema.js";
1616+import { FOLLOW_TARGETS } from "@/automations/follow-targets.js";
2217import { scopeCoversActions, computeRequiredScope } from "@/auth/client.js";
2318import { db } from "@/db/index.js";
2419import { automations, deliveryLogs } from "@/db/schema.js";
···295290 ) : action.$type === "follow" ? (
296291 <>
297292 <dt>App</dt>
298298- <dd>{followTargetLabels[action.target] ?? action.target}</dd>
293293+ <dd>{FOLLOW_TARGETS[action.target]?.appName ?? action.target}</dd>
299294 <dt>Collection</dt>
300295 <dd>
301301- <NsidCode>{FOLLOW_TARGET_COLLECTION[action.target]}</NsidCode>
296296+ <NsidCode>{FOLLOW_TARGETS[action.target]?.collection}</NsidCode>
302297 </dd>
303298 <dt>Subject DID</dt>
304299 <dd>
+4-4
app/routes/u/[handle]/[rkey].tsx
···33import { ArrowLeft, Copy, Filter, Database, Zap } from "../../../icons.js";
44import { getSessionUser } from "@/auth/middleware.js";
55import { resolveHandle } from "@/auth/client.js";
66-import { opLabels, operationLabels, followTargetLabels } from "@/automations/labels.js";
66+import { opLabels, operationLabels } from "@/automations/labels.js";
77import { actionTypeKey } from "@/automations/action-catalogue.js";
88-import { FOLLOW_TARGET_COLLECTION } from "@/db/schema.js";
88+import { FOLLOW_TARGETS } from "@/automations/follow-targets.js";
99import { db } from "@/db/index.js";
1010import { users, automations } from "@/db/schema.js";
1111import { sanitizeActions } from "@/automations/sanitize.js";
···265265 ) : action.$type === "follow" ? (
266266 <>
267267 <dt>App</dt>
268268- <dd>{followTargetLabels[action.target] ?? action.target}</dd>
268268+ <dd>{FOLLOW_TARGETS[action.target]?.appName ?? action.target}</dd>
269269 <dt>Collection</dt>
270270 <dd>
271271- <NsidCode>{FOLLOW_TARGET_COLLECTION[action.target]}</NsidCode>
271271+ <NsidCode>{FOLLOW_TARGETS[action.target]?.collection}</NsidCode>
272272 </dd>
273273 <dt>Subject DID</dt>
274274 <dd>
+56-90
docs/follow-actions.md
···25252. **Profile pre-check** — does the subject have a profile record on the
2626 target graph? If not, return 204 ("skip"). Implemented as a single
2727 `fetchRecord` call against the target's profile NSID
2828- (see `FOLLOW_TARGET_PROFILE` in [lib/db/schema.ts](../lib/db/schema.ts)).
2828+ (see `FOLLOW_TARGETS[target].profileCollection` / `profileRkey` in
2929+ [lib/automations/follow-targets.ts](../lib/automations/follow-targets.ts)).
29303. **Already-follows pre-check** — does the automation owner already follow
3031 the subject on the target graph? If so, return 204. Implemented by
3132 constructing a synthetic `FetchStepSearch` and handing it to
···7172### Profile check
72737374```
7474-at://{subject}/{FOLLOW_TARGET_PROFILE[target].collection}/{FOLLOW_TARGET_PROFILE[target].rkey}
7575+at://{subject}/{FOLLOW_TARGETS[target].profileCollection}/{FOLLOW_TARGETS[target].profileRkey}
7576```
76777778A `fetchRecord` call. `found: false` → skip with `"Skipped: no <AppName>
···8889 kind: "search",
8990 name: "__follow_preflight_already_follows",
9091 repo: match.automation.did,
9191- collection: FOLLOW_TARGET_COLLECTION[action.target],
9292+ collection: FOLLOW_TARGETS[action.target].collection,
9293 where: [{ field: "subject", operator: "eq", value: subject }],
9394 limit: 1,
9495};
···176177177178# Adding a new follow target
178179179179-Walk-through for adding a hypothetical new social graph "Foo" living at
180180-`org.foo` with profile collection `org.foo.actor.profile/self` and follow
181181-collection `org.foo.graph.follow`. Three of the changes are caught by the
182182-parity test in [lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts);
183183-the rest are user-visible enough that a missed touchpoint will surface in
184184-manual testing or a related test.
185185-186186-## 1. Lexicon
187187-188188-[lexicons/run/airglow/automation.json](../lexicons/run/airglow/automation.json) — add
189189-`"foo"` to `followAction.properties.target.knownValues`. After editing, run:
190190-191191-```sh
192192-goat lex lint lexicons/
193193-```
180180+Per-target metadata lives in a single registry — `FOLLOW_TARGETS` in
181181+[lib/automations/follow-targets.ts](../lib/automations/follow-targets.ts).
182182+The `FollowTarget` union type, the runtime allow-list, the catalogue tiles,
183183+the executor's collection / profile NSID lookups, and the dashboard's "App"
184184+label all derive from it. Adding a target is one registry entry plus a
185185+matching push to the lexicon, plus the layered CSS / favicon work that's
186186+genuinely per-target.
194187195195-## 2. Backend types and maps
188188+Walk-through for adding a hypothetical new social graph "Foo" with profile
189189+collection `org.foo.actor.profile/self` and follow collection
190190+`org.foo.graph.follow`.
196191197197-[lib/db/schema.ts](../lib/db/schema.ts) — three things:
198198-199199-```ts
200200-export type FollowTarget = "bluesky" | "tangled" | "sifa" | "foo";
201201-202202-export const FOLLOW_TARGET_COLLECTION: Record<FollowTarget, string> = {
203203- // ...
204204- foo: "org.foo.graph.follow",
205205-};
206206-207207-export const FOLLOW_TARGET_PROFILE: Record<FollowTarget, { collection: string; rkey: string }> = {
208208- // ...
209209- foo: { collection: "org.foo.actor.profile", rkey: "self" },
210210-};
211211-```
212212-213213-[lib/actions/validation.ts](../lib/actions/validation.ts):
214214-215215-```ts
216216-export const VALID_FOLLOW_TARGETS = new Set(["bluesky", "tangled", "sifa", "foo"]);
217217-```
218218-219219-…and update the `Invalid follow target` error string nearby to mention the
220220-new target.
221221-222222-[lib/automations/pds.ts](../lib/automations/pds.ts) and
223223-[lib/automations/sanitize.ts](../lib/automations/sanitize.ts) — extend the
224224-inline `target: "bluesky" | "tangled" | "sifa"` literal type to include
225225-`"foo"`. (These can't reference `FollowTarget` because they're at the PDS
226226-serialization boundary, but a future refactor could collapse them.)
227227-228228-## 3. UI metadata
192192+## 1. Registry entry
229193230194[lib/automations/follow-targets.ts](../lib/automations/follow-targets.ts) —
231231-add an entry to `FOLLOW_TARGET_META`:
195195+add `"foo"` to the `ColorKey` union (so the theme token below is reachable),
196196+then add an entry to `FOLLOW_TARGETS`. Insertion order in the registry
197197+controls the order tiles appear within their category in the UI.
232198233199```ts
234200foo: {
235201 catId: "apps", // or "bluesky" if it belongs in the Bluesky tile group
236236- colorKey: "foo", // requires a matching theme color, see step 4
202202+ colorKey: "foo",
237203 appName: "Foo",
238204 label: "Follow on Foo",
239239- faviconDomain: "foo.example",
240205 description: "Follow someone on Foo",
206206+ faviconDomain: "foo.example",
207207+ collection: "org.foo.graph.follow",
208208+ profileCollection: "org.foo.actor.profile",
209209+ profileRkey: "self",
241210},
242211```
243212244244-Add `"foo"` to the `ColorKey` union in the same file.
213213+That single entry feeds:
214214+215215+- `FollowTarget` (the literal union — derived from `keyof typeof FOLLOW_TARGETS`)
216216+- `VALID_FOLLOW_TARGETS` (runtime allow-list, derived from `Object.keys`)
217217+- The catalogue tile (auto-generated under the right category in `action-catalogue.ts`)
218218+- The executor's profile pre-check URI and follow record collection
219219+- The dashboard / public-page "App" label and "Collection" NSID display
245220246246-[lib/automations/labels.ts](../lib/automations/labels.ts) — add
247247-`foo: "Foo"` to `followTargetLabels` (used by the dashboard log view).
221221+No other code-side maps to update.
248222249249-## 4. Catalogue tile
223223+## 2. Lexicon
250224251251-[lib/automations/action-catalogue.ts](../lib/automations/action-catalogue.ts) —
252252-add `"follow-foo"` to the `AddableActionId` union and an entry under the
253253-appropriate category:
225225+[lexicons/run/airglow/automation.json](../lexicons/run/airglow/automation.json) — add
226226+`"foo"` to `followAction.properties.target.knownValues`. After editing, run:
254227255255-```ts
256256-{
257257- id: "follow-foo",
258258- label: FOLLOW_TARGET_META.foo.label,
259259- description: FOLLOW_TARGET_META.foo.description,
260260- icon: UserPlus,
261261- available: true,
262262- colorKey: FOLLOW_TARGET_META.foo.colorKey,
263263- faviconDomain: FOLLOW_TARGET_META.foo.faviconDomain,
264264-},
228228+```sh
229229+goat lex lint lexicons/
265230```
266231267267-Update the corresponding tile-order test in
268268-[action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts) so
269269-the new ordering is intentional and reviewable.
232232+The lexicon parity test in
233233+[lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts)
234234+asserts that `Object.keys(FOLLOW_TARGETS)` matches `knownValues` — drift in
235235+either direction fails the suite.
270236271271-## 5. Theme color
237237+## 3. Theme color
272238273239[app/styles/tokens/colors.ts](../app/styles/tokens/colors.ts) — add `foo`
274240and `fooSubtle` entries in both the dark and light palettes.
···277243tokens on `vars.color`.
278244279245[app/styles/action-header.css.ts](../app/styles/action-header.css.ts) — add
280280-the matching `&[data-cat="foo"]` selector. (A typo silently loses the accent.)
246246+the matching `&[data-cat="foo"]` selector. (A typo silently loses the
247247+accent — the token name has to match the registry's `colorKey`.)
281248282282-## 6. Favicon
249249+vanilla-extract requires statically-declared CSS variables, so this layer
250250+can't be derived from the registry; the registry's `colorKey` field is the
251251+binding between a target and its theme tokens.
252252+253253+## 4. Favicon
283254284255[app/components/Favicon/index.tsx](../app/components/Favicon/index.tsx) —
285256register the local SVG so the tile renders the right brandmark instead of a
286257generic icon. Drop the SVG into `public/static/favicons/foo.example.<hash>.svg`
287258and add the entry to the favicon map.
288259289289-## 7. Tests
260260+## 5. Tests
290261291291-The parity test
292292-([lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts))
293293-already pins `FOLLOW_TARGET_COLLECTION` ↔ `VALID_FOLLOW_TARGETS` ↔
294294-`FOLLOW_TARGET_META`, so it will fail until those three are in sync. Beyond
295295-that, add or update:
262262+The lexicon parity test already pins `FOLLOW_TARGETS` to the lexicon's
263263+`knownValues`, so it fails until those two are in sync. Beyond that:
296264265265+- Update the tile-order assertions in
266266+ [lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts)
267267+ if the new tile slots into an existing category — that order is
268268+ product-visible and intentional.
297269- [lib/actions/follow.test.ts](../lib/actions/follow.test.ts) — extend
298270 `"maps tangled and sifa targets to their collections"` to cover `foo`,
299271 and `"uses the target-specific profile NSID for the profile pre-check"`
300272 to assert the new profile URI shape.
301273302302-> **Drift caveat.** The parity test does **not** include
303303-> `FOLLOW_TARGET_PROFILE`. If you add a new target and forget the profile
304304-> entry, the executor will throw `Cannot read property 'collection' of
305305-undefined` at the first fire. Worth adding to the parity test next time
306306-> someone touches that file.
307307-308308-## 8. Manual verification
274274+## 6. Manual verification
309275310276Run `vp check && vp test`, then in `vp dev`:
311277
···44import { isValidNsid } from "../lexicons/resolver.js";
55import { PLACEHOLDER_RE, validateTextTemplate } from "./template.js";
66import type { Condition, FetchStepSearch } from "../db/schema.js";
77+import {
88+ FOLLOW_TARGETS,
99+ VALID_FOLLOW_TARGETS,
1010+ type FollowTarget,
1111+} from "../automations/follow-targets.js";
712813export type ActionInput =
914 | {
···3742 }
3843 | {
3944 type: "follow";
4040- target: "bluesky" | "tangled" | "sifa";
4545+ target: FollowTarget;
4146 subject: string;
4247 comment?: string;
4348 };
4444-4545-export const VALID_FOLLOW_TARGETS = new Set(["bluesky", "tangled", "sifa"]);
46494750export const VALID_OPERATORS = new Set([
4851 "eq",
···420423 if (!VALID_FOLLOW_TARGETS.has(input.target)) {
421424 return {
422425 valid: false,
423423- error: `Invalid follow target "${input.target}". Must be one of: bluesky, tangled, sifa`,
426426+ error: `Invalid follow target "${input.target}". Must be one of: ${Object.keys(FOLLOW_TARGETS).join(", ")}`,
424427 };
425428 }
426429 if (!input.subject || typeof input.subject !== "string" || !input.subject.trim()) {
+18-18
lib/automations/action-catalogue.test.ts
···11import { describe, it, expect } from "vitest";
22-import {
33- ACTION_CATALOGUE,
44- ACTION_INFO_BY_TYPE,
55- FOLLOW_TARGET_META,
66- actionTypeKey,
77-} from "./action-catalogue.js";
88-import { FOLLOW_TARGET_COLLECTION } from "../db/schema.js";
99-import { VALID_FOLLOW_TARGETS } from "../actions/validation.js";
22+import { readFileSync } from "node:fs";
33+import { fileURLToPath } from "node:url";
44+import { ACTION_CATALOGUE, ACTION_INFO_BY_TYPE, actionTypeKey } from "./action-catalogue.js";
55+import { FOLLOW_TARGETS } from "./follow-targets.js";
106117// Tile order is a product-visible detail: reorders should be intentional and
128// show up in review, not slip in silently with other catalogue edits.
···6864 });
6965});
70667171-// Adding a new follow target means touching three maps (schema collection,
7272-// validation whitelist, catalogue metadata). Drift means silent breakage,
7373-// so pin them together.
7474-describe("follow target key parity", () => {
7575- const expected = ["bluesky", "tangled", "sifa"].sort();
7676-7777- it("FOLLOW_TARGET_COLLECTION, VALID_FOLLOW_TARGETS, and FOLLOW_TARGET_META agree on targets", () => {
7878- expect(Object.keys(FOLLOW_TARGET_COLLECTION).sort()).toEqual(expected);
7979- expect([...VALID_FOLLOW_TARGETS].sort()).toEqual(expected);
8080- expect(Object.keys(FOLLOW_TARGET_META).sort()).toEqual(expected);
6767+// `FOLLOW_TARGETS` is the single source of truth for code-side per-target
6868+// metadata; the only data-layer drift surface left is the lexicon JSON, which
6969+// declares the wire format. Pin them together: any new target must show up in
7070+// both, or this fails.
7171+describe("follow target lexicon parity", () => {
7272+ it("FOLLOW_TARGETS keys match the lexicon's followAction.target.knownValues", () => {
7373+ const lexiconPath = fileURLToPath(
7474+ new URL("../../lexicons/run/airglow/automation.json", import.meta.url),
7575+ );
7676+ const lexicon = JSON.parse(readFileSync(lexiconPath, "utf8")) as {
7777+ defs: { followAction: { properties: { target: { knownValues: string[] } } } };
7878+ };
7979+ const known = lexicon.defs.followAction.properties.target.knownValues;
8080+ expect([...known].sort()).toEqual(Object.keys(FOLLOW_TARGETS).sort());
8181 });
8282});
+27-36
lib/automations/action-catalogue.ts
···88 UserPlus,
99 Webhook,
1010} from "../../app/icons.js";
1111-import { FOLLOW_TARGET_META, type ColorKey } from "./follow-targets.js";
1212-1313-// Re-exported so existing consumers can keep importing from action-catalogue.
1414-// The data itself lives in follow-targets.ts (no icon / JSX dependencies) so
1515-// the backend action pipeline can read `appName` without pulling in UI code.
1616-export { FOLLOW_TARGET_META, type ColorKey };
1111+import { FOLLOW_TARGETS, type ColorKey, type FollowTarget } from "./follow-targets.js";
17121813export type AddableActionId =
1914 | "webhook"
···2116 | "record"
2217 | "patch-record"
2318 | "bookmark"
2424- | "follow-bluesky"
2525- | "follow-tangled"
2626- | "follow-sifa";
1919+ | `follow-${FollowTarget}`;
27202821type ActionInfo = {
2922 label: string;
···3730 faviconDomain?: string;
3831};
39323333+/** Build the catalogue tile for a given follow target. Insertion order in
3434+ * `FOLLOW_TARGETS` controls the order tiles appear within their category. */
3535+function followTileFor(target: FollowTarget) {
3636+ const t = FOLLOW_TARGETS[target];
3737+ return {
3838+ id: `follow-${target}` as const,
3939+ label: t.label,
4040+ description: t.description,
4141+ icon: UserPlus,
4242+ available: true,
4343+ colorKey: t.colorKey,
4444+ faviconDomain: t.faviconDomain,
4545+ };
4646+}
4747+4848+const followTilesByCat: Record<"bluesky" | "apps", ReturnType<typeof followTileFor>[]> = {
4949+ bluesky: [],
5050+ apps: [],
5151+};
5252+for (const target of Object.keys(FOLLOW_TARGETS) as FollowTarget[]) {
5353+ followTilesByCat[FOLLOW_TARGETS[target].catId].push(followTileFor(target));
5454+}
5555+4056export const ACTION_CATALOGUE = [
4157 {
4258 id: "webhook",
···6480 icon: MessageSquare,
6581 available: true,
6682 },
6767- {
6868- id: "follow-bluesky",
6969- label: FOLLOW_TARGET_META.bluesky.label,
7070- description: FOLLOW_TARGET_META.bluesky.description,
7171- icon: UserPlus,
7272- available: true,
7373- colorKey: FOLLOW_TARGET_META.bluesky.colorKey,
7474- faviconDomain: FOLLOW_TARGET_META.bluesky.faviconDomain,
7575- },
8383+ ...followTilesByCat.bluesky,
7684 {
7785 id: "bsky-like",
7886 label: "Like a post",
···95103 available: true,
96104 faviconDomain: "margin.at",
97105 },
9898- {
9999- id: "follow-sifa",
100100- label: FOLLOW_TARGET_META.sifa.label,
101101- description: FOLLOW_TARGET_META.sifa.description,
102102- icon: UserPlus,
103103- available: true,
104104- colorKey: FOLLOW_TARGET_META.sifa.colorKey,
105105- faviconDomain: FOLLOW_TARGET_META.sifa.faviconDomain,
106106- },
107107- {
108108- id: "follow-tangled",
109109- label: FOLLOW_TARGET_META.tangled.label,
110110- description: FOLLOW_TARGET_META.tangled.description,
111111- icon: UserPlus,
112112- available: true,
113113- colorKey: FOLLOW_TARGET_META.tangled.colorKey,
114114- faviconDomain: FOLLOW_TARGET_META.tangled.faviconDomain,
115115- },
106106+ ...followTilesByCat.apps,
116107 ],
117108 },
118109 {
+65-28
lib/automations/follow-targets.ts
···11-import type { FollowTarget } from "../db/schema.js";
22-31/** Keys that map to a CSS selector in action-header.css.ts. Keep in sync
42 * with the `&[data-cat="..."]` selectors there; a typo silently loses the
53 * accent otherwise. */
64export type ColorKey = "webhook" | "bluesky" | "pds" | "apps" | "sifa" | "tangled";
7588-/** Per-target metadata for follow actions. Lives in a pure-data module (no JSX
99- * / icon imports) so backend code paths like `executeFollow` and the dry-run
1010- * logger can read `appName` without pulling in UI components. The action
1111- * catalogue in `action-catalogue.ts` re-exports this for UI consumers. */
1212-export const FOLLOW_TARGET_META: Record<
1313- FollowTarget,
1414- {
1515- catId: "bluesky" | "apps";
1616- colorKey: ColorKey;
1717- appName: string;
1818- label: string;
1919- faviconDomain: string;
2020- description: string;
2121- }
2222-> = {
66+type FollowTargetEntry = {
77+ /** Catalogue category the tile lives under. */
88+ catId: "bluesky" | "apps";
99+ /** Theme color token (`data-cat` selector + vars.color.<key>). */
1010+ colorKey: ColorKey;
1111+ /** Display name of the target app. */
1212+ appName: string;
1313+ /** Catalogue tile label. */
1414+ label: string;
1515+ /** Catalogue tile description. */
1616+ description: string;
1717+ /** Domain used to render the per-app favicon. */
1818+ faviconDomain: string;
1919+ /** NSID where the follow record is written. */
2020+ collection: string;
2121+ /** NSID of the profile record we pre-flight check the subject for. */
2222+ profileCollection: string;
2323+ /** Rkey of that profile record (effectively always "self" today, but kept
2424+ * per-target for the rare case a future graph diverges). */
2525+ profileRkey: string;
2626+};
2727+2828+/** Single source of truth for every code-side follow-target detail. Adding a
2929+ * fourth social graph is one entry here + a matching push to the lexicon's
3030+ * `followAction.target.knownValues`; the lexicon parity test in
3131+ * `action-catalogue.test.ts` enforces the latter.
3232+ *
3333+ * Insertion order doubles as the catalogue tile order within each category
3434+ * (Bluesky tile group: bsky-post → follow-bluesky → ...; Apps tile group:
3535+ * bookmark → follow-sifa → follow-tangled). Reorder here to reorder the UI.
3636+ *
3737+ * Pure-data module: no JSX / icon imports, so backend code paths can read
3838+ * `appName` etc. without pulling in UI components. */
3939+export const FOLLOW_TARGETS = {
2340 bluesky: {
2441 catId: "bluesky",
2542 colorKey: "bluesky",
2643 appName: "Bluesky",
2744 label: "Follow on Bluesky",
2828- faviconDomain: "bsky.app",
2945 description: "Follow someone on Bluesky",
3030- },
3131- tangled: {
3232- catId: "apps",
3333- colorKey: "tangled",
3434- appName: "Tangled",
3535- label: "Follow on Tangled",
3636- faviconDomain: "tangled.sh",
3737- description: "Follow someone on Tangled",
4646+ faviconDomain: "bsky.app",
4747+ collection: "app.bsky.graph.follow",
4848+ profileCollection: "app.bsky.actor.profile",
4949+ profileRkey: "self",
3850 },
3951 sifa: {
4052 catId: "apps",
4153 colorKey: "sifa",
4254 appName: "Sifa",
4355 label: "Follow on Sifa",
5656+ description: "Follow someone on Sifa",
4457 faviconDomain: "sifa.id",
4545- description: "Follow someone on Sifa",
5858+ collection: "id.sifa.graph.follow",
5959+ // Sifa's profile lexicon NSID literally ends in `.self` (see
6060+ // at://did:plc:2f2ahswozqy4v5lvu676375y/com.atproto.lexicon.schema/id.sifa.profile.self),
6161+ // and the rkey is also `self`, so the URI is `at://{did}/id.sifa.profile.self/self`.
6262+ profileCollection: "id.sifa.profile.self",
6363+ profileRkey: "self",
4664 },
4747-};
6565+ tangled: {
6666+ catId: "apps",
6767+ colorKey: "tangled",
6868+ appName: "Tangled",
6969+ label: "Follow on Tangled",
7070+ description: "Follow someone on Tangled",
7171+ faviconDomain: "tangled.sh",
7272+ collection: "sh.tangled.graph.follow",
7373+ profileCollection: "sh.tangled.actor.profile",
7474+ profileRkey: "self",
7575+ },
7676+} as const satisfies Record<string, FollowTargetEntry>;
7777+7878+/** Derived from the registry keys — the only place the union lives. */
7979+export type FollowTarget = keyof typeof FOLLOW_TARGETS;
8080+8181+/** Runtime allow-list for incoming follow-target strings (validation, public
8282+ * API normalization). `string` so it can compare against unvalidated inputs
8383+ * without a cast. */
8484+export const VALID_FOLLOW_TARGETS: ReadonlySet<string> = new Set(Object.keys(FOLLOW_TARGETS));