fix(stammbaum): keep dimmed nodes opaque so connectors do not bleed through (#703)

Group opacity on the node <g> made the whole node translucent — including
its card fill — so the connector lines drawn beneath a dimmed node showed
through it. Render the card fill at full strength outside the dim group and
move the lineage focus+dim onto an inner content group (outline + labels)
only. The focus ring also leaves the dim group, so a dimmed-but-focused
node keeps a full-strength ring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-31 19:12:39 +02:00
parent e5784caa9d
commit 9c12f62345
2 changed files with 59 additions and 28 deletions

View File

@@ -9,7 +9,7 @@ interface Props {
node: PersonNodeDTO; node: PersonNodeDTO;
pos: { x: number; y: number }; pos: { x: number; y: number };
selected: boolean; selected: boolean;
/** Dim the whole node when a lineage is highlighted and this person is outside it. */ /** Dim the node's outline + labels when a lineage is highlighted and this person is outside it. */
dimmed?: boolean; dimmed?: boolean;
onSelect: (id: string) => void; onSelect: (id: string) => void;
} }
@@ -38,12 +38,11 @@ const datesLabel = $derived(
aria-label="{node.displayName}{datesLabel}" aria-label="{node.displayName}{datesLabel}"
aria-expanded={selected} aria-expanded={selected}
transform="translate({pos.x}, {pos.y})" transform="translate({pos.x}, {pos.y})"
opacity={dimmed ? DIMMED_OPACITY : undefined}
onclick={() => onSelect(node.id)} onclick={() => onSelect(node.id)}
onkeydown={handleKey} onkeydown={handleKey}
onfocus={() => (focused = true)} onfocus={() => (focused = true)}
onblur={() => (focused = false)} onblur={() => (focused = false)}
class="lineage-fade cursor-pointer focus:outline-none" class="cursor-pointer focus:outline-none"
> >
{#if focused} {#if focused}
<rect <rect
@@ -57,40 +56,52 @@ const datesLabel = $derived(
stroke-width="2" stroke-width="2"
/> />
{/if} {/if}
<!-- Opaque card fill — full strength even when dimmed, so the connectors
drawn beneath the node never bleed through. The lineage dim lives on the
content group below; group opacity here would make the fill translucent. -->
<rect <rect
width={NODE_W} width={NODE_W}
height={NODE_H} height={NODE_H}
rx="4" rx="4"
fill={selected ? 'var(--c-primary)' : 'var(--c-surface)'} fill={selected ? 'var(--c-primary)' : 'var(--c-surface)'}
stroke="var(--c-primary)"
stroke-width="1.5"
/> />
{#if selected} <!-- Outline + labels carry the lineage focus+dim; the box stays opaque. -->
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" /> <g class="lineage-fade" opacity={dimmed ? DIMMED_OPACITY : undefined}>
{/if} <rect
<text width={NODE_W}
x={NODE_W / 2} height={NODE_H}
y={NODE_H / 2 - 6} rx="4"
text-anchor="middle" fill="none"
font-family="serif" stroke="var(--c-primary)"
font-size="16" stroke-width="1.5"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'} />
> {#if selected}
{node.displayName} <rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
</text> {/if}
{#if node.birthYear || node.deathYear}
<text <text
x={NODE_W / 2} x={NODE_W / 2}
y={NODE_H / 2 + 12} y={NODE_H / 2 - 6}
text-anchor="middle" text-anchor="middle"
font-family="sans-serif" font-family="serif"
font-size="12" font-size="16"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'} fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
opacity={selected ? 0.75 : 1}
> >
{node.birthYear ?? '?'}{node.deathYear ?? ''} {node.displayName}
</text> </text>
{/if} {#if node.birthYear || node.deathYear}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
text-anchor="middle"
font-family="sans-serif"
font-size="12"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={selected ? 0.75 : 1}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>
{/if}
</g>
</g> </g>
<style> <style>

View File

@@ -940,10 +940,27 @@ describe('StammbaumTree lineage highlight (#703)', () => {
edge('mutter', 'kind', 'PARENT_OF') edge('mutter', 'kind', 'PARENT_OF')
]; ];
// The lineage dim lives on the inner content group (outline + labels); the
// card fill renders outside it at full strength so connectors beneath never
// bleed through a dimmed node.
function nodeOpacity(displayName: string): string | null { function nodeOpacity(displayName: string): string | null {
const content = nodeContentGroup(displayName);
return content.getAttribute('opacity');
}
function nodeContentGroup(displayName: string): Element {
const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`); const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
if (!g) throw new Error(`No node group rendered for ${displayName}`); if (!g) throw new Error(`No node group rendered for ${displayName}`);
return g.getAttribute('opacity'); const content = g.querySelector('g.lineage-fade');
if (!content) throw new Error(`No content group rendered for ${displayName}`);
return content;
}
/** The opaque card fill is a direct-child rect of the node, outside the dim group. */
function cardFillIsOpaque(displayName: string): boolean {
const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
if (!g) throw new Error(`No node group rendered for ${displayName}`);
const fill = g.querySelector(':scope > rect');
if (!fill) throw new Error(`No card-fill rect rendered for ${displayName}`);
return fill.getAttribute('opacity') === null && fill.getAttribute('fill-opacity') === null;
} }
const DIM = '0.4'; const DIM = '0.4';
@@ -974,8 +991,11 @@ describe('StammbaumTree lineage highlight (#703)', () => {
for (const name of ['Vater', 'Grossvater', 'Grossmutter', 'Kind', 'Mutter']) { for (const name of ['Vater', 'Grossvater', 'Grossmutter', 'Kind', 'Mutter']) {
expect(nodeOpacity(name)).toBeNull(); expect(nodeOpacity(name)).toBeNull();
} }
// The collateral sibling dims. // The collateral sibling dims — but its card fill stays opaque so the
// connectors drawn beneath it do not show through (the dim is on the
// outline + labels only).
expect(nodeOpacity('Tante')).toBe(DIM); expect(nodeOpacity('Tante')).toBe(DIM);
expect(cardFillIsOpaque('Tante')).toBe(true);
}); });
it('recomputes the highlight for a newly selected person and clears the previous one (AC6)', async () => { it('recomputes the highlight for a newly selected person and clears the previous one (AC6)', async () => {