Compare commits
13 Commits
ae868f4110
...
bc58d77f2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc58d77f2c | ||
|
|
515fa03088 | ||
|
|
060a1149e0 | ||
|
|
558e1e6b22 | ||
|
|
6dd60571e3 | ||
|
|
3365f5845e | ||
|
|
3faac13533 | ||
|
|
5890bb3abd | ||
|
|
060db69108 | ||
|
|
1842e23c81 | ||
|
|
26519d029a | ||
|
|
488d4384a1 | ||
|
|
6a6967d841 |
@@ -11,6 +11,7 @@ bun.lockb
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
/.svelte-kit/
|
/.svelte-kit/
|
||||||
/.svelte-kit-backup/
|
/.svelte-kit-backup/
|
||||||
|
/.svelte-kit.old/
|
||||||
|
|
||||||
# Generated files
|
# Generated files
|
||||||
/.svelte-kit-backup/
|
/.svelte-kit-backup/
|
||||||
|
|||||||
@@ -44,8 +44,14 @@ test.describe('Person mention — read mode', () => {
|
|||||||
personId = person.id;
|
personId = person.id;
|
||||||
|
|
||||||
// 2. Document with a PDF so the transcription panel is mountable.
|
// 2. Document with a PDF so the transcription panel is mountable.
|
||||||
|
// Sara #3: timestamp the title so a previous run that crashed in beforeAll
|
||||||
|
// (and therefore skipped afterAll cleanup) cannot collide with this one.
|
||||||
|
const uniqueSuffix = Date.now();
|
||||||
const docRes = await request.post('/api/documents', {
|
const docRes = await request.post('/api/documents', {
|
||||||
multipart: { title: 'E2E Person Mention Read', documentDate: '1945-05-08' }
|
multipart: {
|
||||||
|
title: `E2E Person Mention Read ${uniqueSuffix}`,
|
||||||
|
documentDate: '1945-05-08'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
||||||
const doc = await docRes.json();
|
const doc = await docRes.json();
|
||||||
@@ -54,7 +60,7 @@ test.describe('Person mention — read mode', () => {
|
|||||||
|
|
||||||
await request.put(`/api/documents/${docId}`, {
|
await request.put(`/api/documents/${docId}`, {
|
||||||
multipart: {
|
multipart: {
|
||||||
title: doc.title,
|
title: doc.title as string,
|
||||||
documentDate: '1945-05-08',
|
documentDate: '1945-05-08',
|
||||||
file: {
|
file: {
|
||||||
name: 'minimal.pdf',
|
name: 'minimal.pdf',
|
||||||
@@ -143,10 +149,13 @@ test.describe('Person mention — read mode', () => {
|
|||||||
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||||
await expect(link).toBeVisible({ timeout: 5000 });
|
await expect(link).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
await link.tap();
|
// Sara #2: assert no card *before* the tap so the test actually proves
|
||||||
// The card never mounted — the tap navigated directly per spec
|
// the touch device suppression worked, not just that we navigated away.
|
||||||
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
|
|
||||||
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
|
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
|
||||||
|
|
||||||
|
await link.tap();
|
||||||
|
// The card never mounted — the tap navigated directly per spec.
|
||||||
|
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
|
||||||
} finally {
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
|||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
includeIgnoreFile(gitignorePath),
|
includeIgnoreFile(gitignorePath),
|
||||||
{ ignores: ['src/paraglide/**'] },
|
{ ignores: ['src/paraglide/**', '.svelte-kit.old/**'] },
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...ts.configs.recommended,
|
...ts.configs.recommended,
|
||||||
...svelte.configs.recommended,
|
...svelte.configs.recommended,
|
||||||
|
|||||||
@@ -423,6 +423,7 @@
|
|||||||
"person_mention_open_link": "Zur Person",
|
"person_mention_open_link": "Zur Person",
|
||||||
"person_mention_hover_hint": "Klick öffnet Seite",
|
"person_mention_hover_hint": "Klick öffnet Seite",
|
||||||
"person_mention_load_error": "Person konnte nicht geladen werden.",
|
"person_mention_load_error": "Person konnte nicht geladen werden.",
|
||||||
|
"person_mention_loading": "Lade Person…",
|
||||||
"person_mention_popup_empty": "Keine Personen gefunden",
|
"person_mention_popup_empty": "Keine Personen gefunden",
|
||||||
"person_mention_btn_label": "Person verlinken",
|
"person_mention_btn_label": "Person verlinken",
|
||||||
"person_mention_create_new": "Neue Person anlegen",
|
"person_mention_create_new": "Neue Person anlegen",
|
||||||
|
|||||||
@@ -423,6 +423,7 @@
|
|||||||
"person_mention_open_link": "Open person",
|
"person_mention_open_link": "Open person",
|
||||||
"person_mention_hover_hint": "Click opens the page",
|
"person_mention_hover_hint": "Click opens the page",
|
||||||
"person_mention_load_error": "Could not load person.",
|
"person_mention_load_error": "Could not load person.",
|
||||||
|
"person_mention_loading": "Loading person…",
|
||||||
"person_mention_popup_empty": "No persons found",
|
"person_mention_popup_empty": "No persons found",
|
||||||
"person_mention_btn_label": "Link person",
|
"person_mention_btn_label": "Link person",
|
||||||
"person_mention_create_new": "Create new person",
|
"person_mention_create_new": "Create new person",
|
||||||
|
|||||||
@@ -423,6 +423,7 @@
|
|||||||
"person_mention_open_link": "Ir a la persona",
|
"person_mention_open_link": "Ir a la persona",
|
||||||
"person_mention_hover_hint": "Clic abre la página",
|
"person_mention_hover_hint": "Clic abre la página",
|
||||||
"person_mention_load_error": "No se pudo cargar la persona.",
|
"person_mention_load_error": "No se pudo cargar la persona.",
|
||||||
|
"person_mention_loading": "Cargando persona…",
|
||||||
"person_mention_popup_empty": "No se encontraron personas",
|
"person_mention_popup_empty": "No se encontraron personas",
|
||||||
"person_mention_btn_label": "Vincular persona",
|
"person_mention_btn_label": "Vincular persona",
|
||||||
"person_mention_create_new": "Crear nueva persona",
|
"person_mention_create_new": "Crear nueva persona",
|
||||||
|
|||||||
@@ -2,15 +2,10 @@
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
import type { LoadState } from '$lib/types/personHoverCard';
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
export type LoadState =
|
|
||||||
| { status: 'loading' }
|
|
||||||
| { status: 'error' }
|
|
||||||
| { status: 'loaded'; person: Person; relationships: RelationshipDTO[] };
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
personId: string;
|
personId: string;
|
||||||
cardId: string;
|
cardId: string;
|
||||||
@@ -39,13 +34,41 @@ const dateRange = $derived(
|
|||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut the notes excerpt at the last word boundary inside the NOTES_MAX
|
||||||
|
* window. Mid-word truncation is especially ugly in German compound nouns
|
||||||
|
* ("…Familienzu…"), so prefer the previous space if there is one within
|
||||||
|
* a reasonable distance. Fall back to a hard cut for strings with no
|
||||||
|
* spaces at all (e.g. a single 150-char word). Leonie FINDING-04 / Elicit E5.
|
||||||
|
*/
|
||||||
|
function truncateAtWordBoundary(text: string, max: number): string {
|
||||||
|
if (text.length <= max) return text;
|
||||||
|
const window = text.slice(0, max);
|
||||||
|
const lastSpace = window.lastIndexOf(' ');
|
||||||
|
// If the last space is too close to the start (< 70% of the window) we'd
|
||||||
|
// produce a near-empty excerpt — fall back to the hard cut instead.
|
||||||
|
const minBoundary = Math.floor(max * 0.7);
|
||||||
|
const cut = lastSpace >= minBoundary ? window.slice(0, lastSpace) : window;
|
||||||
|
return cut + '…';
|
||||||
|
}
|
||||||
|
|
||||||
const notesExcerpt = $derived.by(() => {
|
const notesExcerpt = $derived.by(() => {
|
||||||
if (state.status !== 'loaded') return null;
|
if (state.status !== 'loaded') return null;
|
||||||
const notes = state.person.notes;
|
const notes = state.person.notes;
|
||||||
if (!notes) return null;
|
if (!notes) return null;
|
||||||
if (notes.length <= NOTES_MAX) return notes;
|
return truncateAtWordBoundary(notes, NOTES_MAX);
|
||||||
return notes.slice(0, NOTES_MAX) + '…';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Accessible name for the region landmark — required by WCAG 1.3.1.
|
||||||
|
// Falls back to a localised loading label so axe-core never sees an unnamed
|
||||||
|
// region (Leonie FINDING-02 / Elicit NFR concern).
|
||||||
|
const ariaLabel = $derived(
|
||||||
|
state.status === 'loaded' ? state.person.displayName : m.person_mention_loading()
|
||||||
|
);
|
||||||
|
|
||||||
|
// aria-busy="true" while loading so SR clients know the region's contents
|
||||||
|
// will change. Cleared on loaded/error so the new content is announced.
|
||||||
|
const ariaBusy = $derived(state.status === 'loading');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -54,12 +77,19 @@ const notesExcerpt = $derived.by(() => {
|
|||||||
id={cardId}
|
id={cardId}
|
||||||
role="region"
|
role="region"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-busy={ariaBusy ? 'true' : undefined}
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:top={`${position.top}px`}
|
style:top={`${position.top}px`}
|
||||||
style:left={`${position.left}px`}
|
style:left={`${position.left}px`}
|
||||||
>
|
>
|
||||||
{#if state.status === 'loading'}
|
{#if state.status === 'loading'}
|
||||||
<div data-testid="person-hover-card-skeleton" class="skeleton">
|
<div
|
||||||
|
data-testid="person-hover-card-skeleton"
|
||||||
|
class="skeleton"
|
||||||
|
role="status"
|
||||||
|
aria-label={m.person_mention_loading()}
|
||||||
|
>
|
||||||
<div class="bar"></div>
|
<div class="bar"></div>
|
||||||
<div class="bar"></div>
|
<div class="bar"></div>
|
||||||
<div class="bar"></div>
|
<div class="bar"></div>
|
||||||
|
|||||||
@@ -197,7 +197,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('truncates notes longer than 120 characters with an ellipsis', async () => {
|
it('truncates notes longer than 120 characters with an ellipsis (single long word)', async () => {
|
||||||
|
// Single 150-char word with no spaces: word-boundary cut would yield nothing,
|
||||||
|
// so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length).
|
||||||
const long = 'x'.repeat(150);
|
const long = 'x'.repeat(150);
|
||||||
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
@@ -207,8 +209,34 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||||
});
|
});
|
||||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||||
expect(notes.textContent!.length).toBeLessThanOrEqual(122);
|
expect(notes.textContent).toBe('x'.repeat(120) + '…');
|
||||||
expect(notes.textContent).toContain('…');
|
});
|
||||||
|
|
||||||
|
it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => {
|
||||||
|
// 150-char string with spaces — must cut at the last space, not mid-word.
|
||||||
|
const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3);
|
||||||
|
// length is 180, last space at idx ≤120
|
||||||
|
const withLongNotes = { ...AUGUSTE, notes: sentence } as Person;
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||||
|
});
|
||||||
|
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||||
|
const text = notes.textContent ?? '';
|
||||||
|
// Ends with ellipsis
|
||||||
|
expect(text.endsWith('…')).toBe(true);
|
||||||
|
// Last char before the ellipsis is NOT a half-word — verify by checking that
|
||||||
|
// the position right before … is the end of a word (i.e., there's no letter
|
||||||
|
// further along in the original text immediately after our cut point).
|
||||||
|
const cut = text.slice(0, -1); // strip the …
|
||||||
|
// Find this cut substring in the original sentence
|
||||||
|
const idx = sentence.indexOf(cut);
|
||||||
|
expect(idx).toBe(0);
|
||||||
|
const charAfterCut = sentence[cut.length];
|
||||||
|
// The next char should be a space — confirming we cut on a boundary
|
||||||
|
expect(charAfterCut).toBe(' ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits notes section when notes is null', async () => {
|
it('omits notes section when notes is null', async () => {
|
||||||
@@ -246,6 +274,54 @@ describe('PersonHoverCard — accessibility', () => {
|
|||||||
expect(root.getAttribute('aria-live')).toBe('polite');
|
expect(root.getAttribute('aria-live')).toBe('polite');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sets aria-busy="true" while loading so SR announces the state change on load', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loading' }
|
||||||
|
});
|
||||||
|
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||||
|
expect(root.getAttribute('aria-busy')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set aria-busy when loaded (so the loaded content is announced)', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||||
|
});
|
||||||
|
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||||
|
// aria-busy is either absent or "false"
|
||||||
|
const busy = root.getAttribute('aria-busy');
|
||||||
|
expect(busy === null || busy === 'false').toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the region with the person displayName when loaded (WCAG 1.3.1)', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||||
|
});
|
||||||
|
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||||
|
expect(root.getAttribute('aria-label')).toBe('Auguste Raddatz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the region with a generic loading label while loading', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loading' }
|
||||||
|
});
|
||||||
|
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||||
|
// Region must have an accessible name in every state — axe-core flags
|
||||||
|
// role="region" without aria-label / aria-labelledby.
|
||||||
|
expect(root.getAttribute('aria-label')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
|
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
personId: 'p-aug',
|
personId: 'p-aug',
|
||||||
|
|||||||
@@ -2,16 +2,20 @@
|
|||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
||||||
import { renderTranscriptionBody } from '$lib/utils/mention';
|
import {
|
||||||
import PersonHoverCard, { type LoadState } from './PersonHoverCard.svelte';
|
renderTranscriptionBody,
|
||||||
|
type SafeHtml,
|
||||||
|
PERSON_MENTION_SELECTOR
|
||||||
|
} from '$lib/utils/mention';
|
||||||
|
import { computeHoverCardPosition } from '$lib/utils/hoverCardPosition';
|
||||||
|
import PersonHoverCard from './PersonHoverCard.svelte';
|
||||||
|
import type { HoverData, LoadState } from '$lib/types/personHoverCard';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
type HoverData = { person: Person; relationships: RelationshipDTO[] };
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
blocks: TranscriptionBlockData[];
|
blocks: TranscriptionBlockData[];
|
||||||
onParagraphClick: (annotationId: string) => void;
|
onParagraphClick: (annotationId: string) => void;
|
||||||
@@ -22,9 +26,14 @@ let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
|||||||
|
|
||||||
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||||
|
|
||||||
// Per-page in-memory cache: a sweep across 20 mentions of the same person
|
// Per-component (per-mount) in-memory cache: a sweep across 20 mentions of the
|
||||||
// must not fire 20 backend calls (B15.5). The Promise<HoverData | null> shape
|
// same person must not fire 20 backend calls (B15.5). The Promise<HoverData | null>
|
||||||
// lets simultaneous hovers share the same in-flight fetch.
|
// shape lets simultaneous hovers share the same in-flight fetch.
|
||||||
|
//
|
||||||
|
// Trade-off: closing and re-opening the transcription panel rebuilds this cache
|
||||||
|
// (Elicit OQ-372-02). That's intentional — staleness from another tab deleting
|
||||||
|
// a person is rare in this read-only view, and a per-document/global cache would
|
||||||
|
// complicate invalidation. If user reports on stale cards accumulate, revisit.
|
||||||
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
|
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
|
||||||
const deletedPersonIds = new SvelteSet<string>();
|
const deletedPersonIds = new SvelteSet<string>();
|
||||||
|
|
||||||
@@ -35,30 +44,30 @@ let activeCard: {
|
|||||||
position: { top: number; left: number };
|
position: { top: number; left: number };
|
||||||
} | null = $state(null);
|
} | null = $state(null);
|
||||||
|
|
||||||
const CARD_WIDTH = 320;
|
|
||||||
const CARD_HEIGHT = 180;
|
|
||||||
const CARD_GAP = 6;
|
|
||||||
|
|
||||||
// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
|
// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
|
||||||
// as <em data-marker> tags; text segments run through HTML-escaping + mention
|
// as <em data-marker> tags; text segments run through HTML-escaping + mention
|
||||||
// substitution. The two are concatenated to preserve marker boundaries — markers
|
// substitution. The two are concatenated to preserve marker boundaries — markers
|
||||||
// never end up nested inside an anchor (Felix #5324 B19b).
|
// never end up nested inside an anchor (Felix #5324 B19b).
|
||||||
function renderBlockHtml(block: TranscriptionBlockData): string {
|
function renderBlockHtml(block: TranscriptionBlockData): SafeHtml {
|
||||||
return splitByMarkers(block.text)
|
return splitByMarkers(block.text)
|
||||||
.map((segment) => {
|
.map((segment) => {
|
||||||
if (segment.type === 'marker') {
|
if (segment.type === 'marker') {
|
||||||
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>`;
|
// splitByMarkers only emits the literal markers [unleserlich] and [...],
|
||||||
|
// no user input — safe to embed directly. Wrap in SafeHtml to satisfy
|
||||||
|
// the brand contract.
|
||||||
|
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>` as SafeHtml;
|
||||||
}
|
}
|
||||||
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
|
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
|
||||||
})
|
})
|
||||||
.join('');
|
.join('') as SafeHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchHoverData(personId: string): Promise<HoverData | null> {
|
/**
|
||||||
let cached = hoverCache.get(personId);
|
* Fetches person + relationships from the backend. 404 returns null
|
||||||
if (cached) return cached;
|
* (deleted person — caller marks the link as tombstoned). Any other
|
||||||
|
* non-OK response throws so the caller can render the error state.
|
||||||
cached = (async () => {
|
*/
|
||||||
|
async function loadHoverData(personId: string): Promise<HoverData | null> {
|
||||||
const personRes = await fetch(`/api/persons/${personId}`);
|
const personRes = await fetch(`/api/persons/${personId}`);
|
||||||
if (personRes.status === 404) return null;
|
if (personRes.status === 404) return null;
|
||||||
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
|
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
|
||||||
@@ -69,33 +78,24 @@ function fetchHoverData(personId: string): Promise<HoverData | null> {
|
|||||||
? ((await relRes.json()) as RelationshipDTO[])
|
? ((await relRes.json()) as RelationshipDTO[])
|
||||||
: [];
|
: [];
|
||||||
return { person, relationships };
|
return { person, relationships };
|
||||||
})();
|
|
||||||
|
|
||||||
hoverCache.set(personId, cached);
|
|
||||||
return cached;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeCardPosition(rect: DOMRect): { top: number; left: number } {
|
/** Cache wrapper around `loadHoverData` — first hover fires the fetch, all
|
||||||
const vw = window.innerWidth;
|
* subsequent hovers (and concurrent in-flight ones) share the same Promise. */
|
||||||
const vh = window.innerHeight;
|
function getOrFetchHoverData(personId: string): Promise<HoverData | null> {
|
||||||
|
const cached = hoverCache.get(personId);
|
||||||
let top = rect.bottom + CARD_GAP;
|
if (cached) return cached;
|
||||||
let left = rect.left;
|
const promise = loadHoverData(personId);
|
||||||
|
hoverCache.set(personId, promise);
|
||||||
// Flip up if the card would overflow the bottom edge OR the mention sits in
|
return promise;
|
||||||
// the bottom 30% of the viewport (Leonie #5329).
|
}
|
||||||
if (vh - rect.bottom < CARD_HEIGHT + CARD_GAP || rect.top > vh * 0.7) {
|
|
||||||
top = rect.top - CARD_HEIGHT - CARD_GAP;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flip left if <300px from the right edge.
|
|
||||||
if (vw - rect.left < 300) {
|
|
||||||
left = rect.right - CARD_WIDTH;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function currentViewport() {
|
||||||
return {
|
return {
|
||||||
top: Math.max(0, top + window.scrollY),
|
viewportWidth: window.innerWidth,
|
||||||
left: Math.max(0, left + window.scrollX)
|
viewportHeight: window.innerHeight,
|
||||||
|
scrollX: window.scrollX,
|
||||||
|
scrollY: window.scrollY
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +109,12 @@ async function handleMentionEnter(event: Event) {
|
|||||||
link.setAttribute('aria-describedby', cardId);
|
link.setAttribute('aria-describedby', cardId);
|
||||||
|
|
||||||
const rect = link.getBoundingClientRect();
|
const rect = link.getBoundingClientRect();
|
||||||
const position = computeCardPosition(rect);
|
const position = computeHoverCardPosition(rect, currentViewport());
|
||||||
|
|
||||||
activeCard = { personId, cardId, position, state: { status: 'loading' } };
|
activeCard = { personId, cardId, position, state: { status: 'loading' } };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchHoverData(personId);
|
const data = await getOrFetchHoverData(personId);
|
||||||
// Bail if a different mention is now active
|
// Bail if a different mention is now active
|
||||||
if (!activeCard || activeCard.personId !== personId) return;
|
if (!activeCard || activeCard.personId !== personId) return;
|
||||||
|
|
||||||
@@ -143,7 +143,18 @@ function handleMentionLeave(event: Event) {
|
|||||||
activeCard = null;
|
activeCard = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modified clicks (ctrl/meta/shift/alt) and middle-clicks must fall through to
|
||||||
|
* the browser's default anchor behaviour so users can open the person page in
|
||||||
|
* a new tab/window. Felix #7. Only the plain primary-button click navigates
|
||||||
|
* via SPA goto().
|
||||||
|
*/
|
||||||
|
function isPlainPrimaryClick(event: MouseEvent): boolean {
|
||||||
|
return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleMentionClick(event: MouseEvent) {
|
async function handleMentionClick(event: MouseEvent) {
|
||||||
|
if (!isPlainPrimaryClick(event)) return;
|
||||||
const link = event.target as HTMLAnchorElement;
|
const link = event.target as HTMLAnchorElement;
|
||||||
const personId = link.dataset.personId;
|
const personId = link.dataset.personId;
|
||||||
if (!personId) return;
|
if (!personId) return;
|
||||||
@@ -158,28 +169,37 @@ async function handleMentionClick(event: MouseEvent) {
|
|||||||
// Attach delegated event listeners on each rendered block. Using {@html ...}
|
// Attach delegated event listeners on each rendered block. Using {@html ...}
|
||||||
// for the body means we cannot bind events declaratively to the injected
|
// for the body means we cannot bind events declaratively to the injected
|
||||||
// anchors, so we hook up listeners via a Svelte action when the wrapper mounts.
|
// anchors, so we hook up listeners via a Svelte action when the wrapper mounts.
|
||||||
|
//
|
||||||
|
// Keyboard parity (Leonie FINDING-01, WCAG 2.1.1): focusin/focusout mirror
|
||||||
|
// mouseenter/mouseleave so users tabbing through transcribed text get the
|
||||||
|
// same preview affordance.
|
||||||
function attachMentionHandlers(node: HTMLElement) {
|
function attachMentionHandlers(node: HTMLElement) {
|
||||||
function onEnter(e: Event) {
|
function onEnter(e: Event) {
|
||||||
const t = e.target as HTMLElement;
|
const t = e.target as HTMLElement;
|
||||||
if (t.matches?.('a.person-mention')) handleMentionEnter(e);
|
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionEnter(e);
|
||||||
}
|
}
|
||||||
function onLeave(e: Event) {
|
function onLeave(e: Event) {
|
||||||
const t = e.target as HTMLElement;
|
const t = e.target as HTMLElement;
|
||||||
if (t.matches?.('a.person-mention')) handleMentionLeave(e);
|
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionLeave(e);
|
||||||
}
|
}
|
||||||
function onClick(e: MouseEvent) {
|
function onClick(e: MouseEvent) {
|
||||||
const t = e.target as HTMLElement;
|
const t = e.target as HTMLElement;
|
||||||
if (t.matches?.('a.person-mention')) handleMentionClick(e);
|
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionClick(e);
|
||||||
}
|
}
|
||||||
// mouseenter does not bubble — capture it.
|
// mouseenter does not bubble — capture it.
|
||||||
node.addEventListener('mouseenter', onEnter, true);
|
node.addEventListener('mouseenter', onEnter, true);
|
||||||
node.addEventListener('mouseleave', onLeave, true);
|
node.addEventListener('mouseleave', onLeave, true);
|
||||||
|
// focusin/focusout do bubble — no capture phase needed.
|
||||||
|
node.addEventListener('focusin', onEnter);
|
||||||
|
node.addEventListener('focusout', onLeave);
|
||||||
node.addEventListener('click', onClick);
|
node.addEventListener('click', onClick);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener('mouseenter', onEnter, true);
|
node.removeEventListener('mouseenter', onEnter, true);
|
||||||
node.removeEventListener('mouseleave', onLeave, true);
|
node.removeEventListener('mouseleave', onLeave, true);
|
||||||
|
node.removeEventListener('focusin', onEnter);
|
||||||
|
node.removeEventListener('focusout', onLeave);
|
||||||
node.removeEventListener('click', onClick);
|
node.removeEventListener('click', onClick);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -280,13 +280,14 @@ describe('TranscriptionReadView — person-mention rendering', () => {
|
|||||||
|
|
||||||
const link = document.querySelector('a.person-mention')!;
|
const link = document.querySelector('a.person-mention')!;
|
||||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
const personFetches = fetchMock.mock.calls.filter((c) =>
|
const personFetches = fetchMock.mock.calls.filter((c) =>
|
||||||
String(c[0]).includes(`/api/persons/${PERSON_ID}`)
|
String(c[0]).includes(`/api/persons/${PERSON_ID}`)
|
||||||
);
|
);
|
||||||
expect(personFetches.length).toBeGreaterThanOrEqual(1);
|
expect(personFetches.length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => {
|
it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
@@ -308,13 +309,13 @@ describe('TranscriptionReadView — person-mention rendering', () => {
|
|||||||
// Plus a re-hover on the first
|
// Plus a re-hover on the first
|
||||||
links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await vi.waitFor(() => {
|
||||||
|
|
||||||
const personFetches = fetchMock.mock.calls.filter(
|
const personFetches = fetchMock.mock.calls.filter(
|
||||||
(c) => String(c[0]) === `/api/persons/${PERSON_ID}`
|
(c) => String(c[0]) === `/api/persons/${PERSON_ID}`
|
||||||
);
|
);
|
||||||
expect(personFetches.length).toBe(1);
|
expect(personFetches.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('mounts the hover card on mouseenter when the fetch loads', async () => {
|
it('mounts the hover card on mouseenter when the fetch loads', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@@ -349,10 +350,11 @@ describe('TranscriptionReadView — person-mention rendering', () => {
|
|||||||
const link = document.querySelector('a.person-mention')!;
|
const link = document.querySelector('a.person-mention')!;
|
||||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await vi.waitFor(() => {
|
||||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||||
expect(card).not.toBeNull();
|
expect(card).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('unmounts the hover card on mouseleave', async () => {
|
it('unmounts the hover card on mouseleave', async () => {
|
||||||
render(TranscriptionReadView, {
|
render(TranscriptionReadView, {
|
||||||
@@ -362,13 +364,101 @@ describe('TranscriptionReadView — person-mention rendering', () => {
|
|||||||
|
|
||||||
const link = document.querySelector('a.person-mention')!;
|
const link = document.querySelector('a.person-mention')!;
|
||||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||||
await new Promise((r) => setTimeout(r, 5));
|
|
||||||
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||||
await new Promise((r) => setTimeout(r, 5));
|
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||||
expect(card).toBeNull();
|
expect(card).toBeNull();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mounts the hover card on focusin so keyboard users see the preview (WCAG 2.1.1)', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (String(url).endsWith('/relationships')) {
|
||||||
|
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: PERSON_ID,
|
||||||
|
firstName: 'Auguste',
|
||||||
|
lastName: 'Raddatz',
|
||||||
|
displayName: 'Auguste Raddatz',
|
||||||
|
personType: 'PERSON',
|
||||||
|
familyMember: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')!;
|
||||||
|
link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unmounts the hover card on focusout', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')!;
|
||||||
|
link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
// the card mounts even in 404 → loading → null path; assert it cleans up on blur
|
||||||
|
});
|
||||||
|
link.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||||
|
expect(card).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets ctrl-click and meta-click fall through so users can open in a new tab', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
|
||||||
|
|
||||||
|
// ctrl-click (Linux/Win "open in new tab")
|
||||||
|
const ctrlClick = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true });
|
||||||
|
const ctrlPrevented = !link.dispatchEvent(ctrlClick);
|
||||||
|
expect(ctrlPrevented).toBe(false);
|
||||||
|
|
||||||
|
// meta-click (macOS "open in new tab")
|
||||||
|
const metaClick = new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true });
|
||||||
|
const metaPrevented = !link.dispatchEvent(metaClick);
|
||||||
|
expect(metaPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets middle-click fall through so users can open in a background tab', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
|
||||||
|
// button === 1 is middle mouse button
|
||||||
|
const middleClick = new MouseEvent('click', { bubbles: true, cancelable: true, button: 1 });
|
||||||
|
const prevented = !link.dispatchEvent(middleClick);
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('degrades to plain unlinked text when the person fetch returns 404', async () => {
|
it('degrades to plain unlinked text when the person fetch returns 404', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
|
||||||
@@ -380,13 +470,15 @@ describe('TranscriptionReadView — person-mention rendering', () => {
|
|||||||
|
|
||||||
const link = document.querySelector('a.person-mention')!;
|
const link = document.querySelector('a.person-mention')!;
|
||||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
|
||||||
|
|
||||||
// 404 → no card mounted
|
await vi.waitFor(() => {
|
||||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
|
||||||
expect(card).toBeNull();
|
|
||||||
// Anchor is marked as deleted so subsequent hovers/clicks treat it as plain text
|
// Anchor is marked as deleted so subsequent hovers/clicks treat it as plain text
|
||||||
const stillLink = document.querySelector('a.person-mention')!;
|
const stillLink = document.querySelector('a.person-mention')!;
|
||||||
expect(stillLink.getAttribute('data-person-deleted')).toBe('true');
|
expect(stillLink.getAttribute('data-person-deleted')).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 404 → no card mounted
|
||||||
|
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||||
|
expect(card).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
23
frontend/src/lib/types/personHoverCard.ts
Normal file
23
frontend/src/lib/types/personHoverCard.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data the PersonHoverCard needs to render its loaded state.
|
||||||
|
* Bundled here so the orchestrator (TranscriptionReadView) and the view
|
||||||
|
* (PersonHoverCard) share one canonical shape.
|
||||||
|
*/
|
||||||
|
export type HoverData = { person: Person; relationships: RelationshipDTO[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hover card's three visible states.
|
||||||
|
*
|
||||||
|
* `loading` — initial fetch in flight; skeleton is shown
|
||||||
|
* `error` — fetch failed (non-404, non-OK); generic error message + footer link
|
||||||
|
* `loaded` — fetch succeeded; person + relationships available
|
||||||
|
*/
|
||||||
|
export type LoadState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'error' }
|
||||||
|
| { status: 'loaded'; person: Person; relationships: RelationshipDTO[] };
|
||||||
152
frontend/src/lib/utils/hoverCardPosition.spec.ts
Normal file
152
frontend/src/lib/utils/hoverCardPosition.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
computeHoverCardPosition,
|
||||||
|
CARD_WIDTH_PX,
|
||||||
|
CARD_HEIGHT_PX,
|
||||||
|
CARD_GAP_PX,
|
||||||
|
BOTTOM_BAND_RATIO,
|
||||||
|
RIGHT_FLIP_THRESHOLD_PX
|
||||||
|
} from './hoverCardPosition';
|
||||||
|
|
||||||
|
const makeRect = (overrides: Partial<DOMRect> = {}): DOMRect => {
|
||||||
|
const base = { top: 100, left: 200, bottom: 120, right: 300, width: 100, height: 20 };
|
||||||
|
const merged = { ...base, ...overrides };
|
||||||
|
return {
|
||||||
|
...merged,
|
||||||
|
x: merged.left,
|
||||||
|
y: merged.top,
|
||||||
|
toJSON: () => merged
|
||||||
|
} as DOMRect;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('computeHoverCardPosition', () => {
|
||||||
|
it('exports the spec constants used by the spec/CSS layer', () => {
|
||||||
|
// Pin the values the design spec calls out — if these drift, the design spec
|
||||||
|
// in #5329 needs to drift with them. Felix's PR review #2 (named constants).
|
||||||
|
expect(CARD_WIDTH_PX).toBe(320);
|
||||||
|
expect(CARD_HEIGHT_PX).toBe(180);
|
||||||
|
expect(CARD_GAP_PX).toBe(6);
|
||||||
|
expect(BOTTOM_BAND_RATIO).toBe(0.7);
|
||||||
|
expect(RIGHT_FLIP_THRESHOLD_PX).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('default placement (below-right)', () => {
|
||||||
|
it('positions the card below the rect with a small gap', () => {
|
||||||
|
const rect = makeRect({ top: 100, bottom: 120, left: 200 });
|
||||||
|
const result = computeHoverCardPosition(rect, {
|
||||||
|
viewportWidth: 1440,
|
||||||
|
viewportHeight: 900,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0
|
||||||
|
});
|
||||||
|
expect(result.top).toBe(120 + CARD_GAP_PX);
|
||||||
|
expect(result.left).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flip-up rule (Leonie #5329)', () => {
|
||||||
|
it('flips up when the card would overflow the bottom edge', () => {
|
||||||
|
// Mention sits 50px above the viewport bottom — card is 180px tall, can't fit below
|
||||||
|
const rect = makeRect({ top: 800, bottom: 850 });
|
||||||
|
const result = computeHoverCardPosition(rect, {
|
||||||
|
viewportWidth: 1440,
|
||||||
|
viewportHeight: 900,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0
|
||||||
|
});
|
||||||
|
expect(result.top).toBe(800 - CARD_HEIGHT_PX - CARD_GAP_PX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips up when the mention sits in the bottom 30% of the viewport (BOTTOM_BAND_RATIO)', () => {
|
||||||
|
// rect.top is at 80% of viewport — fits below numerically, but poor UX
|
||||||
|
const rect = makeRect({ top: 720, bottom: 740 });
|
||||||
|
const result = computeHoverCardPosition(rect, {
|
||||||
|
viewportWidth: 1440,
|
||||||
|
viewportHeight: 900,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0
|
||||||
|
});
|
||||||
|
expect(result.top).toBe(720 - CARD_HEIGHT_PX - CARD_GAP_PX);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flip-left rule', () => {
|
||||||
|
it('flips left when the rect is within RIGHT_FLIP_THRESHOLD_PX of the right edge', () => {
|
||||||
|
// vw - rect.left = 1440 - 1200 = 240 < 300, so flip
|
||||||
|
const rect = makeRect({ left: 1200, right: 1300, top: 100, bottom: 120 });
|
||||||
|
const result = computeHoverCardPosition(rect, {
|
||||||
|
viewportWidth: 1440,
|
||||||
|
viewportHeight: 900,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0
|
||||||
|
});
|
||||||
|
// left = right - CARD_WIDTH = 1300 - 320 = 980
|
||||||
|
expect(result.left).toBe(980);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flip left when the rect has plenty of right-side room', () => {
|
||||||
|
// vw - rect.left = 1440 - 200 = 1240 >> 300 → no flip
|
||||||
|
const rect = makeRect({ left: 200, right: 300 });
|
||||||
|
const result = computeHoverCardPosition(rect, {
|
||||||
|
viewportWidth: 1440,
|
||||||
|
viewportHeight: 900,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0
|
||||||
|
});
|
||||||
|
expect(result.left).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewport clamping (Leonie FINDING-05)', () => {
|
||||||
|
it('clamps left so the card never overflows the right edge', () => {
|
||||||
|
// On a 320px viewport, even with flip the card width equals the viewport.
|
||||||
|
// Without clamping the card would be at left=0 but extend to 320 — fine.
|
||||||
|
// At viewport=400px with rect.left=200, flip puts left=300-320=-20, clamped to 0.
|
||||||
|
const rect = makeRect({ left: 200, right: 300, top: 100, bottom: 120 });
|
||||||
|
const result = computeHoverCardPosition(rect, {
|
||||||
|
viewportWidth: 400,
|
||||||
|
viewportHeight: 900,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0
|
||||||
|
});
|
||||||
|
expect(result.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result.left + CARD_WIDTH_PX).toBeLessThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns a negative top or left', () => {
|
||||||
|
const rect = makeRect({ top: -50, left: -100, bottom: -30, right: 0 });
|
||||||
|
const result = computeHoverCardPosition(rect, {
|
||||||
|
viewportWidth: 1440,
|
||||||
|
viewportHeight: 900,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0
|
||||||
|
});
|
||||||
|
expect(result.top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result.left).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scroll offset', () => {
|
||||||
|
it('adds window.scrollY to the absolute-positioned top', () => {
|
||||||
|
const rect = makeRect({ top: 100, bottom: 120 });
|
||||||
|
const result = computeHoverCardPosition(rect, {
|
||||||
|
viewportWidth: 1440,
|
||||||
|
viewportHeight: 900,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 500
|
||||||
|
});
|
||||||
|
expect(result.top).toBe(120 + CARD_GAP_PX + 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds window.scrollX to the absolute-positioned left', () => {
|
||||||
|
const rect = makeRect({ top: 100, bottom: 120, left: 200, right: 300 });
|
||||||
|
const result = computeHoverCardPosition(rect, {
|
||||||
|
viewportWidth: 1440,
|
||||||
|
viewportHeight: 900,
|
||||||
|
scrollX: 50,
|
||||||
|
scrollY: 0
|
||||||
|
});
|
||||||
|
expect(result.left).toBe(200 + 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
69
frontend/src/lib/utils/hoverCardPosition.ts
Normal file
69
frontend/src/lib/utils/hoverCardPosition.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Pure positioning logic for the person-mention hover card.
|
||||||
|
*
|
||||||
|
* Pulled out of TranscriptionReadView so the four placement branches
|
||||||
|
* (default, flip-up, flip-left, both) plus the viewport clamp are unit-testable
|
||||||
|
* without DOM. Sara's PR-B2 review #6 (no test for computeCardPosition) and
|
||||||
|
* Leonie's FINDING-05 (320px overflow) both land here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Width of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
|
||||||
|
export const CARD_WIDTH_PX = 320;
|
||||||
|
|
||||||
|
/** Min-height of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
|
||||||
|
export const CARD_HEIGHT_PX = 180;
|
||||||
|
|
||||||
|
/** Gap between the mention rect and the card so they do not touch. */
|
||||||
|
export const CARD_GAP_PX = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mentions in the bottom 30% of the viewport flip the card up by default,
|
||||||
|
* even if it would numerically fit below — keeping the eye-line stable
|
||||||
|
* is more important than minimal travel (Leonie #5329).
|
||||||
|
*/
|
||||||
|
export const BOTTOM_BAND_RATIO = 0.7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mentions within this distance of the right viewport edge flip the card
|
||||||
|
* left so it stays fully visible.
|
||||||
|
*/
|
||||||
|
export const RIGHT_FLIP_THRESHOLD_PX = 300;
|
||||||
|
|
||||||
|
export type Viewport = {
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
scrollX: number;
|
||||||
|
scrollY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CardPosition = { top: number; left: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute absolute-positioned top/left for the hover card, given a rect for
|
||||||
|
* the mention anchor and the current viewport. Output is in document
|
||||||
|
* coordinates (already includes scroll offsets).
|
||||||
|
*/
|
||||||
|
export function computeHoverCardPosition(rect: DOMRect, vp: Viewport): CardPosition {
|
||||||
|
let top = rect.bottom + CARD_GAP_PX;
|
||||||
|
let left = rect.left;
|
||||||
|
|
||||||
|
const overflowsBottom = vp.viewportHeight - rect.bottom < CARD_HEIGHT_PX + CARD_GAP_PX;
|
||||||
|
const inBottomBand = rect.top > vp.viewportHeight * BOTTOM_BAND_RATIO;
|
||||||
|
if (overflowsBottom || inBottomBand) {
|
||||||
|
top = rect.top - CARD_HEIGHT_PX - CARD_GAP_PX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vp.viewportWidth - rect.left < RIGHT_FLIP_THRESHOLD_PX) {
|
||||||
|
left = rect.right - CARD_WIDTH_PX;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp left so the card never extends past the right viewport edge
|
||||||
|
// (FINDING-05: at 320px viewport the flip would otherwise produce a
|
||||||
|
// negative left or right-side overflow).
|
||||||
|
left = Math.min(left, vp.viewportWidth - CARD_WIDTH_PX - CARD_GAP_PX);
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: Math.max(0, top + vp.scrollY),
|
||||||
|
left: Math.max(0, left + vp.scrollX)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -232,9 +232,11 @@ describe('renderTranscriptionBody', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('processes longer displayNames first to avoid prefix shadowing', () => {
|
it('processes longer displayNames first to avoid prefix shadowing', () => {
|
||||||
const augusteShort: PersonMention = { personId: 'p-short', displayName: 'Auguste' };
|
const SHORT_ID = '11111111-1111-4111-8111-111111111111';
|
||||||
|
const LONG_ID = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const augusteShort: PersonMention = { personId: SHORT_ID, displayName: 'Auguste' };
|
||||||
const augusteLong: PersonMention = {
|
const augusteLong: PersonMention = {
|
||||||
personId: 'p-long',
|
personId: LONG_ID,
|
||||||
displayName: 'Auguste Raddatz'
|
displayName: 'Auguste Raddatz'
|
||||||
};
|
};
|
||||||
// Sidecar order is short-first; longer match must still win for the long text
|
// Sidecar order is short-first; longer match must still win for the long text
|
||||||
@@ -242,8 +244,8 @@ describe('renderTranscriptionBody', () => {
|
|||||||
augusteShort,
|
augusteShort,
|
||||||
augusteLong
|
augusteLong
|
||||||
]);
|
]);
|
||||||
expect(result).toContain('href="/persons/p-long"');
|
expect(result).toContain(`href="/persons/${LONG_ID}"`);
|
||||||
expect(result).toContain('href="/persons/p-short"');
|
expect(result).toContain(`href="/persons/${SHORT_ID}"`);
|
||||||
// The "Raddatz" suffix must not leak inside the short-name anchor
|
// The "Raddatz" suffix must not leak inside the short-name anchor
|
||||||
expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
|
expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
|
||||||
});
|
});
|
||||||
@@ -257,18 +259,20 @@ describe('renderTranscriptionBody', () => {
|
|||||||
|
|
||||||
it('first-sidecar-wins when two entries share the same displayName', () => {
|
it('first-sidecar-wins when two entries share the same displayName', () => {
|
||||||
// Two persons named "Hans" — first sidecar entry wins for all occurrences.
|
// Two persons named "Hans" — first sidecar entry wins for all occurrences.
|
||||||
const hansFirst: PersonMention = { personId: 'p-first', displayName: 'Hans' };
|
const FIRST_ID = '33333333-3333-4333-8333-333333333333';
|
||||||
const hansSecond: PersonMention = { personId: 'p-second', displayName: 'Hans' };
|
const SECOND_ID = '44444444-4444-4444-8444-444444444444';
|
||||||
|
const hansFirst: PersonMention = { personId: FIRST_ID, displayName: 'Hans' };
|
||||||
|
const hansSecond: PersonMention = { personId: SECOND_ID, displayName: 'Hans' };
|
||||||
const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
|
const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
|
||||||
expect(result).toContain('href="/persons/p-first"');
|
expect(result).toContain(`href="/persons/${FIRST_ID}"`);
|
||||||
expect(result).not.toContain('href="/persons/p-second"');
|
expect(result).not.toContain(`href="/persons/${SECOND_ID}"`);
|
||||||
const anchorCount = (result.match(/<a /g) ?? []).length;
|
const anchorCount = (result.match(/<a /g) ?? []).length;
|
||||||
expect(anchorCount).toBe(2);
|
expect(anchorCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('escapes HTML in displayName to prevent stored XSS', () => {
|
it('escapes HTML in displayName to prevent stored XSS', () => {
|
||||||
const xss: PersonMention = {
|
const xss: PersonMention = {
|
||||||
personId: 'p-xss',
|
personId: '55555555-5555-4555-8555-555555555555',
|
||||||
displayName: '<script>alert(1)</script>'
|
displayName: '<script>alert(1)</script>'
|
||||||
};
|
};
|
||||||
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
|
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
|
||||||
@@ -292,7 +296,7 @@ describe('renderTranscriptionBody', () => {
|
|||||||
|
|
||||||
it('escapes quotes in displayName so they cannot break the href attribute', () => {
|
it('escapes quotes in displayName so they cannot break the href attribute', () => {
|
||||||
const tricky: PersonMention = {
|
const tricky: PersonMention = {
|
||||||
personId: 'p-quote',
|
personId: '66666666-6666-4666-8666-666666666666',
|
||||||
displayName: 'O"Brien'
|
displayName: 'O"Brien'
|
||||||
};
|
};
|
||||||
const result = renderTranscriptionBody('@O"Brien', [tricky]);
|
const result = renderTranscriptionBody('@O"Brien', [tricky]);
|
||||||
@@ -307,4 +311,40 @@ describe('renderTranscriptionBody', () => {
|
|||||||
const result = renderTranscriptionBody('Plain old transcription text.', []);
|
const result = renderTranscriptionBody('Plain old transcription text.', []);
|
||||||
expect(result).toBe('Plain old transcription text.');
|
expect(result).toBe('Plain old transcription text.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips substitution when personId is not a UUID (defense in depth)', () => {
|
||||||
|
// Nora #5551: if personId ever flowed in from a less-sanitised source
|
||||||
|
// (a future "external person" or a bad sidecar), the renderer must not
|
||||||
|
// emit a clickable link. The escaped text remains as plain content.
|
||||||
|
const evil: PersonMention = {
|
||||||
|
personId: 'javascript:alert(1)',
|
||||||
|
displayName: 'Evil Link'
|
||||||
|
};
|
||||||
|
const result = renderTranscriptionBody('Hi @Evil Link!', [evil]);
|
||||||
|
expect(result).not.toContain('<a ');
|
||||||
|
expect(result).not.toContain('javascript:');
|
||||||
|
// The @-trigger and displayName are preserved as plain text
|
||||||
|
expect(result).toContain('@Evil Link');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips substitution when personId is an absolute URL', () => {
|
||||||
|
const evil: PersonMention = {
|
||||||
|
personId: 'https://evil.example/persons/abc',
|
||||||
|
displayName: 'Phisher'
|
||||||
|
};
|
||||||
|
const result = renderTranscriptionBody('Hi @Phisher', [evil]);
|
||||||
|
expect(result).not.toContain('<a ');
|
||||||
|
expect(result).not.toContain('https://evil.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still substitutes when personId is a well-formed UUID', () => {
|
||||||
|
// Sanity check that the validation does not over-reject valid IDs.
|
||||||
|
const valid: PersonMention = {
|
||||||
|
personId: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
displayName: 'Auguste Raddatz'
|
||||||
|
};
|
||||||
|
const result = renderTranscriptionBody('Brief an @Auguste Raddatz', [valid]);
|
||||||
|
expect(result).toContain('<a ');
|
||||||
|
expect(result).toContain('href="/persons/550e8400-e29b-41d4-a716-446655440000"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
import type { MentionDTO, PersonMention } from '$lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-source CSS selector for rendered person-mention anchors. Used by:
|
||||||
|
* - layout.css (.person-mention rule, focus ring, underline)
|
||||||
|
* - TranscriptionReadView (delegated mouseenter/leave/click handlers)
|
||||||
|
* - unit + e2e tests
|
||||||
|
*
|
||||||
|
* Keep these in sync — the renderer template below emits exactly this class.
|
||||||
|
*/
|
||||||
|
export const PERSON_MENTION_SELECTOR = 'a.person-mention';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branded string type for HTML that has been pre-escaped and assembled by
|
||||||
|
* one of the trusted renderers in this module. The brand exists so that
|
||||||
|
* `{@html …}` consumers can require a SafeHtml input at compile time —
|
||||||
|
* `{@html block.text}` won't typecheck unless the string came through
|
||||||
|
* a renderer that escapes its inputs.
|
||||||
|
*
|
||||||
|
* Defense in depth against stored XSS (Sina #5505 / Nora PR-B2 review).
|
||||||
|
*/
|
||||||
|
export type SafeHtml = string & { readonly __brand: 'SafeHtml' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given the current textarea value and cursor position, returns the
|
* Given the current textarea value and cursor position, returns the
|
||||||
* @-mention query being typed (the text after the last triggering @),
|
* @-mention query being typed (the text after the last triggering @),
|
||||||
@@ -66,6 +87,18 @@ function escapeRegExp(str: string): string {
|
|||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict UUID v1–v5 check. Used as a defensive boundary on PersonMention.personId
|
||||||
|
* before substituting it into an `href` — even though the backend currently only
|
||||||
|
* emits UUIDs, a future "external person" feature must not accidentally turn this
|
||||||
|
* helper into an open-redirect surface (CWE-601).
|
||||||
|
*/
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function isUuid(value: string): boolean {
|
||||||
|
return UUID_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a transcription block's text segment as safe HTML for read mode.
|
* Renders a transcription block's text segment as safe HTML for read mode.
|
||||||
*
|
*
|
||||||
@@ -81,14 +114,18 @@ function escapeRegExp(str: string): string {
|
|||||||
* 5. First-sidecar-wins for entries that share a displayName (deterministic
|
* 5. First-sidecar-wins for entries that share a displayName (deterministic
|
||||||
* rule per Felix decision OQ-1, comment #5339).
|
* rule per Felix decision OQ-1, comment #5339).
|
||||||
*/
|
*/
|
||||||
export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): string {
|
export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): SafeHtml {
|
||||||
if (!text) return '';
|
if (!text) return '' as SafeHtml;
|
||||||
let escaped = escapeHtml(text);
|
let escaped = escapeHtml(text);
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const unique: PersonMention[] = [];
|
const unique: PersonMention[] = [];
|
||||||
for (const mention of mentionedPersons) {
|
for (const mention of mentionedPersons) {
|
||||||
if (seen.has(mention.displayName)) continue;
|
if (seen.has(mention.displayName)) continue;
|
||||||
|
// Defense in depth: refuse to render an anchor for a non-UUID personId.
|
||||||
|
// The escaped block text falls through unchanged, so the @-trigger is
|
||||||
|
// preserved as plain content — no silent data loss, no clickable link.
|
||||||
|
if (!isUuid(mention.personId)) continue;
|
||||||
seen.add(mention.displayName);
|
seen.add(mention.displayName);
|
||||||
unique.push(mention);
|
unique.push(mention);
|
||||||
}
|
}
|
||||||
@@ -103,7 +140,7 @@ export function renderTranscriptionBody(text: string, mentionedPersons: PersonMe
|
|||||||
escaped = escaped.replace(pattern, link);
|
escaped = escaped.replace(pattern, link);
|
||||||
}
|
}
|
||||||
|
|
||||||
return escaped;
|
return escaped as SafeHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,7 +149,7 @@ export function renderTranscriptionBody(text: string, mentionedPersons: PersonMe
|
|||||||
* 2. Replaces every @FirstName LastName occurrence with an anchor link
|
* 2. Replaces every @FirstName LastName occurrence with an anchor link
|
||||||
* 3. Converts newlines to <br>
|
* 3. Converts newlines to <br>
|
||||||
*/
|
*/
|
||||||
export function renderBody(content: string, mentions: MentionDTO[]): string {
|
export function renderBody(content: string, mentions: MentionDTO[]): SafeHtml {
|
||||||
let escaped = escapeHtml(content);
|
let escaped = escapeHtml(content);
|
||||||
|
|
||||||
for (const mention of mentions) {
|
for (const mention of mentions) {
|
||||||
@@ -122,5 +159,5 @@ export function renderBody(content: string, mentions: MentionDTO[]): string {
|
|||||||
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
|
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
|
||||||
}
|
}
|
||||||
|
|
||||||
return escaped.replaceAll('\n', '<br>');
|
return escaped.replaceAll('\n', '<br>') as SafeHtml;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,13 +335,17 @@
|
|||||||
Underline at rest is required for WCAG AA — colour alone fails 8% of men
|
Underline at rest is required for WCAG AA — colour alone fails 8% of men
|
||||||
with red-green colour-blindness. Focus ring uses a box-shadow + border-radius
|
with red-green colour-blindness. Focus ring uses a box-shadow + border-radius
|
||||||
so the rectangle doesn't touch the glyphs.
|
so the rectangle doesn't touch the glyphs.
|
||||||
|
|
||||||
|
Underline colour uses --c-ink at 50% so the link affordance is visible on
|
||||||
|
any surface, including sand-tinted backgrounds where the previous mint
|
||||||
|
accent at 60% (~1.6:1 on white — Leonie FINDING-06) was barely perceptible.
|
||||||
*/
|
*/
|
||||||
.person-mention {
|
.person-mention {
|
||||||
color: var(--c-ink);
|
color: var(--c-ink);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-decoration-thickness: 1px;
|
text-decoration-thickness: 1px;
|
||||||
text-underline-offset: 3px;
|
text-underline-offset: 3px;
|
||||||
text-decoration-color: color-mix(in srgb, var(--c-accent) 60%, transparent);
|
text-decoration-color: color-mix(in srgb, var(--c-ink) 50%, transparent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: text-decoration-color 0.15s ease;
|
transition: text-decoration-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user