· Webentwicklung · 6 minuten Lesezeit
Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input mit Chip-Animation
Tags in einer Vektordatenbank müssen konsistent sein. "Rolex" und "rolex" sind zwei verschiedene Token, beide landen im Embedding. Ein toLowerCase() und eine animierte Chip-Reaktion auf Duplikate lösen das Problem ohne UI-Reibung.

Inhalt
- Warum Normalisierung wichtig ist
- Die Normalisierung
- Das Duplikat-Problem
- Die Duplikat-Prüfung
- Das UX-Problem mit stiller Ablehnung
- Die Chip-Pulse-Animation
- querySelector innerhalb des Shadow DOM
- Das vollständige commitTagInput
- Alle Artikel der Serie
Warum Normalisierung wichtig ist
Tags landen direkt im Embedding-Text. Wie daraus im Backend ein Vektor entsteht, beschreibe ich im Artikel über Qdrant, Embeddings und den RAG-System-Aufbau. Das Backend konkateniert alle Felder zu einer Zeichenkette, die dann in einen Vektor umgewandelt wird.
const parts = [
channel ?? '',
caption ?? '',
altTexts.join(' '),
note ?? '',
tags.join(' '), // Tags are embedded directly
].filter(Boolean);Das Embedding-Modell kennt keine Tags im abstrakten Sinne. Es sieht Text-Token. “Python” und “python” sind für die meisten Tokenizer verschiedene Token oder zumindest in unterschiedlichen Embedding-Dimensionen aktiv. Zwei Posts mit dem gleichen konzeptuellen Tag landen im Vektorraum an leicht unterschiedlichen Positionen, wenn die Schreibweise inkonsistent ist.
Das beeinträchtigt Suchanfragen. Wenn der Nutzer nach “python” fragt und Hälfte der Posts das Tag als “Python” gespeichert hat, ist die Trefferquote schlechter als nötig.
Die Lösung ist einfach: .toLowerCase() in commitTagInput(). Die Grundlage für commitTagInput() habe ich im Artikel über Notiz und Tags im Capture-Overlay beschrieben.
Die Normalisierung
Vorher:
const commitTagInput = () => {
const val = tagInput.value.replace(/,/g, '').trim();
if (val) {
currentTags.push(val);
// ...
}
};Danach:
const commitTagInput = () => {
const val = tagInput.value.replace(/,/g, '').trim().toLowerCase();
if (!val) return;
// ...
};Ein Methodenaufruf. Die Auswirkung ist spürbar: Egal ob der Nutzer “React”, “REACT” oder “react” eingibt, im Array und im Qdrant-Punkt landet immer “react”.
Das Duplikat-Problem
Normalisierung auf Lowercase schafft ein neues Problem: “React” und “react” werden als gleich behandelt. Gut für die Konsistenz. Aber was passiert, wenn der Nutzer denselben Tag zweimal eingibt?
Ohne Duplikat-Erkennung landen beide im Array:
currentTags = ['react', 'typescript', 'react']; // unerwünschtDas Embedding enthält “react” zweimal. Der Qdrant-Punkt ist falsch gewichtet. Und in der UI sieht der Nutzer zwei identische Chips nebeneinander, ein UI-Signal, dass etwas nicht stimmt.
Die Duplikat-Prüfung
Der Check ist minimal:
const commitTagInput = () => {
const val = tagInput.value.replace(/,/g, '').trim().toLowerCase();
if (!val) return;
if (currentTags.includes(val)) {
// duplicate: skip
tagInput.value = '';
return;
}
currentTags.push(val);
tagInput.value = '';
renderChips();
};Array.includes() auf einem typischerweise kleinen Array von 5 bis 15 Tags ist O(n). Das ist völlig ausreichend. Eine Map oder Set wäre Overengineering für diesen Use Case.
Das UX-Problem mit stiller Ablehnung
Der Code oben clearst den Input und gibt kein Feedback. Aus Nutzerperspektive: Der Nutzer tippt “react”, drückt Enter, der Input wird geleert. Aber kein neuer Chip erscheint. Warum? Keine Ahnung.
Stille Ablehnung ist schlechtes UX-Design. Der Nutzer versteht nicht, was passiert ist.
Die Frage: Welches Signal ist eindeutig, ohne zu stören?
Eine Fehlermeldung wäre übertrieben. Ein roter Rahmen um das Eingabefeld zeigt auf das falsche Element, denn das Eingabefeld ist nicht kaputt und der Tag existiert bereits.
Das richtige Signal: Der bestehende Chip für diesen Tag reagiert. Er zeigt: “Ich bin bereits da.”
Die Chip-Pulse-Animation
Der Chip erhält für 450ms eine Klasse, die eine kurze Farbanimation auslöst:
@keyframes chipPulse {
0% {
background: #ede9fe;
color: #4f46e5;
}
35% {
background: #fca5a5;
color: #dc2626;
transform: scale(1.06);
}
100% {
background: #ede9fe;
color: #4f46e5;
transform: scale(1);
}
}
.chip-duplicate {
animation: chipPulse 0.45s ease;
}Der Chip flasht kurz rot und wächst minimal. Danach kehrt er zur normalen Farbe zurück. Der Nutzer sieht sofort: “Dieser Tag existiert bereits.” Die Animation ist schnell genug, um nicht zu nerven, aber deutlich genug, um wahrgenommen zu werden.
Die Klasse wird nach der Animation wieder entfernt, damit sie beim nächsten Duplikat erneut ausgelöst werden kann:
if (currentTags.includes(val)) {
tagInput.value = '';
const idx = currentTags.indexOf(val);
const chips = Array.from(tagsBox.querySelectorAll<HTMLElement>('.chip'));
const dupChip = chips[idx];
if (dupChip) {
dupChip.classList.add('chip-duplicate');
dupChip.addEventListener('animationend', () => dupChip.classList.remove('chip-duplicate'), { once: true });
}
return;
}{ once: true } ist wichtig. Ohne es würde ein neuer Listener bei jedem Duplikat-Versuch auf demselben Chip hinzugefügt. Nach 5 Duplikat-Versuchen auf dem gleichen Tag: 5 Listener, die alle beim nächsten animationend feuern. Mit { once: true } entfernt sich der Listener nach dem ersten Aufruf selbst.
querySelector innerhalb des Shadow DOM
Die Chip-Suche über tagsBox.querySelectorAll(".chip") ist wichtig: Sie sucht innerhalb der Shadow Root, nicht im gesamten Dokument. document.querySelectorAll(".chip") würde nichts finden, weil der Shadow DOM vom äußeren DOM getrennt ist.
// Correct: search within the shadow root
const chips = Array.from(tagsBox.querySelectorAll<HTMLElement>('.chip'));Der Index des Chips entspricht dem Index im currentTags-Array, weil renderChips() beide in derselben Reihenfolge erzeugt. chips[idx] ist also der Chip für currentTags[idx].
Das vollständige commitTagInput
const commitTagInput = () => {
const val = tagInput.value.replace(/,/g, '').trim().toLowerCase();
if (!val) return;
if (currentTags.includes(val)) {
tagInput.value = '';
const idx = currentTags.indexOf(val);
const chips = Array.from(tagsBox.querySelectorAll<HTMLElement>('.chip'));
const dupChip = chips[idx];
if (dupChip) {
dupChip.classList.add('chip-duplicate');
dupChip.addEventListener('animationend', () => dupChip.classList.remove('chip-duplicate'), { once: true });
}
return;
}
currentTags.push(val);
tagInput.value = '';
renderChips();
};Zwei Responsibilities in einer Funktion: Normalisierung und Duplikat-Schutz. Das ist kohärent, weil beide dasselbe Ziel haben: ein konsistentes Tag-Array ohne Überraschungen.
Abbildung: Der Ablauf bei Duplikat-Eingabe: Nutzer tippt “react” (bereits als Chip vorhanden), drückt Enter, der Input wird geleert, und der bestehende “react”-Chip flasht kurz rot, bevor er zur normalen Farbe zurueckkehrt.
Alle Artikel der Serie
Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
tsconfig und Vite:
Node16vs.bundler, warum Vite eigene Regeln hat: Artikel lesenInstagram Caption mit MutationObserver vollständig laden: Artikel lesen
Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen
Notiz und Tags beim Screenshot-Speichern: Artikel lesen
Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input (dieser Artikel)
Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
React Frontend mit react-oidc-context und Zitadel: Artikel lesen
Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen
Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen
Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen
Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen
DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen
Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen
Traefik als einziger Einstiegspunkt im Docker Compose Stack: Artikel lesen
Zitadel hinter Traefik richtig verdrahten mit Issuer, JWKS und Login V2: Artikel lesen
Frontend gesund machen wenn der nginx Healthcheck an localhost scheitert: Artikel lesen
Du baust ein Datenerfassungssystem auf Basis von Browser Extensions mit komplexen DOM-Interaktionen? Lass uns das gemeinsam einschätzen.



