fix(notification): replace view-all anchor with button to prevent iframe navigation #552
Reference in New Issue
Block a user
Delete Branch "feat/issue-545-notification-dropdown-iframe-fix"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
<a href="/aktivitaeten">with<button type="button">inNotificationDropdown.svelte— SvelteKit's capture-phase link interceptor never fires on a button, so the vitest-browser iframe stays on its session URLhandleViewAllto a zero-arg function (dropse: MouseEventande.preventDefault())it()blocks and addsvi.clearAllMocks()toafterEachto prevent mock leakageMotivation
The
<a href onclick=e.preventDefault()>pattern is structurally broken invitest-browser: SvelteKit's link interceptor is registered as a capture-phase document listener and fires before the component's bubble-phase handler. By the timehandleViewAllcallede.preventDefault(), the router had already initiated navigation, crashing the iframe and contaminating all subsequently-run test files withCannot connect to the iframeerrors. This dropped measured client-side coverage from >80% to ≈58%, blocking the CI coverage gate.Fixes #551. Related: #545.
Test plan
npm run test --project=client NotificationDropdown→ 14/14 pass, no iframe errorsnpm run test→ all 3110 tests pass, noCannot connect to the iframein outputnpm run check→ no new type errors in changed files👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Clean fix. The root cause is well-understood, the solution is the simplest correct one, and the test suite tells the story clearly. A few minor things worth noting.
Blockers
None.
Suggestions
1. The ADR pattern note in
docs/adr/012-browser-test-mocking-strategy.mdcontradicts the fixThe note added says:
But this PR deliberately removes the
hrefand replaces the<a>with a<button>. The documented pattern (<a href> + e.preventDefault()) is exactly the broken pattern this PR fixes. The note should describe the<button onclick={goto(…)}>pattern that was actually shipped, not the pattern that was removed.2.
handleViewAllorder:onClose()beforegoto()— is that intentional?Calling
onClose()first thengoto()means the dropdown closes before navigation happens. That's probably correct (avoids a visible stale dropdown during the transition), but given the PR description specifically mentions the SvelteKit capture-phase router issue, a one-line comment explaining the ordering would make future reviewers confident this isn't accidental.3. Missing
aria-labelfor the "view all activities" button — nice to haveThe button renders the i18n string
m.chronik_view_all()as its label, so it's technically accessible. No issue here. Just noting that the button has no explicitaria-labelbut doesn't need one since it has visible text content.4. Test isolation improvement:
afterEachcleanup orderingvi.clearAllMocks()aftercleanup()is fine — mocks are independent of the DOM. No action needed, just confirming this is correct.5.
page.getByRole('button', { name: /alle aktivitäten|view all/i })— good patternUsing a regex that matches both German and English i18n output means this test won't break when the locale changes in CI. Good defensive choice.
🏛️ Markus Keller — Senior Application Architect
Verdict: ⚠️ Approved with concerns
The fix is architecturally sound — it correctly resolves the interaction between SvelteKit's capture-phase router and the vitest-browser iframe, and the new pattern (
<button>+ programmaticgoto()) is the right approach. My concern is with the documentation layer, which is partially contradictory.Blockers
ADR-012 pattern note is incorrect and will cause future harm
docs/adr/012-browser-test-mocking-strategy.mdnow contains:This is the opposite of what was actually implemented. The PR removes the
<a href>element entirely and replaces it with a<button>. A future developer reading this ADR note will re-introduce the broken<a href> + e.preventDefault()pattern in good faith, causing a repeat of issue #551.The note should document the
<button type="button" onclick={handleViewAll}>pattern and explain why<a href>is problematic in vitest-browser contexts. The "right-click / open-in-new-tab" rationale in the note is also now moot since there is nohref.This must be corrected before merge — ADRs are the codebase's institutional memory. A wrong ADR is worse than no ADR.
Suggestions
Layering is clean —
goto()is called from the component, not from a server load function. This is the right layer for a UI-driven navigation action from a dropdown.Module boundary is respected —
NotificationDropdownreceivesonCloseas a prop and callsgoto()from$app/navigation. No reach into another domain's internals. Good.CSS architecture improvement is welcome — Moving
chronik-fade-inand the print styles tolayout.cssis the right architectural decision. Component-scoped CSS that contains@keyframesgenerates Tailwind noise and doesn't benefit from scoping. Global animations belong in the global stylesheet.Documentation table check — This PR changes no routes, no backend packages, no DB schema, and no new
ErrorCode/Permissionvalues. The doc table in my review guidelines has no required updates for this change. ✓🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
No security regressions introduced. I checked the relevant attack surfaces for this change.
What I checked
1. Open redirect / navigation injection
goto('/aktivitaeten')uses a hardcoded string literal — no user-controlled input flows into the navigation target. No open redirect risk.2. XSS via button content
The button renders
{m.chronik_view_all()}— a Paraglide i18n function call whose output is controlled by the project's message catalog, not user input. Svelte's template compiler escapes text node interpolations. No XSS vector here.3. Loss of
hrefattribute — security implicationsThe original
<a href="/aktivitaeten">carried CSRF-neutral semantics (a GET navigation). Replacing it with<button>+goto()is an equivalent or safer pattern from a security perspective — nohrefmeans no URL that could be modified via DOM injection, bookmarked to a crafted path, or followed without JS.The ADR note's suggestion to "keep the
hreffor right-click semantics" would actually reintroduce a small attack surface (an anchor with a navigablehrefthat JS then intercepts). The pure<button>approach is cleaner from a security standpoint.4. Mock leakage between tests
vi.clearAllMocks()inafterEachcorrectly preventsgotomock state from bleeding into subsequent tests. Without this, a failing assertion in test N could affect test N+1's mock call counts. Good defensive test hygiene.5.
$app/navigationimport scopegotois imported from SvelteKit's$app/navigationmodule, which is a framework-internal alias resolved at build time. No external module resolution attack surface.Summary
Clean change from a security perspective. The move away from
<a href>+e.preventDefault()toward<button>+goto()marginally reduces the attack surface. The test isolation improvement viavi.clearAllMocks()is good practice.🧪 Sara Holt — Senior QA Engineer
Verdict: ✅ Approved
The test changes are high quality. The split into two focused
it()blocks, the mock isolation viavi.clearAllMocks(), and the switch fromgetByRole('link')togetByRole('button', { name: ... })are all correct improvements. Let me go through my full checklist.What I checked
Test naming — ✓
'calls onClose when the view-all button is clicked'— sentence-style, describes behavior'navigates to /aktivitaeten when the view-all button is clicked'— explicit about the expected routeBoth names are self-documenting. When CI fails, no one needs to read the test body to understand what broke.
One logical assertion per test — ✓
Previously a single test asserted both
onCloseand implicitly the link behavior. Now they're split: one test ownsonClose, the other ownsgoto. Clean.Test isolation — ✓
vi.clearAllMocks()inafterEachprevents mock call counts from thegotomock leaking into subsequent tests. This is the correct fix for cross-test contamination.Selector specificity — ✓
getByRole('button', { name: /alle aktivitäten|view all/i })is role-based and locale-resilient. Prefer this over text-only selectors.Coverage of the regression — ✓
The new test
'navigates to /aktivitaeten when the view-all button is clicked'directly verifies the behavior that was broken (iframe navigation crash). The underlying crash isn't testable in a unit test, but the mock assertion ongotoconfirms the correct handler fires.Suggestions
Missing: test for ordering of
onClose()+goto()handleViewAll()callsonClose()first, thengoto(). There are two separate tests but neither verifies the ordering of these two calls. If someone accidentally swaps the order, both existing tests still pass. This is a minor concern (the ordering matters for UX — close before navigate), but a combined assertion orvi.fn()call order check could catch it:This is a "nice to have" — not a blocker for this PR.
Coverage gate impact
The PR description states this fix restores coverage from ~58% back to >80% by preventing the iframe crash from dropping subsequent test files' coverage. That's a meaningful CI reliability improvement and the right motivation for this fix.
🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist
Verdict: ⚠️ Approved with concerns
The semantic change from
<a>to<button>has accessibility implications that need attention. The fix is correct from a testing perspective, but introduces a UX regression for keyboard and AT users.Blockers
1.
<button>without touch target sizing — WCAG 2.2 §2.5.8The "view all" element renders as:
There is no minimum height/width applied. A bare
<button>with only text and no padding/size constraint will render at its natural text height — typically 18–20px tall. WCAG 2.2 Success Criterion 2.5.8 (Target Size, Minimum) requires 24×24px. Our design standard for this audience (60+) is 44×48px. The previous<a>element had the same issue, so this isn't a regression introduced here, but it's worth flagging since we're touching this element.Suggested fix:
2.
<button>announces as "button" to screen readers —<a>announced as "link"Screen readers differentiate between links (navigate somewhere) and buttons (trigger an action). This element navigates to
/aktivitaeten, which semantically makes it a link. By changing to<button>, screen readers will announce "alle Aktivitäten, button" instead of "alle Aktivitäten, link". A user navigating by landmark or role will not find it in the links list.The production UX fix would be to use a visually-styled anchor that calls
goto()programmatically — but that's the pattern that breaks in vitest-browser. This is a genuine tension between test infrastructure constraints and semantic correctness.Pragmatic resolution: Accept the
<button>for now, addrole="link"to restore screen reader semantics:This is unusual but valid HTML —
role="link"on a<button>overrides the implicit button role. Note that this won't give keyboard users theEnterkey navigation affordance (buttons useSpace+Enter; links use onlyEnter) but VoiceOver/NVDA will announce it as a link.Suggestions
prefers-reduced-motionforchronik-fade-in— ✓ already handledThe
@media (prefers-reduced-motion: reduce)block correctly disables the animation. This is the right implementation — no action needed.Print styles in
layout.css— ✓ correct locationMoving
:global(.app-nav) { display: none }and@page { margin: 1.5cm }to the global stylesheet is the right call. Scoped component styles cannot reliably target global print context.Animation name namespacing — ✓
Renaming
.fade-in→.chronik-fade-inand the keyframe accordingly is good hygiene. The generic.fade-inname risked collisions with other components that might define their own fade animations in the global scope.⚙️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
No infrastructure, CI, or deployment changes in this PR. From a platform perspective this is clean.
What I checked
No Docker Compose changes — no new services, no exposed ports, no volume changes.
No CI workflow changes — the fix targets the test runner (vitest-browser iframe crash), but the CI workflow files themselves are unchanged. The fix should naturally restore the coverage gate without any pipeline modifications.
No new environment variables —
$app/navigationis a SvelteKit build-time alias, not a runtime env var. No.envchanges needed.No new infrastructure dependencies —
gotofrom$app/navigationis part of SvelteKit's existing bundle. No new npm packages added.Observation
The PR description mentions that the iframe crash dropped measured coverage from >80% to ≈58%, which was blocking the CI coverage gate. That means this fix restores CI green without touching the coverage threshold or CI configuration — exactly the right approach. The right fix is "make the code not crash the iframe", not "lower the threshold to 58%".
LGTM from infrastructure side.
📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Reviewing from a requirements completeness and acceptance criteria perspective.
Requirements traceability
The PR references issue #551 (fixed) and issue #545 (related). The PR description includes a test plan with three specific, verifiable acceptance criteria:
npm run test --project=client NotificationDropdown→ 14/14 pass, no iframe errorsnpm run test→ all 3110 tests pass, noCannot connect to the iframein outputnpm run check→ no new type errors in changed filesThese are testable, binary, and specific. Good requirements hygiene.
Scope assessment
The PR description clearly states what changed and why. The motivation section correctly identifies the root cause (SvelteKit's capture-phase link interceptor firing before the bubble-phase handler). No scope creep detected — the change is bounded to:
NotificationDropdown.svelteGap: ADR-012 documents the wrong pattern
The pattern note added to the ADR describes
<a href> + e.preventDefault() + goto(), which is the broken pattern this PR removes, not the implemented solution. From a requirements perspective, this is a documentation defect: the ADR will mislead future implementers and produce requirements drift.The implemented behavior is:
<button type="button">onclick={handleViewAll}wherehandleViewAllcallsonClose()thengoto('/aktivitaeten')The ADR note should document this pattern, not the discarded alternative.
Acceptance criteria coverage
The test plan covers the happy path (test pass/fail counts) and type safety. It does not explicitly cover the accessibility regression (screen readers announcing
buttonvslink), but that is outside the scope of this bug fix PR. Tracking that as a separate issue would be appropriate.Review concerns addressed
Three blockers and two suggestions resolved in two new commits:
9e8bb0ad—docs(adr-012): correct pattern note to document button+goto, not anchor+preventDefaultAddresses: Markus (blocker), Felix (suggestion 1), Elicit (gap)
The pattern note in
docs/adr/012-browser-test-mocking-strategy.mdpreviously documented the broken<a href="…"> + e.preventDefault()approach (the exact pattern this PR removes). Replaced it with the correct description: use<button type="button">+goto(path), and explains why the anchor fails — SvelteKit's capture-phase link interceptor fires before the bubble-phaseonclick, makinge.preventDefault()too late.431287a7—fix(notification): add role=link and touch target to view-all buttonAddresses: Leonie (blockers 1 + 2), Felix (suggestion 2), Sara (suggestion)
role="link"on the view-all button — overrides the implicit button role so VoiceOver/NVDA announces it as "alle Aktivitäten, link" rather than "button". Resolves Leonie's screen reader semantics concern.min-h-[44px] px-1added to the button class — meets WCAG 2.2 §2.5.8 (24×24px minimum) and our 44×48px design standard for the 60+ audience. Resolves Leonie's touch target concern.handleViewAll:onClose(); // close first — avoids stale dropdown during navigation transition. Resolves Felix's ordering question — the sequence is intentional, not accidental.'calls onClose before navigating to /aktivitaeten'— assertscallOrder === ['close', 'goto']using Sara's exact suggested pattern. Test selectors updated fromgetByRole('button', …)togetByRole('link', …)to match the new role.Verification
NotificationDropdownspec: 15/15 pass (was 14 — +1 call-order test), no iframe errorsnpm run check: no new errors in changed files🏗️ Markus Keller — Senior Application Architect
Verdict: ✅ Approved
What I Checked
Documentation currency — my primary concern for every PR. Let me run through the checklist:
db-orm.puml/db-relationships.pumlCLAUDE.mdpackage tableCLAUDE.mdroute tabledocker-compose/DEPLOYMENT.mddocs/adr/012-browser-test-mocking-strategy.mdupdated with thebutton+gotopattern noteThe ADR update is exactly right. The root cause here — SvelteKit's capture-phase link interceptor firing before the component's bubble-phase handler — is subtle enough that future developers would repeat this mistake without the documented pattern. The note is precise and references the canonical example.
Architecture Observations
Moving styles to
layout.cssis correct. Thechronik-fade-inanimation and print styles were leaking into component-scoped<style>blocks. Centralising them inlayout.cssis the right boundary: shared animations belong at the global layer, not scattered in components that happen to use them first.The
button+goto()pattern is a clean workaround for the iframe interception constraint. There is no architectural alternative given the SvelteKit capture-phase constraint. The pattern is documented, the fix is minimal, and it avoids SvelteKit internals.No Blockers
No module boundary violations, no new coupling, no missing documentation. This is a well-scoped fix.
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
Blockers
None.
Suggestions
1. Prefer
vi.mocked()over the type cast in the call-order testIn
NotificationDropdown.svelte.test.ts(line ~198):vi.mocked()exists precisely for this. It returns the mock with the correct type without a manual cast, which reads better and is the idiomatic Vitest pattern.What Looks Good
handleViewAll()is clean: zero-arg, two lines, does one thing. Exactly right size.handleViewAll(// close first — avoids stale dropdown during navigation transition) is a legitimate why-comment: it explains a non-obvious ordering constraint. Good call including it.vi.clearAllMocks()inafterEach: correctly prevents mock leakage across tests. This is the fix that matters for test isolation.expects callOrder to equal ['close', 'goto']) is a precise, behavior-level assertion for a subtle ordering invariant. This is the kind of test that actually catches regressions.<style>blocks fromChronikFuerDichBox.svelteandhilfe/transkription/+page.sveltein favour oflayout.cssis correct..chronik-fade-inis now namespaced (no collision risk) and the@media printrule belongs at the global layer.🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
What I Checked
This is a frontend-only component fix with no backend changes, no auth flow changes, and no new API surface. My scan was focused on:
Navigation injection risk —
goto('/aktivitaeten')uses a hardcoded string literal. No user-controlled input reachesgoto(). Zero injection risk.role="link"on a button — not a security issue. The element's semantics change for AT, but no security boundary is affected.Mock setup in tests —
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))is a proper module boundary mock. It does not expose any real navigation or auth state.No new endpoints, no new permissions, no new cross-origin concerns.
No
innerHTML,eval(), ordangerouslySetInnerHTMLpatterns introduced.Nothing to Flag
Clean from a security perspective. The fix actually removes a click handler from an anchor element (the previous
onclick={onClose}pattern), which slightly reduces the attack surface for clickjacking-style confusion — though that wasn't the intent here.🧪 Sara Holt — QA Engineer & Test Automation Specialist
Verdict: ⚠️ Approved with concerns
What Looks Good
vi.clearAllMocks()inafterEach— this is the fix that actually prevents the mock leakage. Combined withcleanup(), the afterEach hook is now correct and deterministic.it()blocks — each test has one reason to fail. The original single test was doing two things (asserting onClose called AND asserting navigation). The split is correct per Arrange-Act-Assert discipline.callOrder === ['close', 'goto']is a precise behavioral assertion for a subtle invariant. This is the right way to test ordering contracts.getByRole('link', { name: /alle aktivitäten|view all/i })— correctly queries by the element's ARIA role (role="link"on the button), not by CSS class or position. Resilient to markup changes.Concerns
1. The navigation test re-renders with a fresh
onClose: () => {}but the order test uses a spyonCloseThese are separate renders in separate
it()blocks — which is correct isolation. But the navigation test doesn't verify thatonClosewas NOT called (it was given a stub that doesn't assert). This is fine — the concerns are separated across tests. ✅ Actually no concern here, just noting the design is intentional.2.
vi.mocked(goto)is cleaner than the cast in the order testThis is a suggestion, not a blocker. The current cast works but
vi.mocked()is the canonical Vitest pattern for typing already-mocked imports.3. No test for the "does NOT navigate when only marking as read" path
The PR adds tests for the view-all button. But there's no negative assertion test: "clicking a mark-read button does NOT call
goto." This would prevent future regressions wheregotois accidentally called in a different handler. Low priority since the mock is cleared between tests, but worth noting.Coverage Assessment
The original coverage gate blockage (≈58% vs >80%) was caused by iframe crashes contaminating subsequent test files. The fix addresses the root cause rather than adding compensating tests — which is the right call. The new tests are genuinely additive.
🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist
Verdict: ⚠️ Approved with concerns
Blockers
None that prevent merge, but one concern needs attention before the next sprint.
Concerns
1.
role="link"on a<button>— keyboard contract mismatch (Medium severity)The intent is correct — telling AT this element navigates like a link. But there's a subtle keyboard contract issue:
role="link"elements are activated by Enter only (not Space)<button>responds to both Enter AND Spacerole="link"tells screen reader users "this is a link, Space does nothing"For keyboard-only users and screen reader users, this creates an inconsistency: Space works, but their AT told them it shouldn't. WCAG 4.1.2 (Name, Role, Value) requires the role to accurately represent the accessible behavior.
Recommended fix — two options:
Option A: Remove
role="link"and style the button to look like a link. The button will be announced as "button" (accurate), and screen readers will say "press Space or Enter to activate":Option B: Keep
role="link"and suppress Space activation explicitly:Option A is simpler and more semantically honest.
2. No explicit focus-visible ring on the new button (Minor)
The previous
<a>presumably inherited project-wide anchor focus styles. The new<button>does not have an explicitfocus-visible:ring-*class in the diff. The Tailwind base reset may strip browser defaults. Verify this renders a visible focus indicator, especially at high contrast mode.Suggested addition:
focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 rounded-smWhat Looks Good
min-h-[44px]— correct 44px touch target on the button. This is a WCAG 2.2 requirement and important for the 60+ audience. ✅text-xs font-medium text-ink-2 hover:text-inkmatches the previous anchor appearance. The visual change is invisible to sighted users. ✅chronik-fade-ininlayout.csswith@media (prefers-reduced-motion: reduce)— the reduced-motion media query is correctly preserved in the migration tolayout.css. ✅📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Requirements Traceability Check
The PR description is well-structured. Let me verify alignment between stated motivation, implementation, and acceptance criteria:
Root cause claim: SvelteKit's capture-phase link interceptor fires before the component's bubble-phase
onclick, makinge.preventDefault()arrive too late.Implementation response: Replace
<a href>with<button type="button">. A button carries nohref, so the capture-phase interceptor never fires. This is a causal fix, not a workaround.Acceptance criteria:
NotificationDropdown→ 14/14 pass, no iframe errors — directly verifiableCannot connect to the iframe— verifiablenpm run check→ no new type errors — verifiableAll three criteria are measurable and test-executable. ✅
Scope Assessment
The PR fixes one root cause and delivers three incidental improvements (CSS consolidation, test isolation via
vi.clearAllMocks(), ADR documentation). None of the incidental changes introduce new requirements or expand scope. The CSS moves are refactors, not feature changes.Open Requirements Question
The PR references both #551 (the iframe crash bug) and #545 (the broader notification dropdown fix). It's clear this closes #551. The relationship to #545 should be confirmed — if #545 is the parent issue, this PR should be noted as one of potentially multiple contributions to that issue, so the acceptance criteria of #545 are tracked separately.
No blockers from a requirements perspective.
🛠️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
What I Checked
This PR touches only frontend Svelte components, test files, a CSS file, and an ADR doc. No changes to:
docker-compose.ymlor any Compose overlay.gitea/workflows/)backend/code, no new JARs or servicesNothing in my domain is affected. The fix is entirely contained to the frontend test layer and component markup.
One Observation (Non-blocking)
The root cause (SvelteKit's capture-phase link interceptor crashing the Playwright iframe) is a CI environment concern. The fix addresses it correctly at the component level, which is better than fighting it at the CI/Playwright configuration level. Good call — infrastructure workarounds for application-layer problems are always more fragile than fixing the application layer.
LGTM from an infrastructure perspective.
- role="link" restores screen reader link semantics (Leonie blocker) - min-h-[44px] px-1 meets WCAG 2.2 §2.5.8 and our 44×48px target size - Comment in handleViewAll explains close-before-navigate ordering - Tests updated to getByRole('link') + new call-order assertion Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>431287a785tobc2dd3a98aThe role=link override on a <button> creates a WCAG 4.1.2 keyboard-contract mismatch: ARIA role=link tells AT users "press Enter to activate (Space does nothing)", but the native <button> responds to both Enter and Space. Removes the override so the element is announced as "button" (accurate). Test selectors updated from getByRole('link') to getByRole('button') accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Round-2 concerns addressed
Two new commits:
6b785579—refactor(notification-tests): use vi.mocked instead of type cast in call-order testAddresses: @Felix (suggestion 1), @Sara (suggestion 2)
Replaces
(goto as ReturnType<typeof vi.fn>).mockImplementation(...)withvi.mocked(goto).mockImplementation(...)in the call-order test.vi.mocked()is the idiomatic Vitest pattern — no manual type cast needed.89860403—fix(notification): remove role=link from view-all button — restores semantically honest button roleAddresses: @Leonie (concern 1)
role="link"on a<button>creates a WCAG 4.1.2 keyboard-contract mismatch: ARIArole="link"tells AT users "press Enter to activate (Space does nothing)", but the native<button>responds to both Enter AND Space. Leonie's Option A applied — removerole="link"so the element is announced as "button" (semantically accurate). Test selector queries updated fromgetByRole('link', ...)togetByRole('button', ...)accordingly; test descriptions updated to match.@Leonie concern 2 (focus-visible ring — minor): No code change needed.
layout.cssline 424 has a global fallback:focus-visible { outline: 2px solid var(--c-focus-ring); }that applies to all interactive elements. The button already renders a visible keyboard focus indicator.Verification
NotificationDropdownspec: 15/15 pass, no iframe errorsnpm run check: no new errors in changed files