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.
klanten-export.csv
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; }