diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index 921dae86a..0a22bff56 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -31,7 +31,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { CoachSection } from "../coach-section"; function render(locale: "en" | "de" = "en") { diff --git a/src/components/settings/__tests__/export-section.test.tsx b/src/components/settings/__tests__/export-section.test.tsx index bc648c7d2..fc42f9c5a 100644 --- a/src/components/settings/__tests__/export-section.test.tsx +++ b/src/components/settings/__tests__/export-section.test.tsx @@ -44,7 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { ExportSection } from "../export-section"; function render(node: React.ReactElement, locale: "en" | "de" = "en") { diff --git a/src/components/settings/__tests__/gesundheitsakte-section.test.tsx b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx index baee6ffba..a66fd760a 100644 --- a/src/components/settings/__tests__/gesundheitsakte-section.test.tsx +++ b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx @@ -29,7 +29,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { GesundheitsakteSection } from "../gesundheitsakte-section"; function render(node: React.ReactElement, locale: "en" | "de" = "en") { diff --git a/src/components/settings/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx index 7bc97a732..f44a9f36d 100644 --- a/src/components/settings/__tests__/medications-section.test.tsx +++ b/src/components/settings/__tests__/medications-section.test.tsx @@ -65,7 +65,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { MedicationsSection } from "../medications-section"; function render(locale: "en" | "de" = "en"): string { diff --git a/src/components/settings/__tests__/mood-section.test.tsx b/src/components/settings/__tests__/mood-section.test.tsx index 5f949344b..894b02cc4 100644 --- a/src/components/settings/__tests__/mood-section.test.tsx +++ b/src/components/settings/__tests__/mood-section.test.tsx @@ -131,7 +131,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { MoodSection } from "../mood-section"; function render(locale: "en" | "de" = "en"): string { diff --git a/src/components/settings/__tests__/section-frame-harness.tsx b/src/components/settings/__tests__/section-frame-harness.tsx new file mode 100644 index 000000000..33e0fd68e --- /dev/null +++ b/src/components/settings/__tests__/section-frame-harness.tsx @@ -0,0 +1,45 @@ +"use client"; + +/** + * Test-only harness that reproduces the page heading the live `SettingsShell` + * paints for a given section slug (`settings-shell.tsx` headingBlock). The + * per-section SSR smoke tests render a section body through this harness so + * they can assert the visible heading + its historic `settings-section--title` + * id alongside the body — mirroring what a user sees on the route. + * + * Production no longer wraps section bodies in a standalone frame component: + * the shell paints the heading from the slug and each section body is just its + * cards. This harness lives in `__tests__` only and is never bundled. + */ + +import * as React from "react"; + +import { useTranslations } from "@/lib/i18n/context"; +import type { SettingsSectionSlug } from "../section-slugs"; + +export function SettingsSectionFrame({ + slug, + children, +}: { + slug: SettingsSectionSlug; + children: React.ReactNode; +}) { + const { t } = useTranslations(); + const titleId = `settings-section-${slug}-title`; + + return ( +
+
+
+

+ {t(`settings.sections.${slug}.title`)} +

+

+ {t(`settings.sections.${slug}.subtitle`)} +

+
+
+ {children} +
+ ); +} diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 296ce813a..5744b4ef7 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -87,7 +87,7 @@ vi.mock("@/components/settings/thresholds-editor-section", () => ({ ThresholdsEditorSection: () =>
, })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import type { SettingsSectionSlug } from "../section-slugs"; import { AboutSection } from "../about-section"; import { AccountSection } from "../account-section"; diff --git a/src/components/settings/settings-section-frame.tsx b/src/components/settings/settings-section-frame.tsx deleted file mode 100644 index 98b9eee63..000000000 --- a/src/components/settings/settings-section-frame.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -/** - * `` — the canonical page-frame for every Settings - * section. The settings twin of the admin renderer's `SectionFrame` - * (`src/app/admin/[section]/renderer.tsx`): it emits the one visible - * heading + subheading every page must show, in the reference style used by - * the Admin Konsole → Übersicht landing. - * - * Before this primitive every settings section hand-rolled its own - * `sr-only` `

`, so no settings page showed a visible heading and the - * left-menu-to-first-card alignment drifted page to page. Routing all 19 - * sections through one frame fixes both: a constant-height heading block - * above the first card, and a real visible `h1 + subtitle`. - * - * - * {cards} - * - * - * Title comes from `settings.sections..title`; subtitle from - * `settings.sections..subtitle`. The `

` keeps the historic - * `settings-section--title` id so the section's own `aria-labelledby` - * linkage — and the spotlight-tour anchors that reference it — stay intact. - * - * `topSlot` renders ABOVE the heading (used by the Layout-hub child - * editors for their "← back to hub" link, which must sit above the title). - * `headingAccessory` renders inline at the top-right of the heading row - * (used by Gesundheitsakte for its guided-tour re-entry trigger). - */ - -import * as React from "react"; - -import { useTranslations } from "@/lib/i18n/context"; -import type { SettingsSectionSlug } from "./section-slugs"; - -interface SettingsSectionFrameBaseProps { - /** Optional content rendered above the heading (e.g. a back-to-hub link). */ - topSlot?: React.ReactNode; - /** Optional content rendered inline at the top-right of the heading row - * (e.g. a guided-tour re-entry trigger). */ - headingAccessory?: React.ReactNode; - children: React.ReactNode; -} - -/** - * Slug mode (the 19 shell sections): title + subtitle come from - * `settings.sections..{title,subtitle}` and the `

` keeps the - * historic `settings-section--title` id for `aria-labelledby` and - * spotlight-tour anchors. - */ -interface SettingsSectionFrameSlugProps extends SettingsSectionFrameBaseProps { - slug: SettingsSectionSlug; -} - -/** - * Explicit mode (the per-module settings pages — Vorsorge / Illness / Labs — - * which deliberately stay out of the slug registry because their static - * routes win over `/settings/[section]`): caller passes resolved strings and - * an optional heading id. This lets `ModuleSettingsFrame` delegate here so - * every settings page shares one heading frame. - */ -interface SettingsSectionFrameExplicitProps extends SettingsSectionFrameBaseProps { - title: string; - subtitle: string; - /** Heading element id (defaults to a stable slug-less id). */ - titleId?: string; -} - -export type SettingsSectionFrameProps = - | SettingsSectionFrameSlugProps - | SettingsSectionFrameExplicitProps; - -function isSlugProps( - props: SettingsSectionFrameProps, -): props is SettingsSectionFrameSlugProps { - return "slug" in props; -} - -export function SettingsSectionFrame(props: SettingsSectionFrameProps) { - const { t } = useTranslations(); - const { topSlot, headingAccessory, children } = props; - - let title: string; - let subtitle: string; - let titleId: string; - if (isSlugProps(props)) { - titleId = `settings-section-${props.slug}-title`; - title = t(`settings.sections.${props.slug}.title`); - subtitle = t(`settings.sections.${props.slug}.subtitle`); - } else { - titleId = props.titleId ?? "settings-section-title"; - title = props.title; - subtitle = props.subtitle; - } - - return ( -
- {topSlot ?
{topSlot}
: null} -
-
-

- {title} -

-

{subtitle}

-
- {headingAccessory ? ( -
{headingAccessory}
- ) : null} -
- {children} -
- ); -}