diff --git a/components/FlagPicker.tsx b/components/FlagPicker.tsx
index 63a2fa9..5d5cdf6 100644
--- a/components/FlagPicker.tsx
+++ b/components/FlagPicker.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useCallback, useEffect, useId, useMemo, useReducer, useRef } from "react";
+import { Fragment, useCallback, useEffect, useId, useMemo, useReducer, useRef } from "react";
import { Pencil, Plus, Search, X } from "lucide-react";
-import { searchCountries } from "@/lib/countries";
+import { flagPickerList } from "@/lib/wc26";
import { comboboxReducer, initialComboboxState } from "@/lib/comboboxNav";
import { useClickOutside } from "@/hooks/useClickOutside";
@@ -41,7 +41,11 @@ export default function FlagPicker({ value, onChange }: Props) {
const listId = `${baseId}-list`;
const optionId = (i: number) => `${baseId}-opt-${i}`;
- const results = useMemo(() => searchCountries(query), [query]);
+ // Browsing shows the 48 World-Cup teams first, so a national team isn't buried
+ // 200 countries down; searching is left exactly as searchCountries ranked it
+ // (see flagPickerList). The list stays FLAT either way — the combobox addresses
+ // its options by index — so the group headers below are rendered as non-options.
+ const { list: results, qualifiedCount } = useMemo(() => flagPickerList(query), [query]);
const close = useCallback(() => dispatch({ type: "close" }), []);
useClickOutside(rootRef, open, close);
@@ -203,9 +207,28 @@ export default function FlagPicker({ value, onChange }: Props) {
{results.map((c, i) => {
const selected = c.code === value;
const highlighted = i === active;
+ // Headers sit at the two group boundaries and are NOT options: they
+ // carry role="presentation" so they stay out of the listbox's index
+ // space, which is what arrow keys and aria-activedescendant address.
+ const header =
+ qualifiedCount === 0
+ ? null
+ : i === 0
+ ? "World Cup 26"
+ : i === qualifiedCount
+ ? "All countries"
+ : null;
return (
+
+ {header && (
+
+ {header}
+
+ )}
{selected && }
+
);
})}
diff --git a/lib/wc26.ts b/lib/wc26.ts
new file mode 100644
index 0000000..831d292
--- /dev/null
+++ b/lib/wc26.ts
@@ -0,0 +1,125 @@
+import { type Country, searchCountries } from "./countries";
+
+// The 48 teams qualified for the 2026 World Cup, keyed by the flag asset code we
+// already ship (public/badges/flags/.png). This is the whole point of the
+// picker change: gitfut is a World-Cup-26 app, but the flag list is a flat ISO
+// dump of ~250 countries, so finding your national team means scrolling past 200
+// that aren't in the tournament.
+//
+// Codes are OUR flag keys, not ISO: England and Scotland are "eng"/"sct" (see
+// UK_NATIONS in countries.ts), which is exactly the kind of thing a hand-written
+// list gets wrong — so a test asserts every code below resolves to a real country
+// AND a real flag asset.
+//
+// Includes the three hosts (Canada, Mexico, USA) and the two inter-confederation
+// play-off winners (Iraq, DR Congo).
+
+export type Confederation = "AFC" | "CAF" | "CONCACAF" | "CONMEBOL" | "OFC" | "UEFA";
+
+export interface QualifiedTeam {
+ code: string; // flag asset key, lowercase
+ confederation: Confederation;
+}
+
+export const WC26_TEAMS: readonly QualifiedTeam[] = [
+ // AFC (9)
+ { code: "au", confederation: "AFC" },
+ { code: "ir", confederation: "AFC" },
+ { code: "iq", confederation: "AFC" }, // inter-confederation play-off winner
+ { code: "jp", confederation: "AFC" },
+ { code: "jo", confederation: "AFC" },
+ { code: "qa", confederation: "AFC" },
+ { code: "sa", confederation: "AFC" },
+ { code: "kr", confederation: "AFC" },
+ { code: "uz", confederation: "AFC" },
+ // CAF (10)
+ { code: "dz", confederation: "CAF" },
+ { code: "cv", confederation: "CAF" },
+ { code: "cd", confederation: "CAF" }, // inter-confederation play-off winner
+ { code: "eg", confederation: "CAF" },
+ { code: "gh", confederation: "CAF" },
+ { code: "ci", confederation: "CAF" },
+ { code: "ma", confederation: "CAF" },
+ { code: "sn", confederation: "CAF" },
+ { code: "za", confederation: "CAF" },
+ { code: "tn", confederation: "CAF" },
+ // CONCACAF (6)
+ { code: "ca", confederation: "CONCACAF" }, // host
+ { code: "cw", confederation: "CONCACAF" },
+ { code: "ht", confederation: "CONCACAF" },
+ { code: "mx", confederation: "CONCACAF" }, // host
+ { code: "pa", confederation: "CONCACAF" },
+ { code: "us", confederation: "CONCACAF" }, // host
+ // CONMEBOL (6)
+ { code: "ar", confederation: "CONMEBOL" },
+ { code: "br", confederation: "CONMEBOL" },
+ { code: "co", confederation: "CONMEBOL" },
+ { code: "ec", confederation: "CONMEBOL" },
+ { code: "py", confederation: "CONMEBOL" },
+ { code: "uy", confederation: "CONMEBOL" },
+ // OFC (1)
+ { code: "nz", confederation: "OFC" },
+ // UEFA (16)
+ { code: "at", confederation: "UEFA" },
+ { code: "be", confederation: "UEFA" },
+ { code: "ba", confederation: "UEFA" },
+ { code: "hr", confederation: "UEFA" },
+ { code: "cz", confederation: "UEFA" },
+ { code: "eng", confederation: "UEFA" },
+ { code: "fr", confederation: "UEFA" },
+ { code: "de", confederation: "UEFA" },
+ { code: "nl", confederation: "UEFA" },
+ { code: "no", confederation: "UEFA" },
+ { code: "pt", confederation: "UEFA" },
+ { code: "sct", confederation: "UEFA" },
+ { code: "es", confederation: "UEFA" },
+ { code: "se", confederation: "UEFA" },
+ { code: "ch", confederation: "UEFA" },
+ { code: "tr", confederation: "UEFA" },
+];
+
+const QUALIFIED = new Set(WC26_TEAMS.map((t) => t.code));
+
+/** True when `code` is one of the 48 teams at the 2026 World Cup. Case-insensitive. */
+export function isQualifiedWC26(code: string | null | undefined): boolean {
+ return !!code && QUALIFIED.has(code.toLowerCase());
+}
+
+/**
+ * Floats the World-Cup-26 teams to the top of a country list, keeping each group
+ * in the order it came in — so the caller's own ranking (searchCountries already
+ * ranks prefix matches above substring ones) still holds WITHIN each group.
+ *
+ * Returns a FLAT list plus the size of the qualified block, deliberately: the
+ * picker's keyboard navigation and aria-activedescendant address options by
+ * index, so grouping has to stay a rendering concern, not a data-shape one.
+ */
+export function qualifiedFirst(countries: readonly Country[]): {
+ list: readonly Country[];
+ qualifiedCount: number;
+} {
+ const qualified: Country[] = [];
+ const rest: Country[] = [];
+ for (const c of countries) (isQualifiedWC26(c.code) ? qualified : rest).push(c);
+ return { list: [...qualified, ...rest], qualifiedCount: qualified.length };
+}
+
+/**
+ * The flag picker's list, and the size of its leading World-Cup block (0 = show no
+ * group headers).
+ *
+ * The regrouping applies to the BROWSE case only. While the user is searching,
+ * searchCountries' prefix-over-substring ranking is what they're steering, and
+ * floating the qualified teams through it would fight that: "ma" would surface
+ * Germany and Panama (substring, qualified) above Madagascar (prefix), which is
+ * plainly the wrong answer. So a search is returned exactly as ranked, and only
+ * the full list gets the World-Cup block — which is where the win actually is:
+ * your national team is at the top instead of 200 countries down.
+ */
+export function flagPickerList(query: string): {
+ list: readonly Country[];
+ qualifiedCount: number;
+} {
+ const matches = searchCountries(query);
+ return query.trim() ? { list: matches, qualifiedCount: 0 } : qualifiedFirst(matches);
+}
diff --git a/tests/wc26.test.ts b/tests/wc26.test.ts
new file mode 100644
index 0000000..fe2fffa
--- /dev/null
+++ b/tests/wc26.test.ts
@@ -0,0 +1,154 @@
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { countryName, normalizeCountry, searchCountries } from "@/lib/countries";
+import { WC26_TEAMS, flagPickerList, isQualifiedWC26, qualifiedFirst } from "@/lib/wc26";
+import type { Confederation } from "@/lib/wc26";
+
+// The team list is hand-written data, so the tests treat it as data: they check it
+// against the two things that can silently break the picker — a code that isn't a
+// real country, and a code with no flag PNG behind it (a card would render a
+// broken image). Everything else here pins the ordering contract the picker relies on.
+
+describe("WC26_TEAMS — the data", () => {
+ it("has the 48 teams of the tournament", () => {
+ expect(WC26_TEAMS).toHaveLength(48);
+ });
+
+ it("lists no team twice", () => {
+ const codes = WC26_TEAMS.map((t) => t.code);
+ expect(new Set(codes).size).toBe(codes.length);
+ });
+
+ it("splits into the real confederation quotas", () => {
+ const count = (c: Confederation) => WC26_TEAMS.filter((t) => t.confederation === c).length;
+ expect({
+ AFC: count("AFC"),
+ CAF: count("CAF"),
+ CONCACAF: count("CONCACAF"),
+ CONMEBOL: count("CONMEBOL"),
+ OFC: count("OFC"),
+ UEFA: count("UEFA"),
+ }).toEqual({ AFC: 9, CAF: 10, CONCACAF: 6, CONMEBOL: 6, OFC: 1, UEFA: 16 });
+ });
+
+ // The list is keyed by OUR flag asset codes, not ISO — England and Scotland are
+ // "eng"/"sct". A typo here is invisible until someone's flag silently vanishes.
+ it("gives every team a code that resolves to a real country", () => {
+ for (const { code } of WC26_TEAMS) {
+ expect(normalizeCountry(code), code).toBe(code);
+ expect(countryName(code), code).toBeTruthy();
+ }
+ });
+
+ it("gives every team a flag asset that actually exists on disk", () => {
+ for (const { code } of WC26_TEAMS) {
+ const png = join(process.cwd(), "public", "badges", "flags", `${code}.png`);
+ expect(existsSync(png), `missing flag asset for ${code}`).toBe(true);
+ }
+ });
+
+ it("includes the three hosts and both play-off winners", () => {
+ for (const code of ["ca", "mx", "us", "iq", "cd"]) {
+ expect(isQualifiedWC26(code), code).toBe(true);
+ }
+ });
+
+ it("carries the UK home nations under their non-ISO codes", () => {
+ expect(countryName("eng")).toBe("England");
+ expect(countryName("sct")).toBe("Scotland");
+ expect(isQualifiedWC26("eng")).toBe(true);
+ expect(isQualifiedWC26("sct")).toBe(true);
+ });
+});
+
+describe("isQualifiedWC26", () => {
+ it("is false for a country that didn't qualify", () => {
+ expect(isQualifiedWC26("it")).toBe(false); // Italy missed out again
+ expect(isQualifiedWC26("dk")).toBe(false);
+ });
+
+ it("matches case-insensitively", () => {
+ expect(isQualifiedWC26("FR")).toBe(true);
+ expect(isQualifiedWC26("Ma")).toBe(true);
+ });
+
+ it("is safe on empty / missing input", () => {
+ expect(isQualifiedWC26("")).toBe(false);
+ expect(isQualifiedWC26(null)).toBe(false);
+ expect(isQualifiedWC26(undefined)).toBe(false);
+ });
+});
+
+describe("qualifiedFirst", () => {
+ const of = (...codes: string[]) => codes.map((code) => ({ code, name: code.toUpperCase() }));
+
+ it("floats qualified teams above everyone else", () => {
+ const { list } = qualifiedFirst(of("it", "fr", "dk", "br"));
+ expect(list.map((c) => c.code)).toEqual(["fr", "br", "it", "dk"]);
+ });
+
+ it("reports the size of the qualified block", () => {
+ expect(qualifiedFirst(of("it", "fr", "dk", "br")).qualifiedCount).toBe(2);
+ expect(qualifiedFirst(of("it", "dk")).qualifiedCount).toBe(0);
+ });
+
+ // searchCountries already ranks prefix matches above substring ones; floating the
+ // qualified block must not scramble that ranking WITHIN either group.
+ it("preserves the incoming order inside each group", () => {
+ const { list } = qualifiedFirst(of("dk", "br", "it", "fr"));
+ expect(list.map((c) => c.code)).toEqual(["br", "fr", "dk", "it"]);
+ });
+
+ it("keeps the full country list intact, just reordered", () => {
+ const all = searchCountries("");
+ const { list, qualifiedCount } = qualifiedFirst(all);
+ expect(list).toHaveLength(all.length);
+ expect(qualifiedCount).toBe(48);
+ expect(new Set(list.map((c) => c.code))).toEqual(new Set(all.map((c) => c.code)));
+ });
+
+ it("puts all 48 — and only those — in the leading block", () => {
+ const { list, qualifiedCount } = qualifiedFirst(searchCountries(""));
+ const lead = list.slice(0, qualifiedCount);
+ expect(lead.every((c) => isQualifiedWC26(c.code))).toBe(true);
+ expect(list.slice(qualifiedCount).some((c) => isQualifiedWC26(c.code))).toBe(false);
+ });
+
+ it("returns an empty result untouched", () => {
+ expect(qualifiedFirst([])).toEqual({ list: [], qualifiedCount: 0 });
+ });
+});
+
+describe("flagPickerList — what the picker actually renders", () => {
+ it("leads the browse list with the 48, and flags the block size", () => {
+ const { list, qualifiedCount } = flagPickerList("");
+ expect(qualifiedCount).toBe(48);
+ expect(list.slice(0, 48).every((c) => isQualifiedWC26(c.code))).toBe(true);
+ // …and nothing is lost off the bottom.
+ expect(list).toHaveLength(searchCountries("").length);
+ });
+
+ it("puts your national team first instead of 200 countries down", () => {
+ const { list } = flagPickerList("");
+ // Plain alphabetical would open on Afghanistan; now it opens on the tournament.
+ expect(list[0].code).not.toBe("af");
+ expect(isQualifiedWC26(list[0].code)).toBe(true);
+ });
+
+ // The regression this design exists to avoid: searchCountries deliberately ranks
+ // prefix matches above substring ones. Floating the qualified teams THROUGH a
+ // search would put Germany and Panama (substring, qualified) above Madagascar
+ // (prefix) for "ma" — plainly wrong. A search is returned exactly as ranked.
+ it("does not reorder a search, so prefix still beats substring", () => {
+ const { list, qualifiedCount } = flagPickerList("ma");
+ expect(list).toEqual(searchCountries("ma"));
+ expect(list[0].name).toBe("Macao"); // not Morocco, not Germany
+ expect(qualifiedCount).toBe(0); // → the picker draws no group headers
+ });
+
+ it("still ranks a searched-for qualified team on its own merits", () => {
+ expect(flagPickerList("morocco").list[0].code).toBe("ma");
+ expect(flagPickerList("fr").list[0].code).toBe("fr"); // exact code match
+ });
+});