diff --git a/apps/web/components/settings/notifications-settings.test.tsx b/apps/web/components/settings/notifications-settings.test.tsx
index 49b4d039d6..722fe05847 100644
--- a/apps/web/components/settings/notifications-settings.test.tsx
+++ b/apps/web/components/settings/notifications-settings.test.tsx
@@ -67,4 +67,29 @@ describe("NotificationsSettings", () => {
"View installation instructions.",
);
});
+
+ // The page body renders inside a `SettingsCard`, whose `Card` base is
+ // `text-xs/relaxed`; sibling settings pages keep group headings at `text-sm`
+ // and descriptions/tables at `text-xs`. This body used to hard-code
+ // `text-base`/`text-sm` throughout, so the whole page read a size larger than
+ // its siblings. The `text-2xl` page title above the card is the shared
+ // `SettingsPageTemplate` heading and is deliberately out of scope. jsdom has
+ // no Tailwind, so assert on the utility classes rather than computed sizes.
+ it("keeps the card body within the settings type scale", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ const body = container.querySelector('[data-slot="card-content"]');
+ expect(body).not.toBeNull();
+ const oversized = [...body!.querySelectorAll("[class]")]
+ .map((element) => element.getAttribute("class") ?? "")
+ // Lookahead, not `(?:\s|$)`: Tailwind's line-height modifier makes the
+ // next character `/` (the Card base is literally `text-xs/relaxed`), so a
+ // consuming boundary would let `text-xl/relaxed` through.
+ .filter((className) => /(?:^|\s)text-(?:base|lg|\d?xl)(?=[\s/]|$)/.test(className));
+ expect(oversized).toEqual([]);
+ });
});
diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx
index dffaf4dca0..8f4474c7e9 100644
--- a/apps/web/components/settings/notifications-settings.tsx
+++ b/apps/web/components/settings/notifications-settings.tsx
@@ -215,13 +215,13 @@ function ExternalProvidersSection({
return (
-
{t("settings:externalProviders")}
-
+
{t("settings:externalProviders")}
+
{t("settings:externalProvidersDescription")}
{!appriseAvailable && (
-
+
{/* The link is part of the sentence, so the whole notice is one
message and the <2> tag addresses the anchor by child index. */}
@@ -239,7 +239,7 @@ function ExternalProvidersSection({
)}
{appriseProviders.length === 0 && (
-
{t("settings:noAppriseProviders")}
+
{t("settings:noAppriseProviders")}
)}
-
{t("settings:notificationEvents")}
-
+
{t("settings:notificationEvents")}
+
{t("settings:notificationEventsDescription")}
@@ -469,7 +469,7 @@ function AppriseProviderForm({
data-settings-dirty={formIsDirty}
data-settings-dirty-level="container"
>
-
{t("settings:appriseProvider")}
+
{t("settings:appriseProvider")}
onNameChange(event.target.value)}
diff --git a/apps/web/e2e/tests/settings/mobile-notifications-type-scale.spec.ts b/apps/web/e2e/tests/settings/mobile-notifications-type-scale.spec.ts
new file mode 100644
index 0000000000..c13d2f6f48
--- /dev/null
+++ b/apps/web/e2e/tests/settings/mobile-notifications-type-scale.spec.ts
@@ -0,0 +1,43 @@
+import { test, expect } from "../../fixtures/test-base";
+import { PROVIDER_NAMES, seedNotificationProviders } from "./notifications-type-scale-helpers";
+
+test.describe("Mobile notifications settings type scale", () => {
+ test("renders the stacked event list on the shared settings scale", async ({
+ testPage,
+ apiClient,
+ prCapture,
+ }) => {
+ await seedNotificationProviders(apiClient);
+
+ // No setViewportSize here on purpose: this spec only runs under the
+ // `mobile-chrome` project (testMatch /mobile-.*\.spec\.ts/, Pixel 5), and
+ // `chromium` testIgnores the same pattern. The testPage context sets only
+ // baseURL, so it inherits the project's device.
+ await testPage.goto("/settings/general/notifications");
+
+ const eventList = testPage.getByTestId("notification-events-mobile-list");
+ await expect(eventList).toBeVisible();
+
+ // One row per provider per event section, so the seeded name repeats. Match
+ // them all rather than .first() — a regression confined to a later section
+ // would otherwise pass.
+ const providerRows = eventList.getByText(PROVIDER_NAMES[0], { exact: true });
+ const rowCount = await providerRows.count();
+ expect(rowCount).toBeGreaterThan(1);
+
+ // Capture before asserting so a run against the pre-fix code still produces
+ // the "before" image for the PR description.
+ await prCapture.screenshot("notifications-mobile", {
+ caption: "Settings → General → Notifications on a phone viewport",
+ });
+
+ // The mobile provider rows used to be text-sm while the section heading
+ // above them inherited the 12px Card base — larger children under a smaller
+ // heading. Every row sits at 12px now.
+ const sizes = await providerRows.evaluateAll((elements) =>
+ elements.map((el) => parseFloat(getComputedStyle(el).fontSize)),
+ );
+ expect(sizes).toHaveLength(rowCount);
+ for (const size of sizes) expect(size).toBeCloseTo(12, 1);
+ });
+});
diff --git a/apps/web/e2e/tests/settings/notifications-type-scale-helpers.ts b/apps/web/e2e/tests/settings/notifications-type-scale-helpers.ts
new file mode 100644
index 0000000000..099b9061ba
--- /dev/null
+++ b/apps/web/e2e/tests/settings/notifications-type-scale-helpers.ts
@@ -0,0 +1,24 @@
+import { expect } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+
+const TURN_FINISHED = "session.turn_finished";
+const CLARIFICATION_REQUESTED = "session.clarification_requested";
+
+/** Two providers so the desktop events table renders more than one column. */
+export const PROVIDER_NAMES = ["Desktop", "Team channel"];
+
+/**
+ * The settings type scale is shared by the desktop table and the mobile
+ * stacked list, so both specs seed the same providers. Keeping one helper stops
+ * the two from drifting into asserting against different fixtures.
+ */
+export async function seedNotificationProviders(apiClient: ApiClient): Promise {
+ for (const name of PROVIDER_NAMES) {
+ const response = await apiClient.rawRequest("POST", "/api/v1/notification-providers", {
+ name,
+ type: "local",
+ events: [TURN_FINISHED, CLARIFICATION_REQUESTED],
+ });
+ expect(response.ok).toBe(true);
+ }
+}
diff --git a/apps/web/e2e/tests/settings/notifications-type-scale.spec.ts b/apps/web/e2e/tests/settings/notifications-type-scale.spec.ts
new file mode 100644
index 0000000000..efac8009df
--- /dev/null
+++ b/apps/web/e2e/tests/settings/notifications-type-scale.spec.ts
@@ -0,0 +1,37 @@
+import { test, expect } from "../../fixtures/test-base";
+import { seedNotificationProviders } from "./notifications-type-scale-helpers";
+
+test.describe("Notifications settings type scale", () => {
+ test("renders the card body on the shared settings scale", async ({
+ testPage,
+ apiClient,
+ prCapture,
+ }) => {
+ await seedNotificationProviders(apiClient);
+
+ await testPage.setViewportSize({ width: 1280, height: 1100 });
+ await testPage.goto("/settings/general/notifications");
+
+ const table = testPage.getByTestId("notification-events-desktop-table");
+ await expect(table).toBeVisible();
+ const description = testPage.getByTestId("external-providers-description");
+ await expect(description).toBeVisible();
+
+ // Capture before asserting so a run against the pre-fix code still produces
+ // the "before" image for the PR description.
+ await prCapture.screenshot("notifications-desktop", {
+ caption: "Settings → General → Notifications at 1280px",
+ });
+
+ // The page body inherits the Card base (text-xs/relaxed = 12px); group
+ // headings sit one step up at text-sm (14px). Assert the computed sizes so
+ // the capture is backed by a real measurement, not just an eyeball.
+ expect(
+ await description.evaluate((el) => parseFloat(getComputedStyle(el).fontSize)),
+ ).toBeCloseTo(12, 1);
+ expect(await table.evaluate((el) => parseFloat(getComputedStyle(el).fontSize))).toBeCloseTo(
+ 12,
+ 1,
+ );
+ });
+});