feat(settings): split targets and sources, rename AI Insights section

Three related settings changes:

- Split the combined Targets & Sources page back into two pages: Targets
  (/settings/thresholds) owns the per-metric target-range editor and
  Sources (/settings/sources) owns the source-priority and device-type
  ladders. Each is its own left-nav entry and route, served by the
  dynamic [section] route. The former /settings/sources permanent
  redirect is removed since the route now renders the page directly.
- Rename the AI Insights settings section and nav label so the AI nature
  is explicit: 'AI Insights' (EN) / 'KI-Auswertungen' (DE), replacing the
  bare 'Insights' / 'Auswertungen'.
- Each page renders its own h1 heading consistent with sibling settings
  pages.

Drop the now-unused embedded mode from SourcesSection. Update the section
titles and descriptions across all six locale bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 5d8842934b985eb8ce037993a63bb0736ac0c7bd)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 13:26:10 +02:00
1 parent 708d012d28
commit 78b76d2ac5
14 files changed
+122 -167

No files matched your search

+3 -3
View File
@@ -2632,8 +2632,8 @@
"description": "Kachel-Layout und Reihenfolge."
},
"thresholds": {
"title": "Zielwerte & Quellen",
"description": "Pro Metrik: eigener Zielbereich und welche Quelle führt, wenn mehrere am selben Tag denselben Wert liefern."
"title": "Zielwerte",
"description": "Pro Metrik dein eigener Zielbereich für gesunde Werte."
},
"sources": {
"title": "Quellen",
@@ -2688,7 +2688,7 @@
}
},
"ai": {
"title": "Auswertungen",
"title": "KI-Auswertungen",
"description": "Provider, Modell, API-Key."
},
"api": {
+3 -3
View File
@@ -2632,8 +2632,8 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Targets & Sources",
"description": "Per metric: your own target range and which source wins when more than one records the same value on the same day."
"title": "Targets",
"description": "Per metric: your own target range for healthy values."
},
"sources": {
"title": "Sources",
@@ -2688,7 +2688,7 @@
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
+5 -5
View File
@@ -2632,12 +2632,12 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Objetivos y fuentes",
"description": "Por métrica: tu propio rango objetivo y qué fuente prevalece cuando varias registran el mismo valor el mismo día."
"title": "Objetivos",
"description": "Por métrica: tu propio rango objetivo de valores saludables."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"title": "Fuentes",
"description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.",
"redirectNotice": "Las fuentes ahora forman parte de «Objetivos y fuentes».",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
@@ -2688,7 +2688,7 @@
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
+4 -4
View File
@@ -2632,12 +2632,12 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Objectifs et sources",
"description": "Par métrique : votre propre plage cible et quelle source prévaut quand plusieurs enregistrent la même valeur le même jour."
"title": "Objectifs",
"description": "Par métrique : votre propre plage cible pour des valeurs saines."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.",
"redirectNotice": "Les sources font désormais partie de « Objectifs et sources ».",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
@@ -2688,7 +2688,7 @@
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
+5 -5
View File
@@ -2632,12 +2632,12 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Obiettivi e fonti",
"description": "Per metrica: il tuo intervallo target e quale fonte prevale quando più di una registra lo stesso valore lo stesso giorno."
"title": "Obiettivi",
"description": "Per metrica: il tuo intervallo target per valori sani."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"title": "Fonti",
"description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.",
"redirectNotice": "Le fonti ora fanno parte di «Obiettivi e fonti».",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
@@ -2688,7 +2688,7 @@
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
+5 -5
View File
@@ -2632,12 +2632,12 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Cele i źródła",
"description": "Dla każdej metryki: własny zakres docelowy i które źródło wygrywa, gdy kilka rejestruje tę samą wartość tego samego dnia."
"title": "Cele",
"description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"title": "Źródła",
"description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.",
"redirectNotice": "Źródła są teraz częścią „Cele i źródła”.",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
@@ -2688,7 +2688,7 @@
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
+6 -4
View File
@@ -11,11 +11,12 @@ import { ExportSection } from "@/components/settings/export-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
// v1.4.34 IW-D — `sources` slug was retired (merged into `thresholds`).
// The combined "Targets & Sources" component lives in
// `thresholds-section.tsx`; `/settings/sources` is now a
// `permanentRedirect` handled by the dedicated route file.
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
// separate sections again. `ThresholdsSection` renders the target-range
// editor; `SourcesSection` (standalone mode) renders the source-priority
// ladders. Both are served by this dynamic route.
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import { SourcesSection } from "@/components/settings/sources-section";
import {
SETTINGS_SECTION_SLUGS,
isSettingsSectionSlug,
@@ -49,6 +50,7 @@ const SECTION_COMPONENTS: Record<
notifications: NotificationsSection,
dashboard: DashboardSection,
thresholds: ThresholdsSection,
sources: () => <SourcesSection />,
api: ApiSection,
export: ExportSection,
advanced: AdvancedSection,
@@ -79,15 +79,6 @@ vi.mock("@/components/settings/dashboard-layout-section", () => ({
vi.mock("@/components/settings/thresholds-editor-section", () => ({
ThresholdsEditorSection: () => <div data-testid="thresholds-editor" />,
}));
// v1.4.34 IW-D — `<ThresholdsSection>` now also embeds
// `<SourcesSection mode="embedded">` so the combined "Targets &
// Sources" page reads as one shelf. The SourcesSection itself owns
// React Query and TanStack mutation wiring; stub it at the import
// boundary to keep the SSR smoke test focused on the wrapper.
vi.mock("@/components/settings/sources-section", () => ({
SourcesSection: () => <div data-testid="sources-section" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AboutSection } from "../about-section";
import { AccountSection } from "../account-section";
@@ -169,12 +160,12 @@ describe("settings sections — SSR smoke", () => {
expect(html).toContain("Tour ansehen");
});
it("<AiSection> renders the Insights card", () => {
it("<AiSection> renders the AI Insights heading", () => {
const html = render(<AiSection />);
// v1.4.33 IW7 — section renamed from "AI Insights" to "Insights"
// per the Marc-Voice rule. The card title is replaced with a sparkles
// icon + status badges, and the section H1 carries the only heading.
expect(html).toContain("Insights");
// v1.8.7.1 — section renamed to "AI Insights" so the AI nature of the
// surface is explicit (was the bare "Insights"). The section H1 carries
// the only heading.
expect(html).toContain("AI Insights");
expect(html).toContain("settings-section-ai-title");
});
@@ -230,26 +221,26 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain(">Jetzt prüfen<");
});
it("<ThresholdsSection> renders heading and embeds both editors (v1.4.34 IW-D merge)", () => {
it("<ThresholdsSection> renders the Targets heading and the threshold editor only", () => {
// v1.4.16 phase B6 contract: the route wrapper is named
// `<ThresholdsSection>` (was `<ThresholdsSettingsSection>`), lives
// at `thresholds-section.tsx`, and embeds the inner editor under
// `<ThresholdsEditorSection>` (was `<ThresholdsSection>`).
// v1.4.34 IW-D — the wrapper now also embeds
// `<SourcesSection mode="embedded">` so the page is the merged
// "Targets & Sources" surface. Both editor mounts must be present.
// v1.8.7.1 — Sources split back onto its own page, so this wrapper
// renders the target-range editor only.
const html = render(<ThresholdsSection />);
// v1.4.34 IW-D: section renamed to "Targets & Sources".
expect(html).toContain("Targets &amp; Sources");
// v1.8.7.1: section renamed to "Targets".
expect(html).toContain("Targets");
expect(html).toContain('data-testid="thresholds-editor"');
expect(html).toContain('data-testid="sources-section"');
expect(html).not.toContain('data-testid="sources-section"');
expect(html).not.toContain("settings.sections.thresholds.");
});
it("<ThresholdsSection> resolves the German title (B6 rename + IW-D merge)", () => {
it("<ThresholdsSection> resolves the German title", () => {
const html = render(<ThresholdsSection />, "de");
expect(html).toContain("Zielwerte &amp; Quellen");
// v1.8.7.1 — German title is "Zielwerte" (Targets only).
expect(html).toContain("Zielwerte");
expect(html).toContain('data-testid="thresholds-editor"');
expect(html).toContain('data-testid="sources-section"');
expect(html).not.toContain('data-testid="sources-section"');
});
});
@@ -35,7 +35,7 @@ function renderShell(props: {
}
describe("SETTINGS_SECTION_SLUGS", () => {
it("declares the ten sections agreed for v1.4.34", () => {
it("declares the eleven sections", () => {
// Order matters — `generateStaticParams()` and the sidebar derive their
// ordering from this constant, so a reorder is a behaviour change.
// v1.4.3 split the dashboard panel: layout stays under `dashboard`,
@@ -44,19 +44,17 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.4.16 phase B7 added the consolidated `export` section between
// `api` and `advanced` so every "give me my data out" path lives in
// one place.
// v1.4.25 W5e added `sources` between `thresholds` and `ai` so the
// per-metric-class source-priority surface lives next to the other
// analytics-shaping controls (thresholds → ranges; sources →
// canonical source per metric).
// v1.4.34 IW-D — `sources` slug was retired (merged into
// `thresholds`, now "Targets & Sources"). `/settings/sources` stays
// alive as a `permanentRedirect`. Section count: 10 (was 11).
// v1.4.25 W5e added `sources` between `thresholds` and `ai`; v1.4.34
// IW-D merged it into `thresholds`.
// v1.8.7.1 — `sources` (Sources) is its own slug again, sitting
// between `thresholds` (Targets) and `ai`. Section count: 11.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
"sources",
"ai",
"api",
"export",
@@ -164,11 +162,11 @@ describe("<SettingsShell>", () => {
// in the sidebar user-card dropdown. Route `/settings/about` is
// still alive for direct links.
expect(html).not.toContain('href="/settings/about"');
// v1.4.34 IW-D — Sources slug retired; merged into Targets & Sources
// under `/settings/thresholds`. The Sources nav entry must not
// appear; the route stays alive via permanentRedirect.
expect(html).not.toContain('href="/settings/sources"');
expect(html).toContain("Targets &amp; Sources");
// v1.8.7.1 — Targets and Sources are two separate nav entries again.
expect(html).toContain('href="/settings/thresholds"');
expect(html).toContain('href="/settings/sources"');
expect(html).toContain("Targets");
expect(html).toContain("Sources");
});
it("resolves every section title via the i18n provider — German", () => {
@@ -184,21 +182,21 @@ describe("<SettingsShell>", () => {
// per-metric overrides moved out into their own "Persönliche Zielwerte"
// section, which is the new entry below the Dashboard one.
expect(html).toContain("Dashboard");
// v1.4.34 IW-D — section renamed from "Persönliche Zielwerte" to
// "Zielwerte & Quellen" (combined with the former Sources section).
expect(html).toContain("Zielwerte &amp; Quellen");
// v1.4.33 IW7 — "KI-Auswertungen" renamed to "Auswertungen" per
// the Marc-Voice rule (no "KI"/"AI" prefix in user-facing copy).
expect(html).toContain("Auswertungen");
// v1.8.7.1 — Targets and Sources are two separate German nav
// entries again: "Zielwerte" and "Quellen".
expect(html).toContain("Zielwerte");
expect(html).toContain("Quellen");
// v1.8.7.1 — the AI Insights section is named "KI-Auswertungen" in
// German (the "KI" prefix makes the AI nature explicit).
expect(html).toContain("KI-Auswertungen");
// API & Tokens is identical in both locales (proper noun + ampersand)
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("Erweitert");
// v1.4.33 IW7 — "Über" (About) section removed from the in-shell
// nav, folded into the sidebar user-card dropdown ("Über HealthLog").
expect(html).not.toContain('href="/settings/about"');
// v1.4.34 IW-D — Sources nav entry retired (merged into Targets &
// Sources).
expect(html).not.toContain('href="/settings/sources"');
// v1.8.7.1 — both Targets and Sources nav entries are present.
expect(html).toContain('href="/settings/sources"');
});
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
@@ -16,6 +16,7 @@ import {
Download,
Info,
KeyRound,
Layers,
LayoutDashboard,
Link2,
Settings2,
@@ -35,6 +36,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
notifications: Bell,
dashboard: LayoutDashboard,
thresholds: SlidersHorizontal,
sources: Layers,
ai: Sparkles,
api: KeyRound,
export: Download,
+8 -8
View File
@@ -6,20 +6,20 @@
* graph.
*/
// v1.4.34 IW-D — `sources` slug was retired. The two former sections
// (`/settings/thresholds` and `/settings/sources`) merged into a single
// "Targets & Sources" page under `/settings/thresholds`. The historic
// `/settings/sources` URL stays alive as a `permanentRedirect` (see
// `src/app/settings/sources/page.tsx`) so external bookmarks (iOS,
// docs) follow through unchanged. Per
// `.planning/research/v1434-r-2-carryover-scope.md` §7 and
// `.planning/round-v1433-audit-menu.md` §7.1 (item 3).
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are again two
// separate settings pages, each its own left-nav entry and route. They were
// merged into one "Targets & Sources" page in v1.4.34 IW-D, but the two
// editors drive distinct mutation flows (`/api/user/thresholds` vs
// `/api/auth/me/source-priority`) and reading them as one page made the
// surface long; splitting them back keeps each concern self-contained.
// Both slugs are served by the dynamic `[section]` route below.
export const SETTINGS_SECTION_SLUGS = [
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
"sources",
"ai",
"api",
"export",
+10 -8
View File
@@ -21,6 +21,7 @@ import {
Bell,
Download,
KeyRound,
Layers,
LayoutDashboard,
Link2,
Settings2,
@@ -74,14 +75,10 @@ interface SettingsSection {
* navigation. Direct links (the user-card dropdown, the public
* `/about` permalink) continue to resolve to the same component.
*
* v1.4.34 IW-D — `sources` was merged into `thresholds` (now titled
* "Targets & Sources" / "Zielwerte & Quellen"). The combined section
* surfaces per-metric source priority alongside the threshold inputs
* on a single page. `/settings/sources` stays alive as a
* `permanentRedirect` so external bookmarks keep working; the slug
* was dropped from `SETTINGS_SECTION_SLUGS` because the dynamic
* `[section]` route no longer serves it. Per
* `.planning/research/v1434-r-2-carryover-scope.md` §7.
* v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
* separate sidebar entries again (merged into a single "Targets &
* Sources" page in v1.4.34 IW-D, split back here). Both are served by
* the dynamic `[section]` route.
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
@@ -105,6 +102,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.thresholds.title",
icon: SlidersHorizontal,
},
{
slug: "sources",
titleKey: "settings.sections.sources.title",
icon: Layers,
},
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
{
+23 -43
View File
@@ -99,25 +99,15 @@ const DEVICE_TYPE_LABEL_KEYS: Record<DeviceType, string> = {
};
/**
* v1.4.34 IW-D — section presentation mode.
* - `"standalone"` (default) renders the page-level header (h1 +
* subtitle) and keeps the section's own aria-labelledby wiring.
* - `"embedded"` drops the header because a parent surface
* (`<ThresholdsSection>` on `/settings/thresholds`) already names
* the page. The section becomes a `<div>` instead of a
* `<section>` so the assistive-tech reading order stays one
* combined surface.
* `<SourcesSection>` — route-level wrapper for `/settings/sources`
* (the "Sources" / "Quellen" page).
*
* `/settings/sources` no longer renders this component directly — it
* permanently redirects to `/settings/thresholds`, which uses the
* `embedded` mode. The default keeps backward compatibility for any
* test/storybook that still mounts `<SourcesSection />` standalone.
* v1.8.7.1 — Sources is its own settings page again (it was merged into
* the combined "Targets & Sources" page under `/settings/thresholds` in
* v1.4.34 IW-D). The section renders its own page header (h1 + subtitle)
* and owns the per-metric source-priority + device-type ladders.
*/
export interface SourcesSectionProps {
mode?: "standalone" | "embedded";
}
export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}) {
export function SourcesSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -292,32 +282,22 @@ export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}
return Object.keys(priority.deviceTypePriority).length;
}, [priority]);
// v1.4.34 IW-D — `embedded` mode skips the section header because the
// parent surface (`<ThresholdsSection>`) already names the page.
const isStandalone = mode === "standalone";
const Wrapper = isStandalone ? "section" : "div";
const wrapperProps = isStandalone
? {
"aria-labelledby": "settings-section-sources-title",
className: "space-y-6",
}
: { className: "space-y-6" };
return (
<Wrapper {...wrapperProps}>
{isStandalone && (
<header className="space-y-1">
<h1
id="settings-section-sources-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.sources.description")}
</p>
</header>
)}
<section
aria-labelledby="settings-section-sources-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-sources-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.sources.description")}
</p>
</header>
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
@@ -599,7 +579,7 @@ export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}
</div>
)}
</div>
</Wrapper>
</section>
);
}
+13 -33
View File
@@ -1,37 +1,24 @@
"use client";
import { SourcesSection } from "@/components/settings/sources-section";
import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section";
import { useTranslations } from "@/lib/i18n/context";
/**
* `<ThresholdsSection>` — route-level wrapper for `/settings/thresholds`.
* `<ThresholdsSection>` — route-level wrapper for `/settings/thresholds`
* (the "Targets" / "Zielwerte" page).
*
* v1.4.34 IW-D — section was renamed "Zielwerte & Quellen" / "Targets &
* Sources" and absorbs the former `/settings/sources` content. The two
* editors stay distinct mutation flows (`/api/user/thresholds` for
* ranges + `/api/auth/me/source-priority` for source ladders) so a save
* on one half never disturbs the other; presenting them on a single
* page collapses two adjacent sidebar slots into one and matches the
* Settings nav audit (§7.1 item 3 in
* `.planning/round-v1433-audit-menu.md` — both screens were per-metric
* configuration shelves and reading them together matches how a user
* actually thinks about a metric: "for this metric, which sources do
* I trust AND what range counts as healthy?").
*
* `/settings/sources` stays alive as a `permanentRedirect` (see
* `src/app/settings/sources/page.tsx`) so external bookmarks (iOS
* Settings deep-links, docs) keep resolving.
* v1.8.7.1 — Targets and Sources are two separate settings pages again.
* This page owns only the per-metric target-range editor
* (`/api/user/thresholds`); the source-priority ladders moved back onto
* their own `/settings/sources` page (`<SourcesSection>`). The two were
* merged into one "Targets & Sources" page in v1.4.34 IW-D, but the
* combined surface ran long and the two editors drive distinct mutation
* flows, so each concern is self-contained on its own page.
*
* Visual structure on this page:
* 1. Section header (combined title + description).
* 2. `<ThresholdsEditorSection>` — per-metric range inputs (top).
* The editor card kept its own affordance row (icon, reset-all
* button) so the action surface stays compact.
* 3. `<SourcesSection>` body — per-metric source priority + the
* two-axis device-type expander (bottom). The inner section's
* header was suppressed in `mode="embedded"` because the page
* header above already names the surface.
* 1. Section header (title + description).
* 2. `<ThresholdsEditorSection>` — per-metric range inputs. The editor
* card keeps its own affordance row (icon, reset-all button).
*
* v1.4.16 phase B6: file renamed from the historic
* `thresholds-settings-section.tsx` (`<ThresholdsSettingsSection>`)
@@ -63,15 +50,8 @@ export function ThresholdsSection() {
</p>
</header>
{/* Per-metric threshold ranges — top card. */}
{/* Per-metric threshold ranges. */}
<ThresholdsEditorSection id="thresholds" />
{/* Per-metric source priority + two-axis device-type ladder —
bottom card. `mode="embedded"` suppresses the inner h1 +
subtitle (the page header above owns the surface name) and
drops the inner `<section>` aria-label so the assistive-tech
reading order stays one combined surface, not two. */}
<SourcesSection mode="embedded" />
</section>
);
}