← alle entries

import-patchbay

FC-0168Data & lijstenimport-mapperblockv1.0.0

Kolom-koppelaar voor bestandsimports als een patchbay: één jack per kolom in het bestand, één per veld in je schema, een kabel ertussen. 'Automatisch koppelen' leest voorbeeldwaarden én kopregels (een kolom 'col_7' vol datums vindt 'Ingeschreven'); onzekere koppelingen hangen gestreept tot iemand ze bevestigt. Slepen, tikken of toetsenbord. Componeert button.

zelfde component — andere tokens
klanten-export.csv1,284 rijen · 6 kolommen
In het bestandJouw velden

0 van 6E-mailadres, Volledige naam hebben nog een kolom nodig.

Klik 'Automatisch koppelen': col_7 vol datums vindt 'Ingeschreven' (gestreept = controleren). Sleep een jack naar een veld; Delete ontkoppelt.

blocks/apps-ii/import-patchbay.tsx
/**
 * ImportPatchbay — een kolom-koppelaar voor bestandsimports, gebouwd als een
 * patchbay: één jack per kolom in het bestand, één per veld in je schema, en
 * een kabel ertussen. 'Automatisch koppelen' leest de voorbeeldwaarden én de
 * kopregels, dus een kolom `col_7` vol datums vindt nog steeds 'Ingeschreven';
 * onzekere koppelingen hangen gestreept tot iemand ze bevestigt. Slepen,
 * tikken-en-tikken of toetsenbord (Enter pakt op, pijltjes kiezen, Enter
 * laat vallen; Delete ontkoppelt). Componeert button.
 *
 * Geoogst uit 21st.dev Import Patchbay (n1m4mz): Tailwind + cn → fp-patch-
 * CSS, de <style>-keyframes met cubic-bezier → stylesheet op de motion-
 * tokens, @container-query blijft (CSS), text-destructive → --danger, alle
 * Engelse teksten (meldingen, sr-only, verdict) → `labels`-prop met
 * Nederlandse defaults, React-19 ref-prop → forwardRef, eigen knoppen →
 * button (FC-0003). De sniffer, matcher, geometrie en toetsenbordlogica zijn
 * ongewijzigd.
 */

"use client";

import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type * as React from "react";
import { Button } from "../../primitives/ui/button";

/* ── Types ───────────────────────────────────────────────────────────────── */

export type ValueKind = "email" | "phone" | "url" | "date" | "number" | "currency" | "boolean" | "id" | "text";

export interface PatchColumn { id: string; name: string; samples?: string[]; issues?: number }
export interface PatchField { id: string; label: string; required?: boolean; kind?: ValueKind; hint?: string; synonyms?: string[] }
export type PatchMapping = { [fieldId: string]: string };

export interface PatchSummary { patched: number; fields: number; review: number; missingRequired: string[]; unusedColumns: number; complete: boolean }

export interface PatchbayLabels {
  file: string; rows: string; column: string; columns: string; inFile: string; yourFields: string; empty: string;
  autoPatch: string; confirm: string; review: string; bad: string; expects: string; emptyField: string;
  nothingYet: string; needsColumn: string; needColumn: string; toReview: string; allSet: string; unused: string; unusedOne: string;
  patchedOf: string; kinds: { [k in ValueKind]: string }; kindsShort: { [k in ValueKind]: string };
  msg: { patched: string; unpatched: string; holdingDrop: string; holdingTap: string; holdingKeys: string; droppedNothing: string; cancelled: string; chooseField: string; confirmed: string; autoPatched: string; unsure: string; pressEnter: string };
  sr: { columnOf: string; valuesLike: string; patchedInto: string; notPatched: string; required: string; fieldOf: string; patchedFrom: string; lowConfidence: string; pressDelete: string; emptyHeld: string; emptyPlain: string; warning: string };
}

export const NL_LABELS: PatchbayLabels = {
  file: "Geüpload bestand", rows: "rijen", column: "kolom", columns: "kolommen", inFile: "In het bestand", yourFields: "Jouw velden", empty: "Upload een bestand om de kolommen hier te zien.",
  autoPatch: "Automatisch koppelen", confirm: "Importeren", review: "controleren", bad: "fout", expects: "verwacht", emptyField: "leeg",
  nothingYet: "Nog niets te koppelen.", needsColumn: "heeft nog een kolom nodig.", needColumn: "hebben nog een kolom nodig.", toReview: "te controleren", allSet: "Elk verplicht veld heeft een kolom", unused: "kolommen worden niet geïmporteerd.", unusedOne: "kolom wordt niet geïmporteerd.",
  patchedOf: "van", kinds: { email: "e-mail", phone: "telefoon", url: "link", date: "datum", number: "getal", currency: "bedrag", boolean: "ja/nee", id: "id", text: "tekst" },
  kindsShort: { email: "@", phone: "tel", url: "link", date: "dat", number: "123", currency: "€", boolean: "j/n", id: "id", text: "abc" },
  msg: { patched: "{column} gekoppeld aan {field}.", unpatched: "{field} ontkoppeld.", holdingDrop: "{column} vast. Laat hem op een veld vallen.", holdingTap: "{column} vast. Tik een veld om te koppelen, of druk op Escape.", holdingKeys: "{column} vast. Pijltjes kiezen een veld, Enter koppelt, Escape laat los.", droppedNothing: "Nergens op losgelaten. Niets veranderd.", cancelled: "Geannuleerd.", chooseField: "Kies eerst een veld met de pijltjestoetsen.", confirmed: "{field} bevestigd.", autoPatched: "{n} {what} gekoppeld", unsure: ", {n} te controleren.", pressEnter: "{field} — Enter om te koppelen." },
  sr: { columnOf: "Kolom {name}, {i} van {n}.", valuesLike: "Waarden lijken op {kind}.", patchedInto: "Gekoppeld aan {field}. Enter verplaatst de kabel, Delete ontkoppelt.", notPatched: "Niet gekoppeld. Enter pakt hem op.", required: ", verplicht", fieldOf: "{label}{required}, veld {i} van {n}.", patchedFrom: "Gekoppeld vanuit {column}.", lowConfidence: " Onzeker: Enter bevestigt.", pressDelete: " Delete ontkoppelt.", emptyHeld: "Leeg. Enter koppelt de vastgehouden kolom hier.", emptyPlain: "Leeg.", warning: " Let op: die waarden lijken op {have}, maar dit veld verwacht {want}." },
};

const vul = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ""));

export interface ImportPatchbayProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  columns: PatchColumn[];
  fields: PatchField[];
  value?: PatchMapping;
  defaultValue?: PatchMapping;
  onValueChange?: (mapping: PatchMapping, summary: PatchSummary) => void;
  onConfirm?: (mapping: PatchMapping) => void;
  fileName?: string;
  rowCount?: number;
  threshold?: number;
  sureThreshold?: number;
  rowHeight?: number;
  gutterWidth?: number;
  sag?: number;
  showSamples?: boolean;
  showKinds?: boolean;
  reuseColumns?: boolean;
  autoPatchOnMount?: boolean;
  labels?: Partial<PatchbayLabels>;
}

/* ── De snuffelaar: wat voor waarde is dit? ──────────────────────────────── */

const MONTHS = "jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|mrt|mei|okt";
const RE_EMAIL = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
const RE_URL = /^(https?:\/\/|www\.)\S+\.\S+$/i;
const RE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const RE_ISO = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?/;
const RE_SLASH = /^\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}$/;
const RE_MDY = new RegExp(`^(${MONTHS})[a-z]*\\.?\\s+\\d{1,2},?\\s+\\d{4}$`, "i");
const RE_DMY = new RegExp(`^\\d{1,2}\\s+(${MONTHS})[a-z]*\\.?\\s+\\d{4}$`, "i");
const RE_CUR_PRE = /^[-+]?[$€£¥₹]\s?[\d.,]+$/;
const RE_CUR_POST = /^[-+]?[\d.,]+\s?(usd|eur|gbp|inr|chf)$/i;
const RE_BOOL = /^(true|false|yes|no|y|n|t|f|ja|nee|j)$/i;
const RE_PHONE = /^\+?[\d(][\d\s().-]{5,}$/;
const RE_GROUPED = /^[-+]?\d{1,3}([,.]\d{3})+([.,]\d+)?$/;
const RE_PLAIN = /^[-+]?\d*[.,]?\d+$/;

function sniffValue(raw: string): ValueKind {
  const v = raw.trim();
  if (!v) return "text";
  if (RE_EMAIL.test(v)) return "email";
  if (RE_URL.test(v)) return "url";
  if (RE_UUID.test(v)) return "id";
  if (RE_ISO.test(v) || RE_SLASH.test(v) || RE_MDY.test(v) || RE_DMY.test(v)) return "date";
  if (RE_CUR_PRE.test(v) || RE_CUR_POST.test(v)) return "currency";
  if (RE_BOOL.test(v)) return "boolean";
  const digits = v.replace(/\D/g, "");
  if (RE_PHONE.test(v) && /[\s().+-]/.test(v) && digits.length >= 7 && digits.length <= 15) return "phone";
  if (RE_GROUPED.test(v) || RE_PLAIN.test(v)) return "number";
  return "text";
}

function sniffColumn(samples: readonly string[]): { kind: ValueKind; filled: number } {
  const values = samples.map((s) => (s ?? "").trim());
  const present = values.filter(Boolean);
  if (present.length === 0) return { kind: "text", filled: 0 };
  const counts = new Map<ValueKind, number>();
  for (const v of present) { const k = sniffValue(v); counts.set(k, (counts.get(k) ?? 0) + 1); }
  let best: ValueKind = "text";
  let bestN = 0;
  for (const [k, n] of counts) if (n > bestN) { best = k; bestN = n; }
  return { kind: bestN / present.length >= 0.6 ? best : "text", filled: present.length / values.length };
}

/* ── De matcher: hoe goed past deze kolom bij dit veld? ─────────────────── */

const TOKEN_GROUPS: readonly (readonly string[])[] = [
  ["email", "emails", "mail", "e", "mailadres"],
  ["phone", "tel", "telephone", "mobile", "cell", "cellphone", "msisdn", "telefoon", "mobiel"],
  ["company", "org", "organisation", "organization", "employer", "business", "account", "bedrijf", "organisatie"],
  ["first", "given", "forename", "fname", "voornaam"],
  ["last", "surname", "family", "lname", "achternaam"],
  ["full", "contact", "person", "customer", "user", "naam", "klant", "persoon"],
  ["date", "on", "when", "time", "timestamp", "ts", "datum"],
  ["signed", "signup", "registered", "created", "joined", "since", "start", "ingeschreven", "aangemaakt"],
  ["zip", "postcode", "postal"],
  ["street", "addr", "line", "straat", "adres"],
  ["city", "town", "locality", "plaats", "stad", "woonplaats"],
  ["country", "nation", "land"],
  ["state", "province", "region", "provincie"],
  ["id", "identifier", "uuid", "key", "ref", "reference", "external", "nummer"],
  ["amount", "total", "price", "cost", "revenue", "mrr", "spend", "bedrag", "prijs", "totaal"],
  ["title", "role", "position", "job", "functie"],
  ["website", "site", "url", "link", "homepage", "domain"],
  ["notes", "note", "comment", "comments", "description", "remarks", "opmerking", "notitie"],
  ["status", "stage", "lifecycle"],
  ["plan", "tier", "subscription", "package", "product", "pakket", "abonnement"],
  ["source", "channel", "origin", "campaign", "utm", "bron", "kanaal"],
];
const GROUP_OF = new Map<string, string>();
for (let g = 0; g < TOKEN_GROUPS.length; g++) for (const t of TOKEN_GROUPS[g] ?? []) GROUP_OF.set(t, `g${g}`);
const WEAK = new Set(["address", "name", "no", "num", "number", "field", "col", "column", "data", "info", "value", "the", "of", "de", "het", "1", "2", "3"]);

function tokenize(s: string): string[] {
  return s.replace(/([a-z\d])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
}
const canon = (t: string): string => GROUP_OF.get(t) ?? t;
const weightOf = (t: string): number => (WEAK.has(t) ? 0.35 : 1);
const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n);
const compact = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, "");

function trigrams(s: string): Set<string> {
  const out = new Set<string>();
  const padded = `  ${s} `;
  for (let i = 0; i < padded.length - 2; i++) out.add(padded.slice(i, i + 3));
  return out;
}
function dice(a: string, b: string): number {
  if (!a || !b) return 0;
  const A = trigrams(a);
  const B = trigrams(b);
  let shared = 0;
  for (const t of A) if (B.has(t)) shared++;
  return (2 * shared) / (A.size + B.size);
}
function nameScore(columnName: string, field: PatchField): number {
  const cols = tokenize(columnName);
  if (cols.length === 0) return 0;
  const colSet = new Set(cols.map(canon));
  let best = 0;
  for (const target of [field.label, field.id, ...(field.synonyms ?? [])]) {
    const want = tokenize(target);
    if (want.length === 0) continue;
    const wantSet = new Set(want.map(canon));
    let matched = 0, wanted = 0;
    for (const t of want) { wanted += weightOf(t); if (colSet.has(canon(t))) matched += weightOf(t); }
    let extra = 0;
    for (const t of cols) if (!wantSet.has(canon(t))) extra += weightOf(t);
    const recall = wanted > 0 ? matched / wanted : 0;
    const precision = matched > 0 ? matched / (matched + extra) : 0;
    const byToken = recall * 0.75 + precision * 0.25;
    const byShape = dice(compact(columnName), compact(target)) * 0.85;
    best = Math.max(best, byToken, byShape);
  }
  return clamp01(best);
}

const AFFINITY: { [a in ValueKind]?: { [b in ValueKind]?: number } } = {
  number: { currency: 0.7, id: 0.3, boolean: 0.25 }, currency: { number: 0.7 }, phone: { number: 0.35 }, id: { number: 0.35, text: 0.25 }, url: { text: 0.2 }, date: { number: 0.15 },
};
const NO_EVIDENCE_KIND = 0.55;
const VETOED_WEIGHT = 0.44;

function kindScore(column: ValueKind, field?: ValueKind): number {
  if (!field) return NO_EVIDENCE_KIND;
  if (field === "text" && column === "text") return 0.5;
  if (field === column) return 1;
  if (field === "text") return 0.35;
  if (column === "text") return 0.1;
  return AFFINITY[field]?.[column] ?? -1;
}

/* ── Geometrie: elke coördinaat is rekenwerk op een rij-index ───────────── */

const ROW_GAP = 8;
const rowCenter = (index: number, rowHeight: number): number => index * (rowHeight + ROW_GAP) + rowHeight / 2;
const CABLE_UNITS = 100;
function cablePath(y0: number, y1: number, droop: number): string {
  return `M 0 ${y0} C 42 ${(y0 + droop).toFixed(2)}, 58 ${(y1 + droop).toFixed(2)}, ${CABLE_UNITS} ${y1}`;
}
function looseCable(y0: number, x: number, y: number, droop: number): string {
  return `M 0 ${y0} C 50 ${(y0 + droop).toFixed(2)}, ${(x - 20).toFixed(2)} ${y.toFixed(2)}, ${x.toFixed(2)} ${y.toFixed(2)}`;
}

function summarize(mapping: PatchMapping, columns: PatchColumn[], fields: PatchField[], guesses: ReadonlySet<string>): PatchSummary {
  const used = new Set(Object.values(mapping));
  const missingRequired = fields.filter((f) => f.required && !mapping[f.id]).map((f) => f.id);
  return { patched: fields.filter((f) => mapping[f.id]).length, fields: fields.length, review: fields.filter((f) => mapping[f.id] && guesses.has(f.id)).length, missingRequired, unusedColumns: columns.filter((c) => !used.has(c.id)).length, complete: missingRequired.length === 0 };
}

const STAGGER_MS = 55;
const DRAG_SLOP = 5;
type Held = { columnId: string; pointer: boolean };
type Point = { x: number; y: number };

/* ── Component ───────────────────────────────────────────────────────────── */

export const ImportPatchbay = forwardRef<HTMLDivElement, ImportPatchbayProps>(function ImportPatchbay(
  { columns, fields, value, defaultValue, onValueChange, onConfirm, fileName, rowCount, threshold = 0.4, sureThreshold = 0.68, rowHeight = 52, gutterWidth = 96, sag = 0.18, showSamples = true, showKinds = true, reuseColumns = false, autoPatchOnMount = false, labels, className, style, ...rest },
  ref,
) {
  const t: PatchbayLabels = { ...NL_LABELS, ...labels, kinds: { ...NL_LABELS.kinds, ...labels?.kinds }, kindsShort: { ...NL_LABELS.kindsShort, ...labels?.kindsShort }, msg: { ...NL_LABELS.msg, ...labels?.msg }, sr: { ...NL_LABELS.sr, ...labels?.sr } };
  const uid = useId();
  const [internal, setInternal] = useState<PatchMapping>(defaultValue ?? {});
  const mapping = value ?? internal;
  const [guesses, setGuesses] = useState<ReadonlySet<string>>(() => new Set());
  const [pending, setPending] = useState<ReadonlySet<string>>(() => new Set());
  const [drawing, setDrawing] = useState<ReadonlySet<string>>(() => new Set());
  const [held, setHeld] = useState<Held | null>(null);
  const [candidate, setCandidate] = useState<number | null>(null);
  const [activeColumn, setActiveColumn] = useState(0);
  const [activeField, setActiveField] = useState(0);
  const [message, setMessage] = useState("");

  const gutterRef = useRef<SVGSVGElement | null>(null);
  const looseRef = useRef<SVGPathElement | null>(null);
  const gutterRect = useRef<DOMRect | null>(null);
  const loosePoint = useRef<Point | null>(null);
  const columnRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const fieldRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const timers = useRef<number[]>([]);
  const pressAt = useRef<Point | null>(null);
  const moved = useRef(false);

  useEffect(() => { const pool = timers.current; return () => { for (const tm of pool) window.clearTimeout(tm); pool.length = 0; }; }, []);

  const infos = useMemo(() => columns.map((c) => ({ column: c, ...sniffColumn(c.samples ?? []) })), [columns]);

  const scores = useMemo(() => {
    const columnKinds = new Map<ValueKind, number>();
    for (const i of infos) columnKinds.set(i.kind, (columnKinds.get(i.kind) ?? 0) + 1);
    const fieldKinds = new Map<ValueKind, number>();
    for (const f of fields) if (f.kind) fieldKinds.set(f.kind, (fieldKinds.get(f.kind) ?? 0) + 1);
    const table = new Map<string, number>();
    for (const info of infos) {
      for (const field of fields) {
        const hasValues = info.filled > 0;
        const k = hasValues ? kindScore(info.kind, field.kind) : NO_EVIDENCE_KIND;
        const n = nameScore(info.column.name, field);
        if (k < 0) { table.set(`${info.column.id}|${field.id}`, clamp01(VETOED_WEIGHT * n)); continue; }
        let s = 0.55 * n + 0.45 * k;
        if (n > 0.25 && k >= 0.7) s += 0.12;
        if (info.kind !== "text" && field.kind === info.kind && columnKinds.get(info.kind) === 1 && fieldKinds.get(info.kind) === 1) s += 0.25;
        if (hasValues && info.filled < 0.5) s *= 0.85;
        table.set(`${info.column.id}|${field.id}`, clamp01(s));
      }
    }
    return table;
  }, [infos, fields]);

  const scoreOf = useCallback((columnId: string, fieldId: string) => scores.get(`${columnId}|${fieldId}`) ?? 0, [scores]);

  const proposals = useMemo(() => {
    const takenFields = new Set(Object.keys(mapping).filter((f) => mapping[f]));
    const takenColumns = new Set(Object.values(mapping));
    const pairs: { columnId: string; fieldId: string; score: number; margin: number }[] = [];
    for (const info of infos) {
      if (takenColumns.has(info.column.id)) continue;
      for (const field of fields) {
        if (takenFields.has(field.id)) continue;
        const s = scoreOf(info.column.id, field.id);
        if (s < threshold) continue;
        let elsewhere = 0;
        for (const other of fields) { if (other.id === field.id) continue; const s2 = scoreOf(info.column.id, other.id); if (s2 > elsewhere) elsewhere = s2; }
        pairs.push({ columnId: info.column.id, fieldId: field.id, score: s, margin: s - elsewhere });
      }
    }
    pairs.sort((a, b) => b.score - a.score || b.margin - a.margin);
    const outColumns = new Set<string>();
    const outFields = new Set<string>();
    const picked: typeof pairs = [];
    for (const p of pairs) {
      if (outColumns.has(p.columnId) || outFields.has(p.fieldId)) continue;
      const rival = pairs.some((q) => q.fieldId === p.fieldId && q.columnId !== p.columnId && !outColumns.has(q.columnId) && Math.abs(q.score - p.score) < 1e-9 && Math.abs(q.margin - p.margin) < 1e-9);
      if (rival) continue;
      outColumns.add(p.columnId);
      outFields.add(p.fieldId);
      picked.push(p);
    }
    return picked;
  }, [infos, fields, mapping, scoreOf, threshold]);

  const summary = useMemo(() => summarize(mapping, columns, fields, guesses), [mapping, columns, fields, guesses]);
  const columnToField = useMemo(() => { const out = new Map<string, string>(); for (const [fieldId, columnId] of Object.entries(mapping)) if (columnId) out.set(columnId, fieldId); return out; }, [mapping]);
  const columnIndex = useMemo(() => { const out = new Map<string, number>(); columns.forEach((c, i) => out.set(c.id, i)); return out; }, [columns]);
  const infoById = useMemo(() => { const out = new Map<string, (typeof infos)[number]>(); for (const i of infos) out.set(i.column.id, i); return out; }, [infos]);

  const commit = useCallback((next: PatchMapping, nextGuesses: ReadonlySet<string>, note: string) => {
    if (value === undefined) setInternal(next);
    setGuesses(nextGuesses);
    setMessage(note);
    onValueChange?.(next, summarize(next, columns, fields, nextGuesses));
  }, [value, onValueChange, columns, fields]);

  const reveal = useCallback((ids: string[], stagger: boolean) => {
    const reduce = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
    if (reduce || ids.length === 0) return;
    setPending(new Set(stagger ? ids : []));
    ids.forEach((id, i) => {
      const at = stagger ? i * STAGGER_MS : 0;
      const tm = window.setTimeout(() => {
        setPending((prev) => { const s = new Set(prev); s.delete(id); return s; });
        setDrawing((prev) => new Set(prev).add(id));
        const off = window.setTimeout(() => setDrawing((prev) => { const s = new Set(prev); s.delete(id); return s; }), 320);
        timers.current.push(off);
      }, at);
      timers.current.push(tm);
    });
  }, []);

  const naam = (columnId: string) => columns.find((c) => c.id === columnId)?.name ?? t.column;
  const label = (fieldId: string) => fields.find((f) => f.id === fieldId)?.label ?? "veld";

  const patch = useCallback((columnId: string, fieldId: string, asGuess = false) => {
    const next: PatchMapping = {};
    const vacated: string[] = [];
    for (const [f, c] of Object.entries(mapping)) { if (c && (reuseColumns || c !== columnId)) next[f] = c; else if (c) vacated.push(f); }
    next[fieldId] = columnId;
    const nextGuesses = new Set(guesses);
    for (const f of vacated) nextGuesses.delete(f);
    if (asGuess) nextGuesses.add(fieldId); else nextGuesses.delete(fieldId);
    commit(next, nextGuesses, vul(t.msg.patched, { column: naam(columnId), field: label(fieldId) }));
    reveal([fieldId], false);
  }, [mapping, guesses, commit, reveal, reuseColumns, t.msg.patched]); // eslint-disable-line react-hooks/exhaustive-deps

  const unpatch = useCallback((fieldId: string) => {
    if (!mapping[fieldId]) return;
    const next: PatchMapping = {};
    for (const [f, c] of Object.entries(mapping)) if (c && f !== fieldId) next[f] = c;
    const nextGuesses = new Set(guesses);
    nextGuesses.delete(fieldId);
    commit(next, nextGuesses, vul(t.msg.unpatched, { field: label(fieldId) }));
  }, [mapping, guesses, commit, t.msg.unpatched]); // eslint-disable-line react-hooks/exhaustive-deps

  const autoPatch = useCallback(() => {
    if (proposals.length === 0) return;
    const next: PatchMapping = { ...mapping };
    const nextGuesses = new Set(guesses);
    for (const p of proposals) { next[p.fieldId] = p.columnId; if (p.score < sureThreshold) nextGuesses.add(p.fieldId); else nextGuesses.delete(p.fieldId); }
    const unsure = proposals.filter((p) => p.score < sureThreshold).length;
    commit(next, nextGuesses, vul(t.msg.autoPatched, { n: proposals.length, what: proposals.length === 1 ? t.column : t.columns }) + (unsure > 0 ? vul(t.msg.unsure, { n: unsure }) : "."));
    reveal(proposals.map((p) => p.fieldId), true);
  }, [proposals, mapping, guesses, sureThreshold, commit, reveal, t]);

  const didMount = useRef(false);
  useEffect(() => { if (didMount.current || !autoPatchOnMount) return; didMount.current = true; autoPatch(); }, [autoPatchOnMount, autoPatch]);

  const pointTo = useCallback((clientX: number, clientY: number) => {
    const rect = gutterRect.current;
    if (!rect || rect.width === 0) return;
    const x = ((clientX - rect.left) / rect.width) * CABLE_UNITS;
    const y = clientY - rect.top;
    loosePoint.current = { x, y };
    const path = looseRef.current;
    if (path && held) path.setAttribute("d", looseCable(rowCenter(columnIndex.get(held.columnId) ?? 0, rowHeight), x, y, gutterWidth * sag));
    const armed = x > CABLE_UNITS * 0.3;
    const index = Math.floor(y / (rowHeight + ROW_GAP));
    const within = y - index * (rowHeight + ROW_GAP) <= rowHeight;
    const next = armed && within && index >= 0 && index < fields.length ? index : null;
    setCandidate((prev) => (prev === next ? prev : next));
  }, [held, columnIndex, rowHeight, gutterWidth, sag, fields.length]);

  useEffect(() => {
    if (!held?.pointer) return;
    const move = (e: PointerEvent) => {
      const from = pressAt.current;
      if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > DRAG_SLOP) moved.current = true;
      pointTo(e.clientX, e.clientY);
    };
    const up = () => {
      if (!moved.current) {
        setHeld({ columnId: held.columnId, pointer: false });
        loosePoint.current = null;
        setMessage(vul(t.msg.holdingTap, { column: naam(held.columnId) }));
        return;
      }
      const target = candidate !== null ? fields[candidate] : undefined;
      if (target) patch(held.columnId, target.id); else setMessage(t.msg.droppedNothing);
      setHeld(null);
      setCandidate(null);
      loosePoint.current = null;
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);
    return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); };
  }, [held, candidate, fields, columns, patch, pointTo, t.msg.holdingTap, t.msg.droppedNothing]); // eslint-disable-line react-hooks/exhaustive-deps

  const pickUp = useCallback((columnId: string, pointer: boolean, e?: React.PointerEvent) => {
    gutterRect.current = gutterRef.current?.getBoundingClientRect() ?? null;
    const alreadyIn = columnToField.get(columnId);
    setHeld({ columnId, pointer });
    setCandidate(alreadyIn ? fields.findIndex((f) => f.id === alreadyIn) : null);
    setMessage(vul(pointer ? t.msg.holdingDrop : t.msg.holdingKeys, { column: naam(columnId) }));
    if (e) pointTo(e.clientX, e.clientY);
  }, [columnToField, fields, pointTo, t.msg.holdingDrop, t.msg.holdingKeys]); // eslint-disable-line react-hooks/exhaustive-deps

  const cancelHold = useCallback(() => { setHeld(null); setCandidate(null); loosePoint.current = null; setMessage(t.msg.cancelled); }, [t.msg.cancelled]);

  const focusColumn = (i: number) => { const next = Math.max(0, Math.min(columns.length - 1, i)); setActiveColumn(next); columnRefs.current[next]?.focus(); };
  const focusField = (i: number) => { const next = Math.max(0, Math.min(fields.length - 1, i)); setActiveField(next); fieldRefs.current[next]?.focus(); };

  const onColumnKeyDown = (e: React.KeyboardEvent, index: number, columnId: string) => {
    const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
    if (step !== 0) {
      e.preventDefault();
      if (held) { const next = Math.max(0, Math.min(fields.length - 1, (candidate ?? -1) + step)); setCandidate(next); setMessage(vul(t.msg.pressEnter, { field: fields[next]?.label ?? "" })); }
      else focusColumn(index + step);
      return;
    }
    if (e.key === "Home" || e.key === "End") { e.preventDefault(); focusColumn(e.key === "Home" ? 0 : columns.length - 1); return; }
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      if (!held) pickUp(columnId, false);
      else if (candidate !== null && fields[candidate]) { patch(held.columnId, fields[candidate]!.id); setHeld(null); setCandidate(null); }
      else setMessage(t.msg.chooseField);
      return;
    }
    if (e.key === "Escape" && held) { e.preventDefault(); cancelHold(); return; }
    if (e.key === "Delete" || e.key === "Backspace") { const fieldId = columnToField.get(columnId); if (fieldId) { e.preventDefault(); unpatch(fieldId); } }
  };

  const onFieldKeyDown = (e: React.KeyboardEvent, index: number, fieldId: string) => {
    const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
    if (step !== 0) { e.preventDefault(); focusField(index + step); return; }
    if (e.key === "Home" || e.key === "End") { e.preventDefault(); focusField(e.key === "Home" ? 0 : fields.length - 1); return; }
    if (e.key === "Escape" && held) { e.preventDefault(); cancelHold(); return; }
    if (e.key === "Delete" || e.key === "Backspace") { if (mapping[fieldId]) { e.preventDefault(); unpatch(fieldId); } }
  };

  const onFieldActivate = (fieldId: string) => {
    if (held) { patch(held.columnId, fieldId); setHeld(null); setCandidate(null); return; }
    if (guesses.has(fieldId)) { const next = new Set(guesses); next.delete(fieldId); setGuesses(next); setMessage(vul(t.msg.confirmed, { field: label(fieldId) })); return; }
    const columnId = mapping[fieldId];
    if (columnId) pickUp(columnId, false);
  };

  /* ── render ─────────────────────────────────────────────────────────── */

  const boardHeight = Math.max(columns.length, fields.length) * (rowHeight + ROW_GAP) - ROW_GAP || rowHeight;
  const droop = gutterWidth * sag;
  const cables = fields.flatMap((field, fieldIdx) => {
    const columnId = mapping[field.id];
    if (!columnId) return [];
    const colIdx = columnIndex.get(columnId);
    if (colIdx === undefined) return [];
    return [{ key: field.id, d: cablePath(rowCenter(colIdx, rowHeight), rowCenter(fieldIdx, rowHeight), droop), guess: guesses.has(field.id), hidden: pending.has(field.id), drawing: drawing.has(field.id), lit: held?.columnId === columnId }];
  });
  const heldFrom = held ? rowCenter(columnIndex.get(held.columnId) ?? 0, rowHeight) : 0;
  const looseD = held ? (candidate !== null && !held.pointer ? cablePath(heldFrom, rowCenter(candidate, rowHeight), droop) : loosePoint.current ? looseCable(heldFrom, loosePoint.current.x, loosePoint.current.y, droop) : cablePath(heldFrom, heldFrom, droop)) : "";
  const missingLabels = summary.missingRequired.map((id) => fields.find((f) => f.id === id)?.label).filter(Boolean) as string[];
  const blocked = !summary.complete || columns.length === 0;
  const unusedNote = summary.unusedColumns > 0 ? `, en ${summary.unusedColumns} ${summary.unusedColumns === 1 ? t.unusedOne : t.unused}` : "";
  const verdict = columns.length === 0 ? t.nothingYet : missingLabels.length > 0 ? `${missingLabels.join(", ")} ${missingLabels.length === 1 ? t.needsColumn : t.needColumn}` : summary.review > 0 ? `${summary.review} ${t.toReview}` + unusedNote : t.allSet + (unusedNote || ".");
  const gutterStyle = { width: `clamp(52px, 20%, ${gutterWidth}px)` };

  return (
    <div {...rest} ref={ref} style={style} className={["fp-patch", className].filter(Boolean).join(" ")}>
      <div className="fp-patch__kop">
        <div className="fp-patch__bestand">
          <span aria-hidden="true" className="fp-patch__type">CSV</span>
          <span className="fp-patch__bestandsnaam">
            <span className="fp-patch__naam">{fileName ?? t.file}</span>
            <span className="fp-patch__meta">{rowCount !== undefined ? `${rowCount.toLocaleString()} ${t.rows} · ` : ""}{columns.length} {columns.length === 1 ? t.column : t.columns}</span>
          </span>
        </div>
        <Button variant="ghost" onPress={autoPatch} disabled={proposals.length === 0} className="fp-patch__auto">
          <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M2 4.5h3.5a3 3 0 0 1 3 3v1a3 3 0 0 0 3 3H14M11.5 9 14 11.5 11.5 14" /></svg>
          {t.autoPatch}{proposals.length > 0 ? ` ${proposals.length}` : ""}
        </Button>
      </div>

      <div className="fp-patch__bord">
        <div hidden={columns.length === 0} className="fp-patch__koppen">
          <span className="fp-patch__kopje">{t.inFile}</span>
          <span aria-hidden="true" className="fp-patch__goot-ruimte" style={gutterStyle} />
          <span className="fp-patch__kopje">{t.yourFields}</span>
        </div>

        {columns.length === 0 ? (
          <p className="fp-patch__leeg">{t.empty}</p>
        ) : (
          <div className="fp-patch__rijen" onBlur={(e) => { if (held && !held.pointer && !e.currentTarget.contains(e.relatedTarget as Node)) cancelHold(); }}>
            <ul aria-label={t.inFile} className="fp-patch__lijst" style={{ gap: ROW_GAP }}>
              {columns.map((column, i) => {
                const info = infoById.get(column.id);
                const intoField = columnToField.get(column.id);
                const isHeld = held?.columnId === column.id;
                const samples = (column.samples ?? []).filter(Boolean).slice(0, 2);
                return (
                  <li key={column.id} style={{ height: rowHeight }}>
                    <button
                      type="button"
                      data-gekoppeld={intoField ? "" : undefined}
                      data-vast={isHeld ? "" : undefined}
                      ref={(el) => { columnRefs.current[i] = el; columnRefs.current.length = columns.length; }}
                      tabIndex={i === Math.min(activeColumn, columns.length - 1) ? 0 : -1}
                      onFocus={() => setActiveColumn(i)}
                      onKeyDown={(e) => onColumnKeyDown(e, i, column.id)}
                      style={{ touchAction: "pan-y" }}
                      onPointerDown={(e) => { if (e.button !== 0) return; e.preventDefault(); columnRefs.current[i]?.focus(); pressAt.current = { x: e.clientX, y: e.clientY }; moved.current = false; pickUp(column.id, true, e); }}
                      className="fp-patch__jack fp-patch__jack--kolom"
                    >
                      <span aria-hidden="true" className="fp-patch__jack-tekst">
                        <span className="fp-patch__jack-naam">{column.name}</span>
                        {showSamples && samples.length > 0 ? <span className="fp-patch__jack-sub fp-patch__mono">{samples.join("  ·  ")}</span> : null}
                      </span>
                      {showKinds && info ? (
                        <span aria-hidden="true" className="fp-patch__soort fp-patch__mono">
                          <span className="fp-patch__soort--lang">{t.kinds[info.kind]}</span>
                          <span className="fp-patch__soort--kort">{t.kindsShort[info.kind]}</span>
                        </span>
                      ) : null}
                      {column.issues ? <span aria-hidden="true" className="fp-patch__fout fp-patch__mono">{column.issues} {t.bad}</span> : null}
                      <span aria-hidden="true" className={["fp-patch__plug fp-patch__plug--rechts", intoField || isHeld ? "fp-patch__plug--actief" : ""].filter(Boolean).join(" ")}>{intoField || isHeld ? <span className="fp-patch__plug-kern" /> : null}</span>
                      <span className="fp-patch__sr">
                        {vul(t.sr.columnOf, { name: column.name, i: i + 1, n: columns.length })} {info ? vul(t.sr.valuesLike, { kind: t.kinds[info.kind] }) + " " : ""}
                        {intoField ? vul(t.sr.patchedInto, { field: label(intoField) }) : t.sr.notPatched}
                      </span>
                    </button>
                  </li>
                );
              })}
            </ul>

            <svg aria-hidden="true" ref={gutterRef} height={boardHeight} viewBox={`0 0 ${CABLE_UNITS} ${boardHeight}`} preserveAspectRatio="none" style={{ ...gutterStyle, height: boardHeight }} className="fp-patch__goot">
              {cables.map((c) => (
                <path key={c.key} d={c.d} pathLength={c.guess ? undefined : 1} fill="none" strokeLinecap="round" vectorEffect="non-scaling-stroke" strokeWidth={c.lit ? 2.4 : 1.75} strokeDasharray={c.guess ? "5 4" : 1} className={["fp-patch__kabel", c.guess ? "fp-patch__kabel--gok" : "", c.drawing && !c.guess ? "fp-patch__kabel--tekent" : ""].filter(Boolean).join(" ")} style={{ opacity: c.hidden ? 0 : 1 }} />
              ))}
              {held ? <path ref={looseRef} d={looseD} fill="none" strokeLinecap="round" vectorEffect="non-scaling-stroke" strokeWidth={2.4} strokeDasharray={held.pointer ? undefined : "5 4"} className="fp-patch__kabel" /> : null}
            </svg>

            <ul aria-label={t.yourFields} className="fp-patch__lijst" style={{ gap: ROW_GAP }}>
              {fields.map((field, i) => {
                const columnId = mapping[field.id];
                const column = columnId ? columns.find((c) => c.id === columnId) : undefined;
                const isCandidate = held !== null && candidate === i;
                const isGuess = guesses.has(field.id);
                const info = columnId ? infoById.get(columnId) : undefined;
                const mismatch = info && field.kind && field.kind !== "text" && kindScore(info.kind, field.kind) < 0.5;
                const missing = field.required && !columnId;
                return (
                  <li key={field.id} style={{ height: rowHeight }}>
                    <button
                      type="button"
                      data-gekoppeld={columnId ? "" : undefined}
                      data-kandidaat={isCandidate ? "" : undefined}
                      data-ontbreekt={missing ? "" : undefined}
                      id={`${uid}-field-${field.id}`}
                      ref={(el) => { fieldRefs.current[i] = el; fieldRefs.current.length = fields.length; }}
                      tabIndex={i === Math.min(activeField, fields.length - 1) ? 0 : -1}
                      onFocus={() => setActiveField(i)}
                      onKeyDown={(e) => onFieldKeyDown(e, i, field.id)}
                      onPointerEnter={() => held?.pointer && setCandidate(i)}
                      onClick={() => onFieldActivate(field.id)}
                      className="fp-patch__jack fp-patch__jack--veld"
                    >
                      <span aria-hidden="true" className="fp-patch__jack-tekst">
                        <span className="fp-patch__jack-naam"><span className="fp-patch__afkap">{field.label}</span>{field.required ? <span className="fp-patch__ster">*</span> : null}</span>
                        <span className="fp-patch__jack-sub">{column ? <span className="fp-patch__mono">{column.name}</span> : (field.hint ?? (showKinds && field.kind ? `${t.expects} ${t.kinds[field.kind]}` : t.emptyField))}</span>
                      </span>
                      {isGuess ? <span aria-hidden="true" className="fp-patch__badge">{t.review}</span> : mismatch ? <span aria-hidden="true" className="fp-patch__fout fp-patch__mono">{info ? t.kinds[info.kind] : ""}?</span> : null}
                      <span aria-hidden="true" className={["fp-patch__plug fp-patch__plug--links", columnId || isCandidate ? "fp-patch__plug--actief" : "", missing ? "fp-patch__plug--ontbreekt" : ""].filter(Boolean).join(" ")}>{columnId ? <span className={["fp-patch__plug-kern", drawing.has(field.id) ? "fp-patch__plug-kern--zit" : ""].filter(Boolean).join(" ")} /> : null}</span>
                      <span className="fp-patch__sr">
                        {vul(t.sr.fieldOf, { label: field.label, required: field.required ? t.sr.required : "", i: i + 1, n: fields.length })} {column ? vul(t.sr.patchedFrom, { column: column.name }) + (isGuess ? t.sr.lowConfidence : t.sr.pressDelete) : held ? t.sr.emptyHeld : t.sr.emptyPlain}
                        {mismatch && info && field.kind ? vul(t.sr.warning, { have: t.kinds[info.kind], want: t.kinds[field.kind] }) : ""}
                      </span>
                    </button>
                  </li>
                );
              })}
            </ul>
          </div>
        )}
      </div>

      <div className="fp-patch__voet">
        <p id={`${uid}-verdict`} className="fp-patch__oordeel">
          <span className="fp-patch__teller">{summary.patched} {t.patchedOf} {summary.fields}</span>
          <span className="fp-patch__punt" aria-hidden="true">·</span>
          <span className={missingLabels.length > 0 ? "fp-patch__oordeel--rood" : undefined}>{verdict}</span>
        </p>
        <Button aria-disabled={blocked || undefined} aria-describedby={`${uid}-verdict`} onPress={() => (blocked ? setMessage(verdict) : onConfirm?.(mapping))} className={["fp-patch__bevestig", blocked ? "fp-patch__bevestig--geblokkeerd" : ""].filter(Boolean).join(" ")}>
          {t.confirm}
        </Button>
      </div>
      <p aria-live="polite" className="fp-patch__sr">{message}</p>
    </div>
  );
});
blocks/apps-ii/apps-ii.css
/**
 * Apps II — importeren in de app-root (registry-site: app/globals.css).
 * Token-contract 2: widget-grid, import-patchbay, clip-showcase, flip-gallery.
 */

/* ── widget-grid ─────────────────────────────────────────────────────────── */
.fp-widgets { position: relative; width: 100%; }
.fp-widgets__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fp-widgets__raster { display: grid; width: 100%; }
.fp-widgets__widget { position: relative; min-width: 0; border-radius: var(--radius-lg); outline: none; }
.fp-widgets__widget:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-widgets__widget--sleepbaar { cursor: grab; touch-action: pan-y pinch-zoom; user-select: none; -webkit-touch-callout: none; }
.fp-widgets__widget--sleepbaar:active { cursor: grabbing; }
.fp-widgets__widget a, .fp-widgets__widget img { -webkit-user-drag: none; }
/* De schaduw van het optillen: verf, via een klasse — framer doet alleen de schaal (één motor per property). */
.fp-widgets__vlak {
  position: relative; isolation: isolate; display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden;
  border-radius: var(--radius-lg); background: var(--card); color: var(--card-foreground, var(--foreground));
  box-shadow: inset 0 0 0 var(--border-width) var(--border), 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent);
  clip-path: inset(0 round var(--radius-lg));
  transition: box-shadow var(--motion-default-duration) var(--motion-default);
}
.fp-widgets__widget[data-opgetild] .fp-widgets__vlak { box-shadow: inset 0 0 0 var(--border-width) var(--border), var(--shadow-overlay); }
.fp-widgets__vlak--geland { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--foreground) 40%, transparent); }

/* ── import-patchbay ─────────────────────────────────────────────────────── */
.fp-patch { width: 100%; overflow: hidden; border-radius: var(--radius-lg); border: var(--border-width) solid var(--border); background: var(--card); color: var(--card-foreground, var(--foreground)); box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 6%, transparent); }
.fp-patch__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fp-patch__mono { font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
.fp-patch__kop { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); border-bottom: var(--border-width) solid var(--border); }
.fp-patch__bestand { display: flex; align-items: center; gap: var(--space-xs); min-width: 0; }
.fp-patch__type { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 2rem; height: 2rem; border-radius: var(--radius-sm); border: var(--border-width) solid var(--border); background: var(--muted); color: var(--foreground); font-size: var(--text-label); font-weight: 500; }
.fp-patch__bestandsnaam { display: grid; min-width: 0; }
.fp-patch__naam { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-body-sm); font-weight: 500; color: var(--foreground); }
.fp-patch__meta { font-size: var(--text-label); color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
.fp-patch__auto { display: inline-flex; align-items: center; gap: var(--space-xs); height: 2.75rem; border-radius: 999px; padding: 0 var(--space-md); }
.fp-patch__auto > svg { width: 0.875rem; height: 0.875rem; color: var(--primary); }
.fp-patch__bord { container-type: inline-size; padding: var(--space-md) var(--space-md) var(--space-2xs); }
.fp-patch__koppen { display: flex; align-items: center; margin-bottom: var(--space-xs); font-size: var(--text-label); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted-foreground); }
.fp-patch__kopje { flex: 1; min-width: 0; }
.fp-patch__goot-ruimte { flex: 0 0 auto; }
.fp-patch__leeg { margin: 0; padding: var(--space-xl) var(--space-md); border: var(--border-width) dashed var(--border); border-radius: var(--radius); text-align: center; font-size: var(--text-body-sm); color: var(--muted-foreground); }
.fp-patch__rijen { display: flex; align-items: flex-start; }
.fp-patch__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex: 1; min-width: 0; flex-direction: column; }
.fp-patch__jack {
  position: relative; display: flex; align-items: center; gap: var(--space-xs); width: 100%; height: 100%;
  border: var(--border-width) solid var(--border); border-radius: var(--radius); background: var(--background); color: var(--foreground);
  cursor: pointer; font: inherit; text-align: left;
  transition: border-color var(--motion-instant-duration) var(--motion-instant), background var(--motion-instant-duration) var(--motion-instant);
}
.fp-patch__jack--kolom { padding: 0 var(--space-lg) 0 var(--space-sm); }
.fp-patch__jack--veld { padding: 0 var(--space-sm) 0 var(--space-lg); }
.fp-patch__jack:hover { border-color: color-mix(in srgb, var(--foreground) 25%, transparent); }
.fp-patch__jack:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-patch__jack[data-gekoppeld] { border-color: color-mix(in srgb, var(--primary) 40%, transparent); }
.fp-patch__jack[data-vast] { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 10%, transparent); }
.fp-patch__jack[data-ontbreekt] { border-style: dashed; border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
.fp-patch__jack[data-kandidaat] { border-color: var(--primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 40%, transparent); }
.fp-patch__jack-tekst { flex: 1; min-width: 0; display: grid; }
.fp-patch__jack-naam { display: flex; align-items: center; gap: var(--space-2xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-body-sm); font-weight: 500; color: var(--foreground); }
.fp-patch__afkap { overflow: hidden; text-overflow: ellipsis; }
.fp-patch__ster { flex: 0 0 auto; font-size: var(--text-label); color: var(--muted-foreground); }
.fp-patch__jack-sub { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-label); color: var(--muted-foreground); }
.fp-patch__soort { flex: 0 0 auto; font-size: var(--text-label); color: var(--muted-foreground); }
.fp-patch__soort--lang { display: none; }
@container (min-width: 420px) { .fp-patch__soort--lang { display: inline; } .fp-patch__soort--kort { display: none; } }
.fp-patch__fout { flex: 0 0 auto; font-size: var(--text-label); color: var(--danger); font-variant-numeric: tabular-nums; }
.fp-patch__badge { flex: 0 0 auto; padding: 0.125rem var(--space-xs); border-radius: 999px; border: var(--border-width) solid var(--border); background: var(--background); font-size: var(--text-label); font-weight: 500; color: var(--muted-foreground); }
/* De plug: zijn middelpunt ligt precies op de rand van de goot. */
.fp-patch__plug { position: absolute; top: 50%; display: flex; align-items: center; justify-content: center; width: 0.875rem; height: 0.875rem; border-radius: 50%; border: 2px solid var(--border); background: var(--background); transform: translateY(-50%); }
.fp-patch__plug--rechts { right: -7px; }
.fp-patch__plug--links { left: -7px; }
.fp-patch__plug--actief { border-color: var(--primary); }
.fp-patch__plug--ontbreekt { border-color: color-mix(in srgb, var(--danger) 60%, transparent); }
.fp-patch__plug-kern { display: block; width: 0.375rem; height: 0.375rem; border-radius: 50%; background: var(--primary); }
.fp-patch__plug-kern--zit { animation: fp-patch-zit var(--motion-instant-duration) var(--motion-instant); }
@keyframes fp-patch-zit { 0% { transform: scale(1); } 45% { transform: scale(1.12); } 100% { transform: scale(1); } }
.fp-patch__goot { flex: 0 0 auto; overflow: visible; }
.fp-patch__kabel { stroke: var(--primary); }
.fp-patch__kabel--gok { stroke: var(--muted-foreground); }
.fp-patch__kabel--tekent { animation: fp-patch-teken var(--motion-default-duration) var(--motion-default); }
@keyframes fp-patch-teken { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) { .fp-patch__kabel--tekent, .fp-patch__plug-kern--zit { animation: none; } }
.fp-patch__voet { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); margin-top: var(--space-sm); padding: var(--space-sm) var(--space-md); border-top: var(--border-width) solid var(--border); }
.fp-patch__oordeel { min-width: 0; margin: 0; font-size: var(--text-label); color: var(--muted-foreground); }
.fp-patch__teller { font-weight: 500; color: var(--foreground); font-variant-numeric: tabular-nums; }
.fp-patch__punt { margin: 0 var(--space-2xs); }
.fp-patch__oordeel--rood { color: var(--danger); }
.fp-patch__bevestig { height: 2.75rem; border-radius: 999px; padding: 0 var(--space-lg); }
.fp-patch__bevestig--geblokkeerd { background: var(--muted); color: var(--foreground); cursor: not-allowed; }

/* ── clip-showcase ───────────────────────────────────────────────────────── */
.fp-clipshow { display: flex; flex-direction: column; align-items: center; justify-content: space-between; gap: var(--space-2xl); width: 100%; min-height: var(--fp-clipshow-h, 100vh); padding: var(--space-xl); box-sizing: border-box; overflow: hidden; background: var(--background); color: var(--foreground); }
@media (min-width: 48rem) { .fp-clipshow { flex-direction: row; padding: var(--space-2xl); } }
.fp-clipshow__menu { position: relative; z-index: 2; width: 100%; }
@media (min-width: 48rem) { .fp-clipshow__menu { width: 50%; } }
.fp-clipshow__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2xl); }
.fp-clipshow__knop { display: flex; align-items: flex-start; gap: var(--space-lg); padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; text-align: left; text-decoration: none; }
.fp-clipshow__knop:focus-visible { outline: 2px solid var(--ring); outline-offset: 4px; border-radius: var(--radius-sm); }
.fp-clipshow__nummer { margin-top: var(--space-xs); font-size: var(--text-h3); font-weight: 700; color: var(--muted-foreground); transform: scale(1); transition: color var(--motion-default-duration) var(--motion-default), transform var(--motion-default-duration) var(--motion-default); }
.fp-clipshow__item[data-actief] .fp-clipshow__nummer { color: var(--primary); transform: scale(1.1); }
/* Inactieve titels: alleen een contourlijn in de tekstkleur; de actieve is gevuld en schuift naar rechts. */
.fp-clipshow__titel { font-size: var(--text-display); line-height: 0.85; font-weight: 900; letter-spacing: -0.05em; text-transform: uppercase; color: transparent; -webkit-text-stroke: 1.5px color-mix(in srgb, var(--foreground) 40%, transparent); opacity: 0.6; transform: translateX(0); transition: color var(--motion-smooth-duration) var(--motion-smooth), opacity var(--motion-smooth-duration) var(--motion-smooth), transform var(--motion-smooth-duration) var(--motion-smooth); }
.fp-clipshow__item[data-actief] .fp-clipshow__titel { color: var(--foreground); -webkit-text-stroke: 0; opacity: 1; transform: translateX(var(--space-md)); }
.fp-clipshow__podium { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; }
@media (min-width: 48rem) { .fp-clipshow__podium { width: 50%; } }
.fp-clipshow__gloed { position: absolute; width: 120%; height: 120%; border-radius: 50%; background: color-mix(in srgb, var(--primary) 10%, transparent); filter: blur(120px); pointer-events: none; }
.fp-clipshow__doek { position: relative; z-index: 1; width: 100%; max-width: 31rem; height: auto; filter: drop-shadow(var(--shadow-overlay)); }

/* ── flip-gallery ────────────────────────────────────────────────────────── */
.fp-flipgal { position: relative; width: 100%; min-height: var(--fp-flipgal-h, 100vh); overflow: hidden; background: var(--background); color: var(--foreground); }
.fp-flipgal__chroom { opacity: 0; pointer-events: none; transition: opacity var(--motion-smooth-duration) var(--motion-smooth); }
.fp-flipgal--open .fp-flipgal__chroom { opacity: 1; pointer-events: auto; }
.fp-flipgal__teller { position: absolute; top: var(--space-lg); left: var(--space-lg); z-index: 60; margin: 0; font-size: var(--text-label); letter-spacing: 0.02em; font-variant-numeric: tabular-nums; }
.fp-flipgal__teller span { color: var(--muted-foreground); }
.fp-flipgal__podium { position: relative; width: 100%; min-height: var(--fp-flipgal-h, 100vh); cursor: default; }
.fp-flipgal--open .fp-flipgal__podium { cursor: pointer; }
.fp-flipgal__paneel { position: absolute; top: 50%; z-index: 30; max-width: 22rem; transform: translateY(-50%); pointer-events: none; }
.fp-flipgal__paneel--verborgen { display: none; }
.fp-flipgal__titel { margin: 0; font: inherit; font-size: var(--text-h2); line-height: 1.05; font-weight: 400; letter-spacing: -0.02em; }
.fp-flipgal__meta { margin: var(--space-xs) 0 var(--space-lg); font-size: var(--text-label); color: var(--muted-foreground); }
.fp-flipgal__omschrijving { margin: 0; font-size: var(--text-body-sm); line-height: 1.65; }
.fp-flipgal__bijschrift { position: relative; min-height: calc(var(--text-body-sm) * 1.3 * 2); margin-top: var(--space-lg); }
.fp-flipgal__bijschrift p { margin: 0; width: 80%; font-size: var(--text-body-sm); line-height: 1.3; }
.fp-flipgal__kaart { border-radius: var(--radius-lg); overflow: hidden; background: var(--muted); box-shadow: var(--shadow-overlay); will-change: transform; }
.fp-flipgal__kaartknop { display: block; width: 100%; height: 100%; padding: 0; border: 0; background: transparent; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.fp-flipgal__kaartknop:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
.fp-flipgal__kaartknop[data-pressed] { transform: scale(0.98); }
.fp-flipgal__kaartknop > img { display: block; width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.fp-flipgal--open .fp-flipgal__kaart[data-index] .fp-flipgal__kaartknop[aria-current] { cursor: default; }
/* Mobiel: gestapelde kolom met groot beeld, tekst en een raster van miniaturen. */
.fp-flipgal--mobiel { display: flex; flex-direction: column; gap: var(--space-lg); padding: var(--space-xl) var(--space-md); box-sizing: border-box; overflow-y: auto; }
.fp-flipgal--mobiel .fp-flipgal__teller { position: static; }
.fp-flipgal__held { position: relative; flex: 1; min-height: 45vh; border-radius: var(--radius-lg); overflow: hidden; background: var(--muted); box-shadow: var(--shadow-overlay); }
.fp-flipgal__held > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.fp-flipgal__raster { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-sm); }
.fp-flipgal__mini { position: relative; aspect-ratio: 4 / 5; width: 100%; padding: 0; border: 0; border-radius: var(--radius); overflow: hidden; background: var(--muted); cursor: pointer; -webkit-tap-highlight-color: transparent; }
.fp-flipgal__mini:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-flipgal__mini[data-pressed] { transform: scale(0.97); }
.fp-flipgal__mini > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }