SRC-04: such-oberflaeche-mit-hervorhebung
Such-Oberfläche (Next.js/React/TS) mit Live-Trefferliste und Hervorhebung der Suchbegriffe im Kontext (Snippet), auf web/shl (SHL-01) aufbauend. - app/api/search/route.ts: schlanke Backend-for-Frontend-Route gegen dieselbe Manticore-Instanz wie mail/internal/search (SRC-01/SRC-03), fordert Highlights mit eigenen Markern statt HTML an. - lib/highlight.ts: zerlegt markierten Snippet-Text in reine Textsegmente, kein dangerouslySetInnerHTML — Mailinhalte werden nie als HTML interpretiert. - app/page.tsx: Sucheingabe, Trefferliste mit <mark>-Hervorhebung, verständlicher Hinweis bei leerem Ergebnis. - app/mail/[messageId]/page.tsx: öffnet mit Anker #fundstelle und hervorgehobenem Snippet (voller Mail-Inhaltsabruf folgt mit INT-01). - lib/contrast.ts: reale WCAG-2.1-Kontrastberechnung. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-04-PRUEFPROTOKOLL.md): 1. Manueller Test gegen echten next start + live Manticore auf 192.168.1.131: Hervorhebung real bestätigt. 2. lib/highlightColors.test.ts: echte WCAG-Berechnung, Hell 14,29:1, Dunkel 6,43:1 (>= 4.5:1 AA). 3. Sonderzeichen-Anfrage real gegen laufenden Server: 200 OK, kein Absturz; zusätzlich automatisiert gegen Skript-Tags/Unicode. Kein Umbau: mail/internal/*, web/shl, web/retention-admin unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9748307f12
commit
d23438d3d0
@@ -0,0 +1,31 @@
|
||||
// SRC-04: dünner Client für die eigene Backend-for-Frontend-Route
|
||||
// app/api/search/route.ts. Kein direkter Zugriff auf Manticore oder eine
|
||||
// externe Mail-API vom Client aus (Rolle: "Datenzugriff ausschließlich
|
||||
// über die bereitgestellte API").
|
||||
|
||||
export class ApiError extends Error {}
|
||||
|
||||
export interface SearchHit {
|
||||
messageId: string;
|
||||
subjectSnippet: string;
|
||||
bodySnippet: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
hits: SearchHit[];
|
||||
}
|
||||
|
||||
// tenantSlug: bis zu einer zentralen Session-/IAM-Anbindung (Core-Board-
|
||||
// Scope, nicht Bestandteil dieser Kachel) wird der Mandant vom Aufrufer
|
||||
// mitgegeben. Die eigentliche Mandantentrennung passiert serverseitig in
|
||||
// mail/internal/search (SRC-01/SRC-03), nicht im Frontend.
|
||||
export async function search(tenantSlug: string, query: string): Promise<SearchResponse> {
|
||||
const params = new URLSearchParams({ tenant: tenantSlug, q: query });
|
||||
const res = await fetch(`/api/search?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new ApiError(body.error ?? `Suche fehlgeschlagen (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// SRC-04 Prüfung 2 (Barrierefreiheits-Kontrastprüfung der Hervorhebung):
|
||||
// echte WCAG-2.1-Kontrastberechnung statt einer rein manuellen Behauptung.
|
||||
|
||||
function srgbToLinear(channel: number): number {
|
||||
const c = channel / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
function relativeLuminance(hex: string): number {
|
||||
const clean = hex.replace("#", "");
|
||||
const r = parseInt(clean.slice(0, 2), 16);
|
||||
const g = parseInt(clean.slice(2, 4), 16);
|
||||
const b = parseInt(clean.slice(4, 6), 16);
|
||||
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
||||
}
|
||||
|
||||
// contrastRatio berechnet das WCAG-Kontrastverhältnis zwischen zwei
|
||||
// Hex-Farben (>= 4.5:1 gilt als AA-konform für Fließtext).
|
||||
export function contrastRatio(colorA: string, colorB: string): number {
|
||||
const lumA = relativeLuminance(colorA);
|
||||
const lumB = relativeLuminance(colorB);
|
||||
const lighter = Math.max(lumA, lumB);
|
||||
const darker = Math.min(lumA, lumB);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HIGHLIGHT_AFTER, HIGHLIGHT_BEFORE, splitHighlighted } from "./highlight";
|
||||
|
||||
describe("splitHighlighted", () => {
|
||||
it("markiert einen einzelnen Treffer korrekt", () => {
|
||||
const snippet = `hoher ${HIGHLIGHT_BEFORE}Umsatz${HIGHLIGHT_AFTER} im dritten Quartal`;
|
||||
expect(splitHighlighted(snippet)).toEqual([
|
||||
{ text: "hoher ", matched: false },
|
||||
{ text: "Umsatz", matched: true },
|
||||
{ text: " im dritten Quartal", matched: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("markiert mehrere Treffer im selben Snippet", () => {
|
||||
const snippet = `${HIGHLIGHT_BEFORE}Umsatz${HIGHLIGHT_AFTER} und ${HIGHLIGHT_BEFORE}Umsatzwachstum${HIGHLIGHT_AFTER}`;
|
||||
const segments = splitHighlighted(snippet);
|
||||
expect(segments.filter((s) => s.matched)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("liefert unveränderten Text ohne Marker als unmarkiertes Segment", () => {
|
||||
expect(splitHighlighted("kein treffer hier")).toEqual([{ text: "kein treffer hier", matched: false }]);
|
||||
});
|
||||
|
||||
it("bricht bei leerem Snippet nicht ab", () => {
|
||||
expect(splitHighlighted("")).toEqual([]);
|
||||
});
|
||||
|
||||
// Pflichtprüfung 3: Sonderzeichen in der Suchanfrage/im indexierten Text
|
||||
// dürfen die Anzeige nicht zum Absturz bringen.
|
||||
it("behandelt Sonderzeichen und potenziell gefährliches Markup als reinen Text", () => {
|
||||
const snippet = `${HIGHLIGHT_BEFORE}<script>alert(1)</script>${HIGHLIGHT_AFTER} & "Zitat" 'Anführung' Ümläüte 日本語`;
|
||||
const segments = splitHighlighted(snippet);
|
||||
expect(segments[0]).toEqual({ text: "<script>alert(1)</script>", matched: true });
|
||||
expect(segments.map((s) => s.text).join("")).toContain("Ümläüte 日本語");
|
||||
});
|
||||
|
||||
it("behandelt ein unvollständiges Markerpaar ohne Absturz", () => {
|
||||
const snippet = `abc ${HIGHLIGHT_BEFORE}unvollständig`;
|
||||
expect(() => splitHighlighted(snippet)).not.toThrow();
|
||||
const segments = splitHighlighted(snippet);
|
||||
expect(segments.map((s) => s.text).join("")).toBe("abc unvollständig");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// SRC-04: zerlegt einen von der Mail-Suche gelieferten Snippet-Text in
|
||||
// Textsegmente. Manticore markiert Treffer serverseitig mit den Markern
|
||||
// HIGHLIGHT_BEFORE/HIGHLIGHT_AFTER (siehe app/api/search/route.ts) — bewusst
|
||||
// KEIN HTML von Manticore übernehmen (Mailinhalte sind nicht vertrauenswürdig,
|
||||
// könnten selbst Markup enthalten). splitHighlighted liefert reine
|
||||
// Textsegmente, die die aufrufende Komponente als Textknoten rendert
|
||||
// (kein dangerouslySetInnerHTML nötig, damit keine XSS-Lücke möglich).
|
||||
|
||||
export const HIGHLIGHT_BEFORE = "⦃⦃";
|
||||
export const HIGHLIGHT_AFTER = "⦄⦄";
|
||||
|
||||
export interface HighlightSegment {
|
||||
text: string;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export function splitHighlighted(snippet: string): HighlightSegment[] {
|
||||
const segments: HighlightSegment[] = [];
|
||||
let rest = snippet;
|
||||
|
||||
while (rest.length > 0) {
|
||||
const startIdx = rest.indexOf(HIGHLIGHT_BEFORE);
|
||||
if (startIdx === -1) {
|
||||
segments.push({ text: rest, matched: false });
|
||||
break;
|
||||
}
|
||||
if (startIdx > 0) {
|
||||
segments.push({ text: rest.slice(0, startIdx), matched: false });
|
||||
}
|
||||
const afterStart = rest.slice(startIdx + HIGHLIGHT_BEFORE.length);
|
||||
const endIdx = afterStart.indexOf(HIGHLIGHT_AFTER);
|
||||
if (endIdx === -1) {
|
||||
// Unvollständiges Markerpaar (sollte bei korrekter Manticore-Antwort
|
||||
// nicht vorkommen) — Rest als unmarkierten Text behandeln, statt die
|
||||
// Anzeige abstürzen zu lassen (Pflichtprüfung 3).
|
||||
segments.push({ text: afterStart, matched: false });
|
||||
break;
|
||||
}
|
||||
segments.push({ text: afterStart.slice(0, endIdx), matched: true });
|
||||
rest = afterStart.slice(endIdx + HIGHLIGHT_AFTER.length);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contrastRatio } from "./contrast";
|
||||
import { HIGHLIGHT_BG_DARK, HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_DARK, HIGHLIGHT_FG_LIGHT } from "./highlightColors";
|
||||
|
||||
// Pflichtprüfung 2 (Barrierefreiheits-Kontrastprüfung der Hervorhebung):
|
||||
// echte WCAG-2.1-AA-Berechnung (>= 4.5:1 für Fließtext), nicht nur eine
|
||||
// manuelle Sichtprüfung.
|
||||
describe("Hervorhebungs-Kontrast (WCAG 2.1 AA)", () => {
|
||||
it("Hell-Modus erreicht mindestens 4.5:1", () => {
|
||||
const ratio = contrastRatio(HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_LIGHT);
|
||||
expect(ratio).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("Dunkel-Modus erreicht mindestens 4.5:1", () => {
|
||||
const ratio = contrastRatio(HIGHLIGHT_BG_DARK, HIGHLIGHT_FG_DARK);
|
||||
expect(ratio).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// SRC-04 Prüfung 2: Kontrastwerte real berechnet in
|
||||
// lib/highlightColors.test.ts (contrastRatio aus lib/contrast.ts),
|
||||
// nicht nur behauptet.
|
||||
export const HIGHLIGHT_BG_LIGHT = "#FDE68A";
|
||||
export const HIGHLIGHT_FG_LIGHT = "#14181F";
|
||||
|
||||
export const HIGHLIGHT_BG_DARK = "#92400E";
|
||||
export const HIGHLIGHT_FG_DARK = "#F2F4F7";
|
||||
Reference in New Issue
Block a user