# Backlink Reservoir Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build the GLOBAL `backlink/reservoir` page — a dense, sortable/reorderable table of ~400 link sources seeded from the cleaned "Startup Directory Master List" TSV, reusing the keywords reservoir table template + colors, with DA shown as an inverted color gradient and a named-projects "Add to project" layer.

**Architecture:** Pure data layer (`src/lib/backlink/`: types, URL/dedup normalization, projects state, generated fixtures) with `bun:test` coverage for all pure functions. UI layer (`src/components/backlink/`) reuses the generic `DataTable` (sorting / column reorder / visibility) and the `MetricBadge` color idiom. A client container owns table state (filters, visibility, selection) and the projects `Membership` state, passing `membership` + `projects` through TanStack `table.options.meta`.

**Tech Stack:** Next.js 16 App Router (route group `(app)`), React 19, TanStack Table v8, shadcn/ui, Tailwind, bun + bun:test, biome.

**Conventions (non-negotiable):**
- Pure functions only in tests — there is **no React test runner**; UI tasks are verified with `bunx tsc --noEmit` + `bunx biome check` (and a manual note), never a React unit test.
- **No `Math.random` / `Date.now`** anywhere — seeds and ids are deterministic.
- Conventional Commits. Branch is already `feature/backlink-reservoir`. Commit after every task.
- Do **not** touch keywords files (`src/components/keywords/**`, `src/lib/keywords/**`) — there are uncommitted keyword edits to leave alone. Replicate small helpers instead of refactoring shared code across the keywords/backlink boundary.
- Verify each commit with `git add <explicit paths>` — never `git add -A` (uncommitted keyword leftovers must stay unstaged).

**Spec:** `docs/superpowers/specs/2026-06-04-backlink-reservoir-design.md`

---

## File Structure

```
src/lib/backlink/
  types.ts            # LinkSource + enums + label constants (pure)
  normalize.ts        # normalizeUrl / domainNorm / dedupeMerge (pure)
  normalize.test.ts   # bun:test
  projects.ts         # Project, Membership + helpers (pure)
  projects.test.ts    # bun:test
  fixtures.ts         # BACKLINK_SOURCES (~400, GENERATED — do not hand-edit)
  fixtures.test.ts    # bun:test — invariants on the generated data
scripts/
  seed-backlinks.ts   # one-shot TSV→fixtures.ts generator (run once, kept in repo)
src/components/backlink/
  da-badge.tsx            # DA gradient badge (inverted: high = green)
  source-badges.tsx       # KindBadge / LinkTypeBadge / EffortBadge / FlagIcons
  add-to-project-menu.tsx # dropdown: checkbox per project + "New project…"
  backlink-filters-bar.tsx# search + kind/linkType/effort selects + DA range + columns toggle
  columns.tsx             # ColumnDef<LinkSource>[] + TableMeta augmentation
  reservoir-container.tsx # client container: state + toolbar + bulk bar + DataTable
src/app/(app)/backlink/reservoir/page.tsx
docs/project/pages/backlink/reservoir.md   # build brief
```

---

## Task 1: Types & label constants

**Files:**
- Create: `src/lib/backlink/types.ts`

- [ ] **Step 1: Write the types & constants**

```ts
// src/lib/backlink/types.ts

export type LinkType = "dofollow" | "nofollow" | "mixed";
export type Effort = "low" | "medium" | "high";

export type SourceKind =
  | "directory"
  | "ai-directory"
  | "media"
  | "community"
  | "subreddit"
  | "review-site"
  | "launch-platform"
  | "blog"
  | "forum"
  | "marketplace"
  | "newsletter"
  | "design"
  | "platform"
  | "jobs"
  | "tools"
  | "other";

/** A GLOBAL link source — no site dimension (that lives in backlink/planning). */
export interface LinkSource {
  id: string; // deterministic slug derived from domainNorm
  platform: string; // "Product Hunt"
  url: string; // normalized (https, no trailing slash, lowercased host)
  domainNorm: string; // "producthunt.com" (no www) — dedup key
  kind: SourceKind;
  da: number | null; // 0-100; null = unknown
  effort: Effort;
  linkType: LinkType;
  language: string; // "en" for this dataset
  themes: string[]; // topical — empty at seed
  tags: string[]; // free — empty at seed
  flags: { toxic: boolean; disavow: boolean };
  note?: string;
}

export const SOURCE_KINDS: { id: SourceKind; label: string }[] = [
  { id: "directory", label: "Directory" },
  { id: "ai-directory", label: "AI Directory" },
  { id: "media", label: "Media" },
  { id: "community", label: "Community" },
  { id: "subreddit", label: "Subreddit" },
  { id: "review-site", label: "Review Site" },
  { id: "launch-platform", label: "Launch Platform" },
  { id: "blog", label: "Blog" },
  { id: "forum", label: "Forum" },
  { id: "marketplace", label: "Marketplace" },
  { id: "newsletter", label: "Newsletter" },
  { id: "design", label: "Design" },
  { id: "platform", label: "Platform" },
  { id: "jobs", label: "Jobs" },
  { id: "tools", label: "Tools" },
  { id: "other", label: "Autre" },
];

export const LINK_TYPES: LinkType[] = ["dofollow", "nofollow", "mixed"];
export const EFFORTS: Effort[] = ["low", "medium", "high"];

/** TSV "Category" string -> SourceKind. Unknown/empty -> "other". */
export const KIND_FROM_CATEGORY: Record<string, SourceKind> = {
  Directory: "directory",
  "AI Directory": "ai-directory",
  Media: "media",
  Community: "community",
  Subreddit: "subreddit",
  "Review Site": "review-site",
  "Launch Platform": "launch-platform",
  Blog: "blog",
  Forum: "forum",
  Marketplace: "marketplace",
  Newsletter: "newsletter",
  Design: "design",
  Platform: "platform",
  Jobs: "jobs",
  Tools: "tools",
  Creative: "design",
  Interviews: "media",
  "Beta Testing": "platform",
};

export const SOURCE_KIND_LABEL = new Map(
  SOURCE_KINDS.map((k) => [k.id, k.label]),
);
```

- [ ] **Step 2: Verify it typechecks**

Run: `bunx tsc --noEmit`
Expected: clean exit (no errors mentioning `src/lib/backlink/types.ts`).

- [ ] **Step 3: Commit**

```bash
git add src/lib/backlink/types.ts
git commit -m "feat(backlink): link source types + kind/linktype/effort constants"
```

---

## Task 2: URL normalization & domain dedup (TDD)

**Files:**
- Create: `src/lib/backlink/normalize.ts`
- Test: `src/lib/backlink/normalize.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// src/lib/backlink/normalize.test.ts
import { describe, expect, test } from "bun:test";
import { dedupeMerge, domainNorm, normalizeUrl } from "./normalize";
import type { LinkSource } from "./types";

const base = (over: Partial<LinkSource>): LinkSource => ({
  id: "x",
  platform: "X",
  url: "https://x.com",
  domainNorm: "x.com",
  kind: "directory",
  da: null,
  effort: "medium",
  linkType: "nofollow",
  language: "en",
  themes: [],
  tags: [],
  flags: { toxic: false, disavow: false },
  ...over,
});

describe("normalizeUrl", () => {
  test("forces https and strips trailing slash", () => {
    expect(normalizeUrl("http://behance.net/")).toBe("https://behance.net");
  });
  test("lowercases the host but keeps the path", () => {
    expect(normalizeUrl("https://Biggerpockets.com")).toBe(
      "https://biggerpockets.com",
    );
    expect(normalizeUrl("https://news.ycombinator.com/showhn.html")).toBe(
      "https://news.ycombinator.com/showhn.html",
    );
  });
  test("returns empty string for blank input", () => {
    expect(normalizeUrl("")).toBe("");
    expect(normalizeUrl("   ")).toBe("");
  });
});

describe("domainNorm", () => {
  test("drops www and lowercases", () => {
    expect(domainNorm("https://www.ProductHunt.com/")).toBe("producthunt.com");
  });
  test("keeps subdomains other than www", () => {
    expect(domainNorm("http://news.ycombinator.com/showhn.html")).toBe(
      "news.ycombinator.com",
    );
  });
});

describe("dedupeMerge", () => {
  test("merges same domainNorm keeping the highest DA and union of tags", () => {
    const a = base({ id: "a", domainNorm: "dup.com", da: 40, tags: ["x"] });
    const b = base({ id: "b", domainNorm: "dup.com", da: 80, tags: ["y"] });
    const { sources, merges } = dedupeMerge([a, b]);
    expect(sources).toHaveLength(1);
    expect(sources[0].da).toBe(80);
    expect(sources[0].tags.sort()).toEqual(["x", "y"]);
    expect(merges).toHaveLength(1);
    expect(merges[0].domainNorm).toBe("dup.com");
  });
  test("treats null DA as lower than any number", () => {
    const a = base({ id: "a", domainNorm: "d.com", da: null });
    const b = base({ id: "b", domainNorm: "d.com", da: 10 });
    const { sources } = dedupeMerge([a, b]);
    expect(sources[0].da).toBe(10);
  });
  test("leaves distinct domains untouched", () => {
    const { sources, merges } = dedupeMerge([
      base({ id: "a", domainNorm: "a.com" }),
      base({ id: "b", domainNorm: "b.com" }),
    ]);
    expect(sources).toHaveLength(2);
    expect(merges).toHaveLength(0);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test src/lib/backlink/normalize.test.ts`
Expected: FAIL — `Cannot find module './normalize'`.

- [ ] **Step 3: Write minimal implementation**

```ts
// src/lib/backlink/normalize.ts
import type { LinkSource } from "./types";

/** Force https, lowercase host, strip a single trailing slash. "" on blank/invalid. */
export function normalizeUrl(raw: string): string {
  const s = raw.trim();
  if (!s) return "";
  const withProto = /^https?:\/\//i.test(s) ? s : `https://${s}`;
  try {
    const u = new URL(withProto);
    u.protocol = "https:";
    u.hostname = u.hostname.toLowerCase();
    let out = u.toString();
    if (out.endsWith("/") && u.pathname === "/") out = out.slice(0, -1);
    return out;
  } catch {
    return "";
  }
}

/** Registrable-ish host without leading "www.", lowercased. "" on blank/invalid. */
export function domainNorm(raw: string): string {
  const s = raw.trim();
  if (!s) return "";
  const withProto = /^https?:\/\//i.test(s) ? s : `https://${s}`;
  try {
    return new URL(withProto).hostname.toLowerCase().replace(/^www\./, "");
  } catch {
    return "";
  }
}

export interface MergeLog {
  domainNorm: string;
  kept: string; // platform kept
  dropped: string; // platform dropped
}

const daRank = (da: number | null): number =>
  da == null ? Number.NEGATIVE_INFINITY : da;

/** Collapse sources sharing a domainNorm: keep highest DA, union themes/tags. */
export function dedupeMerge(input: LinkSource[]): {
  sources: LinkSource[];
  merges: MergeLog[];
} {
  const byDomain = new Map<string, LinkSource>();
  const merges: MergeLog[] = [];
  for (const src of input) {
    const existing = byDomain.get(src.domainNorm);
    if (!existing) {
      byDomain.set(src.domainNorm, src);
      continue;
    }
    const keepNew = daRank(src.da) > daRank(existing.da);
    const winner = keepNew ? src : existing;
    const loser = keepNew ? existing : src;
    byDomain.set(src.domainNorm, {
      ...winner,
      themes: [...new Set([...existing.themes, ...src.themes])],
      tags: [...new Set([...existing.tags, ...src.tags])],
      flags: {
        toxic: existing.flags.toxic || src.flags.toxic,
        disavow: existing.flags.disavow || src.flags.disavow,
      },
    });
    merges.push({
      domainNorm: src.domainNorm,
      kept: winner.platform,
      dropped: loser.platform,
    });
  }
  return { sources: [...byDomain.values()], merges };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test src/lib/backlink/normalize.test.ts`
Expected: PASS (all cases).

- [ ] **Step 5: Verify typecheck & lint**

Run: `bunx tsc --noEmit && bunx biome check src/lib/backlink/normalize.ts src/lib/backlink/normalize.test.ts`
Expected: clean.

- [ ] **Step 6: Commit**

```bash
git add src/lib/backlink/normalize.ts src/lib/backlink/normalize.test.ts
git commit -m "feat(backlink): URL/domain normalization + domain dedup-merge"
```

---

## Task 3: Projects state (TDD)

**Files:**
- Create: `src/lib/backlink/projects.ts`
- Test: `src/lib/backlink/projects.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// src/lib/backlink/projects.test.ts
import { describe, expect, test } from "bun:test";
import {
  addToProject,
  createProject,
  type Membership,
  projectsOf,
  removeFromProject,
  SEED_PROJECTS,
} from "./projects";

describe("createProject", () => {
  test("derives a deterministic slug id", () => {
    const { project } = createProject([], "Campagne Été");
    expect(project.id).toBe("campagne-ete");
    expect(project.name).toBe("Campagne Été");
  });
  test("suffixes on slug collision", () => {
    const { projects, project } = createProject(
      [{ id: "promo", name: "Promo" }],
      "Promo",
    );
    expect(project.id).toBe("promo-2");
    expect(projects).toHaveLength(2);
  });
});

describe("addToProject", () => {
  test("adds multiple sources to a project, deduping", () => {
    let m: Membership = {};
    m = addToProject(m, ["s1", "s2"], "p1");
    m = addToProject(m, ["s1"], "p1"); // duplicate
    expect(m.s1).toEqual(["p1"]);
    expect(m.s2).toEqual(["p1"]);
  });
  test("a source can belong to several projects", () => {
    let m: Membership = {};
    m = addToProject(m, ["s1"], "p1");
    m = addToProject(m, ["s1"], "p2");
    expect(m.s1.sort()).toEqual(["p1", "p2"]);
  });
});

describe("removeFromProject", () => {
  test("removes one project and drops empty entries", () => {
    let m: Membership = {};
    m = addToProject(m, ["s1"], "p1");
    m = removeFromProject(m, "s1", "p1");
    expect(m.s1).toBeUndefined();
  });
});

describe("projectsOf", () => {
  test("returns projects in SEED_PROJECTS order", () => {
    const projects = [
      { id: "b", name: "B" },
      { id: "a", name: "A" },
    ];
    const m: Membership = { s1: ["a", "b"] };
    expect(projectsOf(m, projects, "s1").map((p) => p.id)).toEqual(["b", "a"]);
  });
  test("seed ships two placeholder projects", () => {
    expect(SEED_PROJECTS).toHaveLength(2);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test src/lib/backlink/projects.test.ts`
Expected: FAIL — `Cannot find module './projects'`.

- [ ] **Step 3: Write minimal implementation**

```ts
// src/lib/backlink/projects.ts

export interface Project {
  id: string;
  name: string;
  // siteId assigned later in backlink/planning
}

/** sourceId -> projectIds. No entry = in no project. */
export type Membership = Record<string, string[]>;

/** Two neutral placeholders so the UI isn't empty on first load. */
export const SEED_PROJECTS: Project[] = [
  { id: "projet-1", name: "Projet exemple 1" },
  { id: "projet-2", name: "Projet exemple 2" },
];

/** Deterministic slug — lowercase, strip accents, non-alnum -> "-". */
function slugify(name: string): string {
  return name
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

export function listProjects(projects: Project[]): Project[] {
  return projects;
}

/** Create a project with a collision-safe slug id; returns the new list + project. */
export function createProject(
  projects: Project[],
  name: string,
): { projects: Project[]; project: Project } {
  const base = slugify(name) || "projet";
  let id = base;
  let n = 2;
  const taken = new Set(projects.map((p) => p.id));
  while (taken.has(id)) {
    id = `${base}-${n}`;
    n += 1;
  }
  const project: Project = { id, name: name.trim() };
  return { projects: [...projects, project], project };
}

/** Add sources to a project (immutable, deduped). */
export function addToProject(
  m: Membership,
  sourceIds: string[],
  projectId: string,
): Membership {
  const next = { ...m };
  for (const sid of sourceIds) {
    const current = next[sid] ?? [];
    if (current.includes(projectId)) continue;
    next[sid] = [...current, projectId];
  }
  return next;
}

/** Remove a project from a source; drop the entry when it becomes empty. */
export function removeFromProject(
  m: Membership,
  sourceId: string,
  projectId: string,
): Membership {
  const current = m[sourceId];
  if (!current) return m;
  const kept = current.filter((p) => p !== projectId);
  const next = { ...m };
  if (kept.length === 0) delete next[sourceId];
  else next[sourceId] = kept;
  return next;
}

/** Projects a source belongs to, in the given projects list order. */
export function projectsOf(
  m: Membership,
  projects: Project[],
  sourceId: string,
): Project[] {
  const ids = new Set(m[sourceId] ?? []);
  return projects.filter((p) => ids.has(p.id));
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test src/lib/backlink/projects.test.ts`
Expected: PASS.

- [ ] **Step 5: Verify typecheck & lint**

Run: `bunx tsc --noEmit && bunx biome check src/lib/backlink/projects.ts src/lib/backlink/projects.test.ts`
Expected: clean.

- [ ] **Step 6: Commit**

```bash
git add src/lib/backlink/projects.ts src/lib/backlink/projects.test.ts
git commit -m "feat(backlink): named-projects membership state + helpers"
```

---

## Task 4: Seed generator + generated fixtures (TDD on invariants)

**Files:**
- Create: `scripts/seed-backlinks.ts`
- Create (generated): `src/lib/backlink/fixtures.ts`
- Test: `src/lib/backlink/fixtures.test.ts`

> The TSV lives OUTSIDE the repo at `~/Downloads/outrank_400+ directories.xlsx - Directories.tsv`. The generator reads it via a path argument and writes `fixtures.ts`. The test asserts invariants on the generated data (not the raw TSV), so it stays green in CI without the TSV.

- [ ] **Step 1: Write the invariant test**

```ts
// src/lib/backlink/fixtures.test.ts
import { describe, expect, test } from "bun:test";
import { BACKLINK_SOURCES } from "./fixtures";
import { SOURCE_KINDS } from "./types";

const KIND_IDS = new Set(SOURCE_KINDS.map((k) => k.id));

describe("BACKLINK_SOURCES (generated seed)", () => {
  test("loads a substantial, deduped set", () => {
    expect(BACKLINK_SOURCES.length).toBeGreaterThan(300);
    const domains = BACKLINK_SOURCES.map((s) => s.domainNorm);
    expect(new Set(domains).size).toBe(domains.length); // unique
  });
  test("every url is https and every id/domain is non-empty", () => {
    for (const s of BACKLINK_SOURCES) {
      expect(s.url.startsWith("https://")).toBe(true);
      expect(s.id.length).toBeGreaterThan(0);
      expect(s.domainNorm.length).toBeGreaterThan(0);
    }
  });
  test("ids are unique", () => {
    const ids = BACKLINK_SOURCES.map((s) => s.id);
    expect(new Set(ids).size).toBe(ids.length);
  });
  test("da is null or within 0..100; kind is valid", () => {
    for (const s of BACKLINK_SOURCES) {
      if (s.da !== null) {
        expect(s.da).toBeGreaterThanOrEqual(0);
        expect(s.da).toBeLessThanOrEqual(100);
      }
      expect(KIND_IDS.has(s.kind)).toBe(true);
    }
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test src/lib/backlink/fixtures.test.ts`
Expected: FAIL — `Cannot find module './fixtures'`.

- [ ] **Step 3: Write the generator script**

```ts
// scripts/seed-backlinks.ts
// Usage: bun scripts/seed-backlinks.ts "<path-to.tsv>"
// Emits src/lib/backlink/fixtures.ts and logs merged duplicates.
import { readFileSync, writeFileSync } from "node:fs";
import { dedupeMerge, domainNorm, normalizeUrl } from "../src/lib/backlink/normalize";
import {
  type Effort,
  KIND_FROM_CATEGORY,
  type LinkSource,
  type LinkType,
} from "../src/lib/backlink/types";

const tsvPath = process.argv[2];
if (!tsvPath) throw new Error("Pass the TSV path as argv[2]");

const EFFORT: Record<string, Effort> = { Low: "low", Medium: "medium", High: "high" };
const LINKTYPE: Record<string, LinkType> = {
  "Do-Follow": "dofollow",
  "No-Follow": "nofollow",
  Mixed: "mixed",
};

function slugify(s: string): string {
  return s
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

const lines = readFileSync(tsvPath, "utf8").split("\n");
const raw: LinkSource[] = [];

for (const line of lines) {
  const c = line.split("\t");
  // TSV has an empty leading column: [ "", Platform, URL, Category, DA, Effort, LinkType, Status, Link, Project ]
  const platform = (c[1] ?? "").trim();
  const urlCell = (c[2] ?? "").trim();
  const category = (c[3] ?? "").trim();
  const daCell = (c[4] ?? "").trim();
  const effortCell = (c[5] ?? "").trim();
  const linkTypeCell = (c[6] ?? "").trim();

  // skip header/junk/empty rows
  if (!platform || platform === "Platform") continue;
  if (platform.includes("outrank.so") || category.startsWith("→")) continue;

  const url = normalizeUrl(urlCell);
  const dn = domainNorm(urlCell);
  if (!url || !dn) continue;

  const da =
    daCell === "" || daCell === "—" || Number.isNaN(Number(daCell))
      ? null
      : Number(daCell);

  raw.push({
    id: slugify(dn),
    platform,
    url,
    domainNorm: dn,
    kind: KIND_FROM_CATEGORY[category] ?? "other",
    da,
    effort: EFFORT[effortCell] ?? "medium",
    linkType: LINKTYPE[linkTypeCell] ?? "nofollow",
    language: "en",
    themes: [],
    tags: [],
    flags: { toxic: false, disavow: false },
  });
}

const { sources, merges } = dedupeMerge(raw);

// ensure unique ids after dedup (distinct domains can slugify to the same id)
const seen = new Map<string, number>();
for (const s of sources) {
  const n = seen.get(s.id) ?? 0;
  if (n > 0) s.id = `${s.id}-${n + 1}`;
  seen.set(s.id, n + 1);
}

// stable order: by platform name
sources.sort((a, b) => a.platform.localeCompare(b.platform, "en"));

const header =
  "// src/lib/backlink/fixtures.ts\n" +
  "// GENERATED by scripts/seed-backlinks.ts — do not hand-edit.\n" +
  'import type { LinkSource } from "./types";\n\n' +
  "export const BACKLINK_SOURCES: LinkSource[] = ";
writeFileSync(
  "src/lib/backlink/fixtures.ts",
  `${header}${JSON.stringify(sources, null, 2)};\n`,
  "utf8",
);

console.log(`Wrote ${sources.length} sources (from ${raw.length} rows).`);
console.log(`Merged ${merges.length} duplicate domains:`);
for (const m of merges) {
  console.log(`  ${m.domainNorm}: kept "${m.kept}", dropped "${m.dropped}"`);
}
```

- [ ] **Step 4: Run the generator**

Run: `bun scripts/seed-backlinks.ts "$HOME/Downloads/outrank_400+ directories.xlsx - Directories.tsv"`
Expected: prints `Wrote <N> sources (from <M> rows).` with N > 300, then the list of merged domains. **Capture that merge log to report to the user.**

- [ ] **Step 5: Format the generated file**

Run: `bunx biome check --write src/lib/backlink/fixtures.ts`
Expected: file reformatted to project style, exits clean.

- [ ] **Step 6: Run the invariant test to verify it passes**

Run: `bun test src/lib/backlink/fixtures.test.ts`
Expected: PASS.

- [ ] **Step 7: Verify full typecheck**

Run: `bunx tsc --noEmit`
Expected: clean.

- [ ] **Step 8: Commit**

```bash
git add scripts/seed-backlinks.ts src/lib/backlink/fixtures.ts src/lib/backlink/fixtures.test.ts
git commit -m "feat(backlink): seed ~400 link sources from cleaned directory TSV"
```

---

## Task 5: DA badge (inverted gradient)

**Files:**
- Create: `src/components/backlink/da-badge.tsx`

> Self-contained (does NOT import from keywords/metric-badge — that file has uncommitted keyword edits). MetricBadge maps t=0→green, t=1→red; DA is inverted so high DA = green via `t = 1 - da/100`.

- [ ] **Step 1: Write the component**

```tsx
// src/components/backlink/da-badge.tsx

/** Map 0..1 to green→orange→red (same warm curve as keywords metric badges). */
function scoreStyle(t: number): React.CSSProperties {
  const clamped = Math.min(Math.max(t, 0), 1);
  const hue = 120 * (1 - clamped) ** 2;
  return { backgroundColor: `hsl(${hue} 78% 45%)` };
}

/**
 * Domain Authority on a fixed 0–100 scale, INVERTED vs KD:
 * high DA = strong = green, low DA = weak = red. null = unknown = grey "—".
 */
export function DaBadge({ value }: { value: number | null }) {
  if (value === null) {
    return (
      <span className="inline-flex min-w-10 items-center justify-center rounded-md bg-muted px-2 py-0.5 text-muted-foreground text-xs font-medium tabular-nums">
        —
      </span>
    );
  }
  return (
    <span
      className="inline-flex min-w-10 items-center justify-center rounded-md px-2 py-0.5 text-black dark:text-black text-xs font-medium tabular-nums"
      style={scoreStyle(1 - value / 100)}
      title={`DA ${value}/100`}
    >
      {value}
    </span>
  );
}
```

- [ ] **Step 2: Verify typecheck & lint**

Run: `bunx tsc --noEmit && bunx biome check src/components/backlink/da-badge.tsx`
Expected: clean.

- [ ] **Step 3: Commit**

```bash
git add src/components/backlink/da-badge.tsx
git commit -m "feat(backlink): DA badge with inverted color gradient"
```

---

## Task 6: Source badges (kind / link type / effort / flags)

**Files:**
- Create: `src/components/backlink/source-badges.tsx`

- [ ] **Step 1: Write the components**

```tsx
// src/components/backlink/source-badges.tsx
import { ShieldAlertIcon, SlashIcon } from "lucide-react";
import type { Effort, LinkType, SourceKind } from "@/lib/backlink/types";
import { SOURCE_KIND_LABEL } from "@/lib/backlink/types";
import { cn } from "@/lib/utils";

export function KindBadge({ kind }: { kind: SourceKind }) {
  return (
    <span className="inline-flex items-center rounded-md border border-border px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
      {SOURCE_KIND_LABEL.get(kind) ?? kind}
    </span>
  );
}

const LINK_TYPE_META: Record<LinkType, { label: string; className: string }> = {
  dofollow: { label: "dofollow", className: "bg-emerald-600 text-white" },
  nofollow: {
    label: "nofollow",
    className: "border border-border text-muted-foreground",
  },
  mixed: { label: "mixed", className: "bg-amber-500 text-black" },
};

export function LinkTypeBadge({ value }: { value: LinkType }) {
  const m = LINK_TYPE_META[value];
  return (
    <span
      className={cn(
        "inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium",
        m.className,
      )}
    >
      {m.label}
    </span>
  );
}

const EFFORT_META: Record<Effort, { label: string; className: string }> = {
  low: { label: "Low", className: "bg-emerald-600 text-white" },
  medium: { label: "Medium", className: "bg-amber-500 text-black" },
  high: { label: "High", className: "bg-red-600 text-white" },
};

export function EffortBadge({ value }: { value: Effort }) {
  const m = EFFORT_META[value];
  return (
    <span
      className={cn(
        "inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium",
        m.className,
      )}
    >
      {m.label}
    </span>
  );
}

export function FlagIcons({
  flags,
}: {
  flags: { toxic: boolean; disavow: boolean };
}) {
  if (!flags.toxic && !flags.disavow)
    return <span className="text-muted-foreground">—</span>;
  return (
    <span className="inline-flex items-center gap-1">
      {flags.toxic ? (
        <ShieldAlertIcon
          className="size-4 text-red-600"
          aria-label="toxic"
        />
      ) : null}
      {flags.disavow ? (
        <SlashIcon className="size-4 text-amber-600" aria-label="disavow" />
      ) : null}
    </span>
  );
}
```

- [ ] **Step 2: Verify typecheck & lint**

Run: `bunx tsc --noEmit && bunx biome check src/components/backlink/source-badges.tsx`
Expected: clean.

- [ ] **Step 3: Commit**

```bash
git add src/components/backlink/source-badges.tsx
git commit -m "feat(backlink): kind/linktype/effort/flags badges"
```

---

## Task 7: Add-to-project menu

**Files:**
- Create: `src/components/backlink/add-to-project-menu.tsx`

> Reuses shadcn `DropdownMenu` + `DropdownMenuCheckboxItem` (same primitives as the keywords filters bar). Multi-select projects + a "New project…" inline input. Pure presentational: all state changes are reported via callbacks.

- [ ] **Step 1: Write the component**

```tsx
// src/components/backlink/add-to-project-menu.tsx
"use client";

import { FolderPlusIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import type { Project } from "@/lib/backlink/projects";

interface Props {
  projects: Project[];
  /** Projects the target source(s) already belong to (checked state). */
  activeProjectIds: string[];
  triggerLabel: string;
  onToggleProject: (projectId: string, checked: boolean) => void;
  onCreateProject: (name: string) => void;
}

export function AddToProjectMenu({
  projects,
  activeProjectIds,
  triggerLabel,
  onToggleProject,
  onCreateProject,
}: Props) {
  const [name, setName] = useState("");
  const active = new Set(activeProjectIds);

  function submitNew() {
    const trimmed = name.trim();
    if (!trimmed) return;
    onCreateProject(trimmed);
    setName("");
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="sm">
          <FolderPlusIcon className="size-4" aria-hidden />
          {triggerLabel}
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="start" className="w-56">
        <DropdownMenuLabel>Ajouter à un projet</DropdownMenuLabel>
        <DropdownMenuSeparator />
        {projects.length === 0 ? (
          <p className="px-2 py-1.5 text-muted-foreground text-xs">
            Aucun projet — créez-en un ci-dessous.
          </p>
        ) : (
          projects.map((p) => (
            <DropdownMenuCheckboxItem
              key={p.id}
              checked={active.has(p.id)}
              onCheckedChange={(v) => onToggleProject(p.id, !!v)}
              onSelect={(e) => e.preventDefault()}
            >
              {p.name}
            </DropdownMenuCheckboxItem>
          ))
        )}
        <DropdownMenuSeparator />
        <div className="flex items-center gap-1 p-1">
          <Input
            value={name}
            onChange={(e) => setName(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                e.preventDefault();
                submitNew();
              }
            }}
            placeholder="Nouveau projet…"
            aria-label="Nom du nouveau projet"
            className="h-8"
          />
          <Button
            type="button"
            size="icon"
            variant="ghost"
            className="size-8 shrink-0"
            onClick={submitNew}
            aria-label="Créer le projet"
          >
            <PlusIcon className="size-4" aria-hidden />
          </Button>
        </div>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
```

- [ ] **Step 2: Verify typecheck & lint**

Run: `bunx tsc --noEmit && bunx biome check src/components/backlink/add-to-project-menu.tsx`
Expected: clean.

- [ ] **Step 3: Commit**

```bash
git add src/components/backlink/add-to-project-menu.tsx
git commit -m "feat(backlink): add-to-project menu (multi-select + new project)"
```

---

## Task 8: Columns + TableMeta augmentation

**Files:**
- Create: `src/components/backlink/columns.tsx`

> Augments TanStack `TableMeta` with `membership` + `projects` (declaration merging with the existing keyword augmentations — all optional). Footprint is defined but hidden by default. The per-row "Projects" cell reads meta and renders project badges; a row-level add-to-project trigger lives in the container's bulk bar, so this column is display-only here.

- [ ] **Step 1: Write the columns**

```tsx
// src/components/backlink/columns.tsx
"use client";

import type { ColumnDef, RowData } from "@tanstack/react-table";
import { ExternalLinkIcon } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import {
  type Membership,
  type Project,
  projectsOf,
} from "@/lib/backlink/projects";
import type { LinkSource } from "@/lib/backlink/types";
import { DaBadge } from "./da-badge";
import {
  EffortBadge,
  FlagIcons,
  KindBadge,
  LinkTypeBadge,
} from "./source-badges";

declare module "@tanstack/react-table" {
  // biome-ignore lint/correctness/noUnusedVariables: TData name required for module augmentation (TS2428)
  interface TableMeta<TData extends RowData> {
    membership?: Membership;
    projects?: Project[];
  }
}

const arrIncludesSome = (
  row: { getValue: (id: string) => unknown },
  columnId: string,
  filterValue: string[],
) => {
  if (!filterValue?.length) return true;
  const cell = row.getValue(columnId);
  if (Array.isArray(cell)) return cell.some((v) => filterValue.includes(v));
  return filterValue.includes(cell as string);
};

/** DA range filter; null DA is excluded as soon as any bound is set. */
const daRange = (
  row: { getValue: (id: string) => unknown },
  columnId: string,
  [min, max]: [number | undefined, number | undefined],
) => {
  const v = row.getValue(columnId) as number | null;
  if (min == null && max == null) return true;
  if (v == null) return false;
  if (min != null && v < min) return false;
  if (max != null && v > max) return false;
  return true;
};

/** DA sort: nulls grouped at the bottom of the natural ascending order. */
const sortDa = (a: { original: LinkSource }, b: { original: LinkSource }) => {
  const x = a.original.da ?? Number.NEGATIVE_INFINITY;
  const y = b.original.da ?? Number.NEGATIVE_INFINITY;
  return x - y;
};

export const TOGGLEABLE_COLUMNS: { id: string; label: string }[] = [
  { id: "kind", label: "Kind" },
  { id: "da", label: "DA" },
  { id: "linkType", label: "Link Type" },
  { id: "effort", label: "Effort" },
  { id: "projects", label: "Projects" },
  { id: "footprint", label: "Footprint" },
  { id: "language", label: "Langue" },
  { id: "themes", label: "Thèmes" },
  { id: "tags", label: "Tags" },
  { id: "flags", label: "Flags" },
];

/** Footprint, langue, thèmes, tags hidden by default (empty in this slice). */
export const DEFAULT_COLUMN_VISIBILITY = {
  footprint: false,
  language: false,
  themes: false,
  tags: false,
};

export const columns: ColumnDef<LinkSource>[] = [
  {
    id: "select",
    enableGlobalFilter: false,
    enableSorting: false,
    header: ({ table }) => (
      <Checkbox
        checked={
          table.getIsAllRowsSelected() ||
          (table.getIsSomeRowsSelected() && "indeterminate")
        }
        onCheckedChange={(v) => table.toggleAllRowsSelected(!!v)}
        aria-label="Tout sélectionner"
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        checked={row.getIsSelected()}
        onCheckedChange={(v) => row.toggleSelected(!!v)}
        onClick={(e) => e.stopPropagation()}
        aria-label={`Sélectionner ${row.original.platform}`}
      />
    ),
  },
  {
    accessorKey: "platform",
    header: "Platform",
    cell: ({ row }) => (
      <div className="flex flex-col">
        <span className="font-medium">{row.original.platform}</span>
        <a
          href={row.original.url}
          target="_blank"
          rel="noopener noreferrer"
          onClick={(e) => e.stopPropagation()}
          className="inline-flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground"
        >
          {row.original.domainNorm}
          <ExternalLinkIcon className="size-3" aria-hidden />
        </a>
      </div>
    ),
  },
  {
    accessorKey: "kind",
    header: "Kind",
    enableGlobalFilter: false,
    filterFn: arrIncludesSome,
    cell: ({ row }) => <KindBadge kind={row.original.kind} />,
  },
  {
    accessorKey: "da",
    header: "DA",
    enableGlobalFilter: false,
    filterFn: daRange,
    sortingFn: sortDa,
    cell: ({ row }) => <DaBadge value={row.original.da} />,
  },
  {
    accessorKey: "linkType",
    header: "Link Type",
    enableGlobalFilter: false,
    filterFn: arrIncludesSome,
    cell: ({ row }) => <LinkTypeBadge value={row.original.linkType} />,
  },
  {
    accessorKey: "effort",
    header: "Effort",
    enableGlobalFilter: false,
    filterFn: arrIncludesSome,
    cell: ({ row }) => <EffortBadge value={row.original.effort} />,
  },
  {
    id: "projects",
    header: "Projects",
    enableGlobalFilter: false,
    enableSorting: false,
    cell: ({ row, table }) => {
      const m = table.options.meta?.membership ?? {};
      const projects = table.options.meta?.projects ?? [];
      const owned = projectsOf(m, projects, row.original.id);
      if (owned.length === 0)
        return <span className="text-muted-foreground">—</span>;
      return (
        <div className="flex flex-wrap gap-1">
          {owned.map((p) => (
            <span
              key={p.id}
              className="inline-flex items-center rounded-md bg-primary/10 px-1.5 py-0.5 text-primary text-xs font-medium"
            >
              {p.name}
            </span>
          ))}
        </div>
      );
    },
  },
  {
    id: "footprint",
    header: "Footprint",
    enableGlobalFilter: false,
    enableSorting: false,
    cell: () => <span className="text-muted-foreground">0/3</span>,
  },
  {
    accessorKey: "language",
    header: "Langue",
    enableGlobalFilter: false,
    filterFn: arrIncludesSome,
    cell: ({ row }) => (
      <span className="text-muted-foreground text-xs uppercase">
        {row.original.language}
      </span>
    ),
  },
  {
    id: "themes",
    header: "Thèmes",
    enableGlobalFilter: false,
    enableSorting: false,
    cell: ({ row }) =>
      row.original.themes.length ? (
        <span className="text-xs">{row.original.themes.join(", ")}</span>
      ) : (
        <span className="text-muted-foreground">—</span>
      ),
  },
  {
    id: "tags",
    header: "Tags",
    enableGlobalFilter: false,
    enableSorting: false,
    cell: ({ row }) =>
      row.original.tags.length ? (
        <span className="text-xs">{row.original.tags.join(", ")}</span>
      ) : (
        <span className="text-muted-foreground">—</span>
      ),
  },
  {
    id: "flags",
    header: "Flags",
    enableGlobalFilter: false,
    enableSorting: false,
    cell: ({ row }) => <FlagIcons flags={row.original.flags} />,
  },
];
```

- [ ] **Step 2: Verify typecheck & lint**

Run: `bunx tsc --noEmit && bunx biome check src/components/backlink/columns.tsx`
Expected: clean.

- [ ] **Step 3: Commit**

```bash
git add src/components/backlink/columns.tsx
git commit -m "feat(backlink): reservoir table columns + meta augmentation"
```

---

## Task 9: Filters bar

**Files:**
- Create: `src/components/backlink/backlink-filters-bar.tsx`

> Backlink-specific (kind / linkType / effort facets + DA range + column toggle). The facet/range helpers are replicated locally (small, and we must not refactor the keywords filters bar across the boundary).

- [ ] **Step 1: Write the component**

```tsx
// src/components/backlink/backlink-filters-bar.tsx
"use client";

import type {
  ColumnFiltersState,
  VisibilityState,
} from "@tanstack/react-table";
import { SlidersHorizontalIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { EFFORTS, LINK_TYPES, SOURCE_KINDS } from "@/lib/backlink/types";
import { TOGGLEABLE_COLUMNS } from "./columns";

interface Props {
  search: string;
  onSearch: (v: string) => void;
  columnFilters: ColumnFiltersState;
  onColumnFilters: (next: ColumnFiltersState) => void;
  columnVisibility: VisibilityState;
  onColumnVisibility: (next: VisibilityState) => void;
}

type Range = [number | undefined, number | undefined];

function rangeValue(filters: ColumnFiltersState, id: string): Range {
  return (
    (filters.find((f) => f.id === id)?.value as Range) ?? [undefined, undefined]
  );
}

function setRange(
  filters: ColumnFiltersState,
  id: string,
  idx: 0 | 1,
  raw: string,
): ColumnFiltersState {
  const next = [...rangeValue(filters, id)] as Range;
  next[idx] = raw === "" ? undefined : Number(raw);
  const rest = filters.filter((f) => f.id !== id);
  if (next[0] == null && next[1] == null) return rest;
  return [...rest, { id, value: next }];
}

function setFacet(
  filters: ColumnFiltersState,
  id: string,
  value: string,
): ColumnFiltersState {
  const rest = filters.filter((f) => f.id !== id);
  if (!value || value === "all") return rest;
  return [...rest, { id, value: [value] }];
}

const facetValue = (filters: ColumnFiltersState, id: string): string =>
  ((filters.find((f) => f.id === id)?.value as string[]) ?? [])[0] ?? "all";

export function BacklinkFiltersBar({
  search,
  onSearch,
  columnFilters,
  onColumnFilters,
  columnVisibility,
  onColumnVisibility,
}: Props) {
  const isVisible = (id: string) => columnVisibility[id] !== false;
  const [daMin, daMax] = rangeValue(columnFilters, "da");
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Input
        placeholder="Rechercher une source…"
        value={search}
        onChange={(e) => onSearch(e.target.value)}
        className="max-w-xs"
        aria-label="Rechercher une source"
      />

      <Select
        value={facetValue(columnFilters, "kind")}
        onValueChange={(v) => onColumnFilters(setFacet(columnFilters, "kind", v))}
      >
        <SelectTrigger className="w-44" aria-label="Filtrer par type de source">
          <SelectValue placeholder="Kind" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="all">Tous les types</SelectItem>
          {SOURCE_KINDS.map((k) => (
            <SelectItem key={k.id} value={k.id}>
              {k.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>

      <Select
        value={facetValue(columnFilters, "linkType")}
        onValueChange={(v) =>
          onColumnFilters(setFacet(columnFilters, "linkType", v))
        }
      >
        <SelectTrigger className="w-40" aria-label="Filtrer par type de lien">
          <SelectValue placeholder="Link Type" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="all">Tous les liens</SelectItem>
          {LINK_TYPES.map((t) => (
            <SelectItem key={t} value={t}>
              {t}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>

      <Select
        value={facetValue(columnFilters, "effort")}
        onValueChange={(v) =>
          onColumnFilters(setFacet(columnFilters, "effort", v))
        }
      >
        <SelectTrigger className="w-36" aria-label="Filtrer par effort">
          <SelectValue placeholder="Effort" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="all">Tous les efforts</SelectItem>
          {EFFORTS.map((e) => (
            <SelectItem key={e} value={e}>
              {e}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>

      <Popover>
        <PopoverTrigger asChild>
          <Button variant="outline" aria-label="Filtre de plage DA">
            DA
            {daMin != null || daMax != null ? " (1)" : ""}
          </Button>
        </PopoverTrigger>
        <PopoverContent className="w-72 space-y-1">
          <span className="text-sm font-medium">DA</span>
          <div className="flex items-center gap-2">
            <Input
              type="number"
              placeholder="min"
              value={daMin ?? ""}
              onChange={(e) =>
                onColumnFilters(setRange(columnFilters, "da", 0, e.target.value))
              }
              aria-label="DA minimum"
            />
            <span className="text-muted-foreground">–</span>
            <Input
              type="number"
              placeholder="max"
              value={daMax ?? ""}
              onChange={(e) =>
                onColumnFilters(setRange(columnFilters, "da", 1, e.target.value))
              }
              aria-label="DA maximum"
            />
          </div>
        </PopoverContent>
      </Popover>

      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button
            variant="outline"
            className="ml-auto"
            aria-label="Afficher ou masquer des colonnes"
          >
            <SlidersHorizontalIcon className="size-4" aria-hidden />
            Colonnes
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end" className="w-48">
          <DropdownMenuLabel>Colonnes</DropdownMenuLabel>
          <DropdownMenuSeparator />
          {TOGGLEABLE_COLUMNS.map((c) => (
            <DropdownMenuCheckboxItem
              key={c.id}
              checked={isVisible(c.id)}
              onCheckedChange={(v) =>
                onColumnVisibility({ ...columnVisibility, [c.id]: !!v })
              }
              onSelect={(e) => e.preventDefault()}
            >
              {c.label}
            </DropdownMenuCheckboxItem>
          ))}
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  );
}
```

- [ ] **Step 2: Verify typecheck & lint**

Run: `bunx tsc --noEmit && bunx biome check src/components/backlink/backlink-filters-bar.tsx`
Expected: clean.

- [ ] **Step 3: Commit**

```bash
git add src/components/backlink/backlink-filters-bar.tsx
git commit -m "feat(backlink): reservoir filters bar (kind/linktype/effort/DA + columns)"
```

---

## Task 10: Reservoir container

**Files:**
- Create: `src/components/backlink/reservoir-container.tsx`

> Owns all table state + projects/membership. Global reservoir: data is the static `BACKLINK_SOURCES` (no site). A bulk "Add to project" bar appears when ≥1 row is selected. Bulk toggle: checking a project adds all selected sources to it; unchecking removes them from it. The menu's checked state reflects projects ALL selected sources already share.

- [ ] **Step 1: Write the container**

```tsx
// src/components/backlink/reservoir-container.tsx
"use client";

import type {
  ColumnFiltersState,
  RowSelectionState,
  VisibilityState,
} from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { DataTable } from "@/components/ui/data-table";
import { BACKLINK_SOURCES } from "@/lib/backlink/fixtures";
import {
  addToProject,
  createProject,
  type Membership,
  type Project,
  removeFromProject,
  SEED_PROJECTS,
} from "@/lib/backlink/projects";
import { columns, DEFAULT_COLUMN_VISIBILITY } from "./columns";
import { AddToProjectMenu } from "./add-to-project-menu";
import { BacklinkFiltersBar } from "./backlink-filters-bar";

export function BacklinkReservoirContainer() {
  const [projects, setProjects] = useState<Project[]>(SEED_PROJECTS);
  const [membership, setMembership] = useState<Membership>({});

  const [search, setSearch] = useState("");
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
    DEFAULT_COLUMN_VISIBILITY,
  );
  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});

  const selectedIds = useMemo(
    () =>
      Object.entries(rowSelection)
        .filter(([, v]) => v)
        .map(([id]) => id),
    [rowSelection],
  );

  // projects shared by ALL selected sources -> checked in the bulk menu
  const sharedProjectIds = useMemo(() => {
    if (selectedIds.length === 0) return [];
    return projects
      .map((p) => p.id)
      .filter((pid) =>
        selectedIds.every((sid) => (membership[sid] ?? []).includes(pid)),
      );
  }, [selectedIds, projects, membership]);

  function toggleProjectForSelection(projectId: string, checked: boolean) {
    setMembership((prev) => {
      if (checked) return addToProject(prev, selectedIds, projectId);
      let next = prev;
      for (const sid of selectedIds)
        next = removeFromProject(next, sid, projectId);
      return next;
    });
  }

  function handleCreateProject(name: string) {
    const { projects: nextProjects, project } = createProject(projects, name);
    setProjects(nextProjects);
    setMembership((prev) => addToProject(prev, selectedIds, project.id));
  }

  return (
    <div className="space-y-3 p-4">
      <div className="flex items-center justify-between gap-2">
        <BacklinkFiltersBar
          search={search}
          onSearch={setSearch}
          columnFilters={columnFilters}
          onColumnFilters={setColumnFilters}
          columnVisibility={columnVisibility}
          onColumnVisibility={setColumnVisibility}
        />
      </div>

      {selectedIds.length > 0 ? (
        <div className="flex items-center gap-3 rounded-md border bg-muted/40 px-3 py-2">
          <span className="text-sm font-medium">
            {selectedIds.length} sélectionnée
            {selectedIds.length > 1 ? "s" : ""}
          </span>
          <AddToProjectMenu
            projects={projects}
            activeProjectIds={sharedProjectIds}
            triggerLabel="Add to project"
            onToggleProject={toggleProjectForSelection}
            onCreateProject={handleCreateProject}
          />
          <button
            type="button"
            onClick={() => setRowSelection({})}
            className="text-muted-foreground text-sm hover:text-foreground"
          >
            Effacer
          </button>
        </div>
      ) : null}

      <DataTable
        columns={columns}
        data={BACKLINK_SOURCES}
        getRowId={(s) => s.id}
        globalFilter={search}
        columnFilters={columnFilters}
        columnVisibility={columnVisibility}
        rowSelection={rowSelection}
        onRowSelectionChange={setRowSelection}
        meta={{ membership, projects }}
        caption="Reservoir global de sources de liens"
      />
    </div>
  );
}
```

- [ ] **Step 2: Verify typecheck & lint**

Run: `bunx tsc --noEmit && bunx biome check src/components/backlink/reservoir-container.tsx`
Expected: clean. (Note: biome may want the import order adjusted — run `bunx biome check --write` on the file if it reports import-order, then re-run.)

- [ ] **Step 3: Commit**

```bash
git add src/components/backlink/reservoir-container.tsx
git commit -m "feat(backlink): reservoir container with bulk add-to-project"
```

---

## Task 11: Route page + build brief + nav

**Files:**
- Create: `src/app/(app)/backlink/reservoir/page.tsx`
- Create: `docs/project/pages/backlink/reservoir.md`
- Inspect/Modify: the sidebar nav (search for where `keywords` routes are listed)

- [ ] **Step 1: Write the page**

```tsx
// src/app/(app)/backlink/reservoir/page.tsx
import { BacklinkReservoirContainer } from "@/components/backlink/reservoir-container";

export default function BacklinkReservoirPage() {
  return <BacklinkReservoirContainer />;
}
```

- [ ] **Step 2: Write the build brief**

```markdown
# backlink/reservoir — build brief

Route: `/backlink/reservoir`. GLOBAL pool of link sources (no site dimension).
Seeded from the cleaned "Startup Directory Master List" TSV (~400 directories).

- Table = keywords reservoir template (DataTable): sort, column reorder (drag /
  Alt+←/→), column show/hide.
- DA color gradient is INVERTED vs KD: high DA = green, low = red; null = grey "—".
- "Add to project" (bulk bar when rows selected): named projects, multi-select,
  "New project…". Site assignment happens later in backlink/planning.
- Footprint / langue / thèmes / tags columns exist but are hidden by default
  (empty in this slice).

Spec: docs/superpowers/specs/2026-06-04-backlink-reservoir-design.md
Deferred: per-site triage + matrix (planning), multi-provider metrics, footprint
population, toxic→disavow flows, dedup/merge UI.
```

- [ ] **Step 3: Find the sidebar nav**

Run: `grep -rn "keywords/reservoir\|/keywords\b" src/components src/app/\(app\)/layout.tsx`
Expected: locate the nav data (likely an `app-sidebar` or `nav-main` component). If a `keywords` group with sub-items exists, add a parallel `backlink` group with a `Reservoir` item pointing to `/backlink/reservoir`. If the nav is a flat list, add a single `Backlink — Reservoir` entry. If NO nav data structure is found, skip this step (the route still resolves directly).

- [ ] **Step 4: Verify build-time typecheck & lint**

Run: `bunx tsc --noEmit && bunx biome check src/app/\(app\)/backlink src/components/backlink`
Expected: clean.

- [ ] **Step 5: Run the full test suite**

Run: `bun test`
Expected: all prior tests + new backlink tests PASS (0 fail).

- [ ] **Step 6: Manual smoke (dev server)**

Run the dev server and open `/backlink/reservoir`. Confirm: table renders ~400 rows, DA badges show the inverted gradient (high=green), sort/reorder/hide work, selecting rows reveals the "Add to project" bar, creating a project + toggling it updates the Projects column. Tail the live dev log per project memory (`~/.d3k/seo-insight-c3a252/logs/`).

- [ ] **Step 7: Commit**

```bash
git add "src/app/(app)/backlink/reservoir/page.tsx" docs/project/pages/backlink/reservoir.md
# include the nav file too if Step 3 modified one
git commit -m "feat(backlink): reservoir route + build brief"
```

---

## Self-Review (completed during planning)

**Spec coverage:**
- §1.1 LinkSource/enums → Task 1. §1.2 projects → Task 3. §1.3 relations deferred → noted, no task (correct).
- §2 seed cleaning (drop junk, https, domainNorm, dedup-merge+log, mappings, null DA) → Tasks 2 + 4.
- §3 table (DataTable reuse, all columns, DaBadge inverted, footprint hidden, toolbar) → Tasks 5–9.
- §4 projects UI (Add to project bulk + new project, Projects column) → Tasks 7, 8, 10.
- §5 files → all created. §6 tests (pure only, no random) → Tasks 2,3,4. §8 acceptance → covered by Task 11 Steps 5–6.

**Placeholder scan:** No TBD/TODO. Every code step has complete code. The only intentionally open step is Task 11 Step 3 (nav discovery) — it gives explicit branch logic for each case found, which is appropriate for an unknown-shape existing file.

**Type consistency:** `LinkSource`, `Membership`, `Project`, `SourceKind`, `Effort`, `LinkType` names match across tasks. `addToProject(m, sourceIds[], projectId)`, `removeFromProject(m, sourceId, projectId)`, `createProject(projects, name)`, `projectsOf(m, projects, sourceId)` signatures are consistent between Task 3 (definition), Task 8 (columns use `projectsOf`), and Task 10 (container uses all four). `dedupeMerge` returns `{ sources, merges }` — consumed that way in Task 4. Column ids in `TOGGLEABLE_COLUMNS`/`DEFAULT_COLUMN_VISIBILITY` (Task 8) match the `accessorKey`/`id` of each column and the filter `id`s used by the filters bar (Task 9: `kind`, `linkType`, `effort`, `da`).
