From def8b8a24dd8e8a9ea59c031a9606bab406643c4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 31 Oct 2026 11:04:19 +0100 Subject: [PATCH] release notes: the 5.1.0 popup, and dataforest cloud in Settings > About The release popup now shows 5.1.0: place search, shared place details and faster routing as three cards side by side, each with its own drawing. The support band is set as a card like the promise above it (optional supportLabelKey; a release without it keeps the plain line), and the two support buttons get the same glass look in their colours. Settings > About gets a full-width panel for dataforest cloud, who sponsor the servers behind place search, address search and routing. Both logos ship in client/public/brands, so opening Settings reaches no third party. Wherever a line names dataforest cloud it links to https://cloud.dataforest.net/?mtm_campaign=trek, the link dataforest gave us; the README moves to it too. All 28 locales carry the new copy, and the old 5.0 card keys are gone. --- README.md | 8 +- .../public/brands/dataforest-cloud-dark.svg | 23 ++ client/public/brands/dataforest-cloud.svg | 23 ++ client/scripts/format-baseline.json | 4 - client/scripts/theme-disable-baseline.json | 2 +- .../src/components/Settings/AboutTab.test.tsx | 23 +- client/src/components/Settings/AboutTab.tsx | 161 ++++++++- .../SystemNotices/ReleaseNoticeModal.test.tsx | 317 +++++++++++------- .../SystemNotices/ReleaseNoticeModal.tsx | 114 +++++-- .../SystemNotices/ReleaseNoticeVisuals.tsx | 172 +++++++++- .../SystemNotices/releaseNotice.css | 58 +++- .../SystemNotices/releaseNoticeVisuals.css | 294 +++++++++++++++- server/src/systemNotices/registry.ts | 36 +- server/src/systemNotices/types.ts | 2 + shared/src/i18n/ar/settings.ts | 8 + shared/src/i18n/ar/system_notice.ts | 31 +- shared/src/i18n/az/settings.ts | 8 + shared/src/i18n/az/system_notice.ts | 31 +- shared/src/i18n/br/settings.ts | 8 + shared/src/i18n/br/system_notice.ts | 31 +- shared/src/i18n/ca/settings.ts | 8 + shared/src/i18n/ca/system_notice.ts | 31 +- shared/src/i18n/cs/settings.ts | 8 + shared/src/i18n/cs/system_notice.ts | 31 +- shared/src/i18n/de/settings.ts | 8 + shared/src/i18n/de/system_notice.ts | 31 +- shared/src/i18n/en/settings.ts | 8 + shared/src/i18n/en/system_notice.ts | 31 +- shared/src/i18n/es/settings.ts | 8 + shared/src/i18n/es/system_notice.ts | 31 +- shared/src/i18n/et/settings.ts | 8 + shared/src/i18n/et/system_notice.ts | 31 +- shared/src/i18n/fr/settings.ts | 8 + shared/src/i18n/fr/system_notice.ts | 31 +- shared/src/i18n/gr/settings.ts | 8 + shared/src/i18n/gr/system_notice.ts | 31 +- shared/src/i18n/he/settings.ts | 8 + shared/src/i18n/he/system_notice.ts | 31 +- shared/src/i18n/hu/settings.ts | 8 + shared/src/i18n/hu/system_notice.ts | 31 +- shared/src/i18n/id/settings.ts | 8 + shared/src/i18n/id/system_notice.ts | 31 +- shared/src/i18n/it/settings.ts | 8 + shared/src/i18n/it/system_notice.ts | 31 +- shared/src/i18n/ja/settings.ts | 8 + shared/src/i18n/ja/system_notice.ts | 31 +- shared/src/i18n/ko/settings.ts | 8 + shared/src/i18n/ko/system_notice.ts | 31 +- shared/src/i18n/nl/settings.ts | 8 + shared/src/i18n/nl/system_notice.ts | 31 +- shared/src/i18n/pl/settings.ts | 8 + shared/src/i18n/pl/system_notice.ts | 31 +- shared/src/i18n/ru/settings.ts | 8 + shared/src/i18n/ru/system_notice.ts | 31 +- shared/src/i18n/sk/settings.ts | 8 + shared/src/i18n/sk/system_notice.ts | 31 +- shared/src/i18n/sv/settings.ts | 8 + shared/src/i18n/sv/system_notice.ts | 31 +- shared/src/i18n/th/settings.ts | 8 + shared/src/i18n/th/system_notice.ts | 30 +- shared/src/i18n/tr/settings.ts | 8 + shared/src/i18n/tr/system_notice.ts | 31 +- shared/src/i18n/uk/settings.ts | 8 + shared/src/i18n/uk/system_notice.ts | 31 +- shared/src/i18n/vi/settings.ts | 8 + shared/src/i18n/vi/system_notice.ts | 31 +- shared/src/i18n/zh-TW/settings.ts | 8 + shared/src/i18n/zh-TW/system_notice.ts | 30 +- shared/src/i18n/zh/settings.ts | 8 + shared/src/i18n/zh/system_notice.ts | 30 +- .../src/system-notice/system-notice.schema.ts | 2 + 71 files changed, 1636 insertions(+), 692 deletions(-) create mode 100644 client/public/brands/dataforest-cloud-dark.svg create mode 100644 client/public/brands/dataforest-cloud.svg diff --git a/README.md b/README.md index 7f6bd8da6..d1e2565ee 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ TREK has grown from a travel planner into a whole ecosystem for trips: plan toge   Stars -PUBLIC SERVICES PROUDLY SPONSORED BY
dataforest cloud +PUBLIC SERVICES PROUDLY SPONSORED BY
dataforest cloud --- @@ -224,7 +224,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
- + @@ -243,7 +243,7 @@ Thank you, dataforest, for backing open source this generously. 💚
-
Visit dataforest cloud +Visit dataforest cloud Looking for a server for your own TREK? dataforest cloud is where TREK's own services live. @@ -277,7 +277,7 @@ Open `http://localhost:3000`, or `http://:3000` from another machine. `COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it. > [!TIP] -> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute. +> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
diff --git a/client/public/brands/dataforest-cloud-dark.svg b/client/public/brands/dataforest-cloud-dark.svg new file mode 100644 index 000000000..bd5edf236 --- /dev/null +++ b/client/public/brands/dataforest-cloud-dark.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/brands/dataforest-cloud.svg b/client/public/brands/dataforest-cloud.svg new file mode 100644 index 000000000..c57183e5b --- /dev/null +++ b/client/public/brands/dataforest-cloud.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/scripts/format-baseline.json b/client/scripts/format-baseline.json index 44c1b5166..501a55b93 100644 --- a/client/scripts/format-baseline.json +++ b/client/scripts/format-baseline.json @@ -899,7 +899,6 @@ "src/components/Roadtrip/useVehicleRange.test.ts", "src/components/Roadtrip/useVehicleRange.ts", "src/components/Roadtrip/vehicleRange.ts", - "src/components/Settings/AboutTab.tsx", "src/components/Settings/AccountTab.test.tsx", "src/components/Settings/AccountTab.tsx", "src/components/Settings/AirTrailConnectionSection.test.tsx", @@ -991,9 +990,6 @@ "src/components/Studio/useBookPresence.ts", "src/components/Studio/useBookStore.ts", "src/components/Studio/useSpreadInteraction.ts", - "src/components/SystemNotices/ReleaseNoticeModal.test.tsx", - "src/components/SystemNotices/ReleaseNoticeModal.tsx", - "src/components/SystemNotices/ReleaseNoticeVisuals.tsx", "src/components/SystemNotices/SystemNoticeBanner.extra.test.tsx", "src/components/SystemNotices/SystemNoticeBanner.test.tsx", "src/components/SystemNotices/SystemNoticeBanner.tsx", diff --git a/client/scripts/theme-disable-baseline.json b/client/scripts/theme-disable-baseline.json index 8110a9c88..2a56b0a8b 100644 --- a/client/scripts/theme-disable-baseline.json +++ b/client/scripts/theme-disable-baseline.json @@ -35,7 +35,7 @@ "components/Roadtrip/dayColors.ts": 8, "components/Roadtrip/nightBookend.ts": 1, "components/Roadtrip/stopKinds.ts": 1, - "components/Settings/AboutTab.tsx": 3, + "components/Settings/AboutTab.tsx": 5, "components/Settings/AccountTab.tsx": 2, "components/Settings/AppearanceSettingsTab.tsx": 1, "components/Settings/PasskeysSection.tsx": 1, diff --git a/client/src/components/Settings/AboutTab.test.tsx b/client/src/components/Settings/AboutTab.test.tsx index d6b27b9c6..063e6f43e 100644 --- a/client/src/components/Settings/AboutTab.test.tsx +++ b/client/src/components/Settings/AboutTab.test.tsx @@ -60,7 +60,8 @@ describe('AboutTab', () => { it('FE-COMP-ABOUT-009: all external links have rel="noopener noreferrer"', () => { render(); const links = document.querySelectorAll('a'); - expect(links).toHaveLength(6); + // Six tiles, and the sponsor panel: its logo, its button and the name in its title and body. + expect(links).toHaveLength(10); links.forEach((link) => { expect(link).toHaveAttribute('rel', 'noopener noreferrer'); }); @@ -130,4 +131,24 @@ describe('AboutTab', () => { expect(link.style.borderColor).toBe(''); expect(link.style.boxShadow).toBe(''); }); + + it('FE-COMP-ABOUT-018: the sponsor panel links dataforest cloud wherever it is named, with both logos bundled', () => { + render(); + const links = document.querySelectorAll('a[href="https://cloud.dataforest.net/?mtm_campaign=trek"]'); + // The logo tile, the call to action, and the brand name in the title and in the body. + expect(links).toHaveLength(4); + for (const link of links) { + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + } + const logos = [...document.querySelectorAll('img')].map((img) => img.getAttribute('src')); + expect(logos).toEqual( + expect.arrayContaining(['/brands/dataforest-cloud.svg', '/brands/dataforest-cloud-dark.svg']) + ); + // Served by TREK itself: opening Settings must not reach a third party. + expect(logos.every((src) => src?.startsWith('/'))).toBe(true); + expect(screen.getByText('Place search')).toBeInTheDocument(); + expect(screen.getByText('Address search')).toBeInTheDocument(); + expect(screen.getByText('Routing')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Settings/AboutTab.tsx b/client/src/components/Settings/AboutTab.tsx index 83d071ee9..81c5f6e9d 100644 --- a/client/src/components/Settings/AboutTab.tsx +++ b/client/src/components/Settings/AboutTab.tsx @@ -1,16 +1,30 @@ -import { BookOpen, Bug, Coffee, ExternalLink, Github, Heart, Info, Lightbulb } from 'lucide-react'; +import { + ArrowUpRight, + BookOpen, + Bug, + Coffee, + ExternalLink, + Github, + Heart, + Info, + Lightbulb, + MapPin, + Route, + Search, +} from 'lucide-react'; import React, { type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from '../../i18n'; +import { useAuthStore } from '../../store/authStore'; +import { fs } from '../shared/DialogShell'; import Section from './Section'; import { StatusPill } from './settingsKit'; -import { fs } from '../shared/DialogShell'; -import { useAuthStore } from '../../store/authStore'; interface Props { appVersion: string; } -const DISCORD_PATH = 'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z'; +const DISCORD_PATH = + 'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z'; // The services' own colours: each tile takes its brand on hover and in its icon square. const BRAND = { @@ -27,7 +41,8 @@ const SIGNAL = { } as const; /** Tile chrome that reads the tile's colour from `--tile`: the border and a faint ring on hover. */ -const TILE_HOVER = 'hover:border-[color:var(--tile)] hover:shadow-[0_0_0_1px_color-mix(in_srgb,var(--tile)_14%,transparent)]'; +const TILE_HOVER = + 'hover:border-[color:var(--tile)] hover:shadow-[0_0_0_1px_color-mix(in_srgb,var(--tile)_14%,transparent)]'; /** The icon square in the tile's colour. */ const TILE_ICON = 'bg-[color-mix(in_srgb,var(--tile)_12%,transparent)] text-[color:var(--tile)]'; @@ -36,7 +51,13 @@ const TILE_ICON = 'bg-[color-mix(in_srgb,var(--tile)_12%,transparent)] text-[col * external-link mark on the right. The border picks up the tile's colour while * the pointer is on it. */ -function LinkTile({ href, color, icon, title, hint }: { +function LinkTile({ + href, + color, + icon, + title, + hint, +}: { href: string; /** A brand colour or a theme token, handed to the classes as `--tile`. */ color?: string; @@ -53,19 +74,133 @@ function LinkTile({ href, color, icon, title, hint }: { style={color ? ({ '--tile': color } as CSSProperties) : undefined} > {icon && ( - + {icon} )}
-
{title}
-
{hint}
+
+ {title} +
+
+ {hint} +
); } +const DATAFOREST_HREF = 'https://cloud.dataforest.net/?mtm_campaign=trek'; +const DATAFOREST_NAME = 'dataforest cloud'; +const DATAFOREST = '#02C382'; // theme-lint-disable: brand colour (dataforest cloud) + +/** The sponsor's name, linked wherever a line names it: it is a brand and reads the same in every language. */ +function withDataforestLink(text: string): ReactNode { + const parts = text.split(DATAFOREST_NAME); + if (parts.length === 1) return text; + return parts.flatMap((part, i) => + i === 0 + ? [part] + : [ + + {DATAFOREST_NAME} + , + part, + ] + ); +} + +/** + * The sponsor of TREK's own servers, across the whole section: what runs there + * and a way to visit them. The logo ships with TREK, so opening Settings sends + * nothing to a third party. + */ +function DataforestBanner() { + const { t } = useTranslation(); + const services = [ + { icon: , label: t('settings.about.sponsor.search') }, + { icon: , label: t('settings.about.sponsor.address') }, + { icon: , label: t('settings.about.sponsor.routing') }, + ]; + return ( +
+ {/* The glow and the faint grid: a server room seen from far away, in the sponsor's green. */} + )} + {/* TREK's own servers, and who sponsors them. It asks nothing of the reader, so + a hosted instance shows it too. */} + + {/* What replaces the grids above. AGPL §13 asks for the source to be offered prominently to whoever uses the software over a network, and a customer of a hosted instance is exactly that reader. The support and diff --git a/client/src/components/SystemNotices/ReleaseNoticeModal.test.tsx b/client/src/components/SystemNotices/ReleaseNoticeModal.test.tsx index b219e0e04..98e211486 100644 --- a/client/src/components/SystemNotices/ReleaseNoticeModal.test.tsx +++ b/client/src/components/SystemNotices/ReleaseNoticeModal.test.tsx @@ -1,9 +1,9 @@ -// FE-RN-001 to FE-RN-016 -import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '../../../tests/helpers/render' -import userEvent from '@testing-library/user-event' -import { ReleaseNoticeModal } from './ReleaseNoticeModal' -import type { SystemNoticeDTO } from '../../store/systemNoticeStore' +// FE-RN-001 to FE-RN-020 +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '../../../tests/helpers/render'; +import type { SystemNoticeDTO } from '../../store/systemNoticeStore'; +import { ReleaseNoticeModal } from './ReleaseNoticeModal'; /** A notice shaped like the release-notes registry entry, with the pieces a test needs to vary. */ function releaseNotice(overrides: Partial = {}): SystemNoticeDTO { @@ -42,182 +42,239 @@ function releaseNotice(overrides: Partial = {}): SystemNoticeDT supportTextKey: 'rel.support', }, ...overrides, - } as SystemNoticeDTO + } as SystemNoticeDTO; } -function renderModal(notice = releaseNotice(), handlers: Partial<{ - onDismiss: () => void; onCTA: () => void; onSecondaryCTA: () => void -}> = {}) { - const onDismiss = handlers.onDismiss ?? vi.fn() - const onCTA = handlers.onCTA ?? vi.fn() - const onSecondaryCTA = handlers.onSecondaryCTA ?? vi.fn() +function renderModal( + notice = releaseNotice(), + handlers: Partial<{ + onDismiss: () => void; + onCTA: () => void; + onSecondaryCTA: () => void; + }> = {} +) { + const onDismiss = handlers.onDismiss ?? vi.fn(); + const onCTA = handlers.onCTA ?? vi.fn(); + const onSecondaryCTA = handlers.onSecondaryCTA ?? vi.fn(); const view = render( - - ) - return { ...view, onDismiss, onCTA, onSecondaryCTA } + + ); + return { ...view, onDismiss, onCTA, onSecondaryCTA }; } describe('ReleaseNoticeModal', () => { it('FE-RN-001: renders nothing for a notice without a release block', () => { - const { container } = renderModal(releaseNotice({ release: undefined })) - expect(container.querySelector('.rn-overlay')).toBeNull() - }) + const { container } = renderModal(releaseNotice({ release: undefined })); + expect(container.querySelector('.rn-overlay')).toBeNull(); + }); it('FE-RN-002: shows the version as a plain figure, not a translation key', () => { - renderModal() - expect(screen.getByText('4.3.0')).toBeInTheDocument() - }) + renderModal(); + expect(screen.getByText('4.3.0')).toBeInTheDocument(); + }); it('FE-RN-003: renders one card per feature, each with its drawing', () => { - renderModal() - expect(screen.getByText('rel.f1.title')).toBeInTheDocument() - expect(document.querySelectorAll('.rn-feature')).toHaveLength(3) - expect(document.querySelector('.rn-vis-tours')).not.toBeNull() - expect(document.querySelector('.rn-vis-studio')).not.toBeNull() - expect(document.querySelector('.rn-vis-assist')).not.toBeNull() - }) + renderModal(); + expect(screen.getByText('rel.f1.title')).toBeInTheDocument(); + expect(document.querySelectorAll('.rn-feature')).toHaveLength(3); + expect(document.querySelector('.rn-vis-tours')).not.toBeNull(); + expect(document.querySelector('.rn-vis-studio')).not.toBeNull(); + expect(document.querySelector('.rn-vis-assist')).not.toBeNull(); + }); it('FE-RN-004: splits the note body on the blank line into separate paragraphs', () => { - const n = releaseNotice() + const n = releaseNotice(); // The translation stub echoes the key, so drive the split through a real value. - n.release!.note.bodyKey = 'first paragraph\n\nsecond paragraph' - renderModal(n) - expect(screen.getByText('first paragraph')).toBeInTheDocument() - expect(screen.getByText('second paragraph')).toBeInTheDocument() - }) + n.release!.note.bodyKey = 'first paragraph\n\nsecond paragraph'; + renderModal(n); + expect(screen.getByText('first paragraph')).toBeInTheDocument(); + expect(screen.getByText('second paragraph')).toBeInTheDocument(); + }); it('FE-RN-005: omits the foot when the release has neither a footnote nor notes', () => { - renderModal() - expect(document.querySelector('.rn-release-foot')).toBeNull() - }) + renderModal(); + expect(document.querySelector('.rn-release-foot')).toBeNull(); + }); it('FE-RN-006: shows the footnote and the notes link when both are present', () => { - const n = releaseNotice() - n.release!.footnoteKey = 'rel.footnote' - n.release!.notes = { labelKey: 'rel.notes', href: 'https://example.test/notes' } - renderModal(n) - expect(screen.getByText('rel.footnote')).toBeInTheDocument() - const link = screen.getByRole('link', { name: /rel\.notes/ }) - expect(link).toHaveAttribute('href', 'https://example.test/notes') - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') - }) + const n = releaseNotice(); + n.release!.footnoteKey = 'rel.footnote'; + n.release!.notes = { labelKey: 'rel.notes', href: 'https://example.test/notes' }; + renderModal(n); + expect(screen.getByText('rel.footnote')).toBeInTheDocument(); + const link = screen.getByRole('link', { name: /rel\.notes/ }); + expect(link).toHaveAttribute('href', 'https://example.test/notes'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); it('FE-RN-007: dismisses from the close button and from the backdrop', async () => { - const user = userEvent.setup() - const onDismiss = vi.fn() - renderModal(releaseNotice(), { onDismiss }) + const user = userEvent.setup(); + const onDismiss = vi.fn(); + renderModal(releaseNotice(), { onDismiss }); - await user.click(screen.getByRole('button', { name: 'Close' })) - expect(onDismiss).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole('button', { name: 'Close' })); + expect(onDismiss).toHaveBeenCalledTimes(1); - await user.click(document.querySelector('.rn-overlay')!) - expect(onDismiss).toHaveBeenCalledTimes(2) - }) + await user.click(document.querySelector('.rn-overlay')!); + expect(onDismiss).toHaveBeenCalledTimes(2); + }); it('FE-RN-008: a click inside the panel does not dismiss', async () => { - const user = userEvent.setup() - const onDismiss = vi.fn() - renderModal(releaseNotice(), { onDismiss }) - await user.click(screen.getByText('rel.note.title')) - expect(onDismiss).not.toHaveBeenCalled() - }) + const user = userEvent.setup(); + const onDismiss = vi.fn(); + renderModal(releaseNotice(), { onDismiss }); + await user.click(screen.getByText('rel.note.title')); + expect(onDismiss).not.toHaveBeenCalled(); + }); it('FE-RN-009: a non-dismissible notice has no close button and an inert backdrop', async () => { - const user = userEvent.setup() - const onDismiss = vi.fn() - renderModal(releaseNotice({ dismissible: false }), { onDismiss }) - expect(screen.queryByRole('button', { name: 'Close' })).toBeNull() - await user.click(document.querySelector('.rn-overlay')!) - expect(onDismiss).not.toHaveBeenCalled() - }) + const user = userEvent.setup(); + const onDismiss = vi.fn(); + renderModal(releaseNotice({ dismissible: false }), { onDismiss }); + expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); + await user.click(document.querySelector('.rn-overlay')!); + expect(onDismiss).not.toHaveBeenCalled(); + }); it('FE-RN-010: wires each support button to its own handler', async () => { - const user = userEvent.setup() - const onCTA = vi.fn() - const onSecondaryCTA = vi.fn() - renderModal(releaseNotice(), { onCTA, onSecondaryCTA }) + const user = userEvent.setup(); + const onCTA = vi.fn(); + const onSecondaryCTA = vi.fn(); + renderModal(releaseNotice(), { onCTA, onSecondaryCTA }); - await user.click(screen.getByRole('button', { name: /rel\.bmc/ })) - expect(onCTA).toHaveBeenCalledTimes(1) - expect(onSecondaryCTA).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: /rel\.bmc/ })); + expect(onCTA).toHaveBeenCalledTimes(1); + expect(onSecondaryCTA).not.toHaveBeenCalled(); - await user.click(screen.getByRole('button', { name: /rel\.kofi/ })) - expect(onSecondaryCTA).toHaveBeenCalledTimes(1) - }) + await user.click(screen.getByRole('button', { name: /rel\.kofi/ })); + expect(onSecondaryCTA).toHaveBeenCalledTimes(1); + }); it('FE-RN-011: labels the dialog with the headline and describes it with the intro', () => { - renderModal() - const dialog = screen.getByRole('dialog') - expect(dialog).toHaveAttribute('aria-modal', 'true') - expect(dialog).toHaveAttribute('aria-labelledby', 'notice-title-release-notes') - expect(dialog).toHaveAttribute('aria-describedby', 'notice-body-release-notes') - expect(document.getElementById('notice-title-release-notes')).toHaveTextContent('rel.headline') - expect(document.getElementById('notice-body-release-notes')).toHaveTextContent('rel.intro') - }) + renderModal(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-labelledby', 'notice-title-release-notes'); + expect(dialog).toHaveAttribute('aria-describedby', 'notice-body-release-notes'); + expect(document.getElementById('notice-title-release-notes')).toHaveTextContent('rel.headline'); + expect(document.getElementById('notice-body-release-notes')).toHaveTextContent('rel.intro'); + }); it('FE-RN-012: sets the promise and the support text with a bold lead', () => { - renderModal() - expect(screen.getByText('rel.promise.lead').tagName).toBe('B') - expect(screen.getByText('rel.support.lead').tagName).toBe('B') - expect(screen.getByText('rel.promise.text')).toBeInTheDocument() - }) + renderModal(); + expect(screen.getByText('rel.promise.lead').tagName).toBe('B'); + expect(screen.getByText('rel.support.lead').tagName).toBe('B'); + expect(screen.getByText('rel.promise.text')).toBeInTheDocument(); + }); it('FE-RN-013: the features aside is optional', () => { - renderModal() - expect(document.querySelector('.rn-features-aside')).toBeNull() + renderModal(); + expect(document.querySelector('.rn-features-aside')).toBeNull(); - const n = releaseNotice() - n.release!.featuresAsideKey = 'rel.features.aside' - renderModal(n) - expect(screen.getByText('rel.features.aside')).toBeInTheDocument() - }) + const n = releaseNotice(); + n.release!.featuresAsideKey = 'rel.features.aside'; + renderModal(n); + expect(screen.getByText('rel.features.aside')).toBeInTheDocument(); + }); it('FE-RN-014: a card whose drawing this client does not know falls back to its icon', () => { - const n = releaseNotice() + const n = releaseNotice(); n.release!.features = [ { iconName: 'Route', visual: 'from-a-newer-server', titleKey: 'rel.x.title', bodyKey: 'rel.x.body' }, { iconName: 'NoSuchIcon', titleKey: 'rel.y.title', bodyKey: 'rel.y.body' }, - ] - renderModal(n) - expect(document.querySelectorAll('.rn-vis-icon')).toHaveLength(2) - expect(document.querySelectorAll('.rn-vis-icon svg')).toHaveLength(2) - }) + ]; + renderModal(n); + expect(document.querySelectorAll('.rn-vis-icon')).toHaveLength(2); + expect(document.querySelectorAll('.rn-vis-icon svg')).toHaveLength(2); + }); it('FE-RN-015: a wide card spans the row with its drawing beside the text, the others stay upright', () => { - const n = releaseNotice() + const n = releaseNotice(); n.release!.features = [ ...n.release!.features, { iconName: 'LifeBuoy', visual: 'help', layout: 'wide', titleKey: 'rel.f4.title', bodyKey: 'rel.f4.body' }, - ] - renderModal(n) - const cards = document.querySelectorAll('.rn-feature') - expect(cards).toHaveLength(4) - expect(cards[3].classList.contains('rn-feature-wide')).toBe(true) - expect([...cards].slice(0, 3).some(c => c.classList.contains('rn-feature-wide'))).toBe(false) + ]; + renderModal(n); + const cards = document.querySelectorAll('.rn-feature'); + expect(cards).toHaveLength(4); + expect(cards[3].classList.contains('rn-feature-wide')).toBe(true); + expect([...cards].slice(0, 3).some((c) => c.classList.contains('rn-feature-wide'))).toBe(false); // The help drawing: the help button and two rows of greetings, each written twice to loop. - const wide = cards[3] - expect(wide.querySelector('.rn-vis-help-q')).not.toBeNull() - expect(wide.querySelectorAll('.rn-vis-hello-row')).toHaveLength(2) - expect(wide.querySelectorAll('.rn-vis-hello').length % 2).toBe(0) + const wide = cards[3]; + expect(wide.querySelector('.rn-vis-help-q')).not.toBeNull(); + expect(wide.querySelectorAll('.rn-vis-hello-row')).toHaveLength(2); + expect(wide.querySelectorAll('.rn-vis-hello').length % 2).toBe(0); // The tours card lists the kinds of tour along its foot. - expect(cards[0].querySelectorAll('.rn-vis-tours-kind')).toHaveLength(5) - }) + expect(cards[0].querySelectorAll('.rn-vis-tours-kind')).toHaveLength(5); + }); it('FE-RN-016: the close button belongs to the panel, not the note', () => { // Below 1080px the panel scrolls and the note is its second screen, so the // stylesheet sticks the button to the panel's own top edge. That only works // while it is a child of .rn-panel; put it back in the note and it scrolls a // whole release half out of reach with every CSS assertion still green. - renderModal(releaseNotice()) - const close = screen.getByRole('button', { name: 'Close' }) - expect(close.parentElement?.classList.contains('rn-panel')).toBe(true) - expect(close.closest('.rn-note')).toBeNull() - }) -}) + renderModal(releaseNotice()); + const close = screen.getByRole('button', { name: 'Close' }); + expect(close.parentElement?.classList.contains('rn-panel')).toBe(true); + expect(close.closest('.rn-note')).toBeNull(); + }); + + it('FE-RN-017: links the sponsor wherever a line names it, and leaves other lines plain', () => { + const n = releaseNotice(); + // The translation stub echoes the key, so the lines carry the brand name as their value. + n.release!.introKey = 'Our servers, sponsored by dataforest cloud, and more.'; + n.release!.supportTextKey = 'Thanks to dataforest cloud today.'; + renderModal(n); + const links = screen.getAllByRole('link', { name: 'dataforest cloud' }); + expect(links).toHaveLength(2); + for (const link of links) { + expect(link).toHaveAttribute('href', 'https://cloud.dataforest.net/?mtm_campaign=trek'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + } + expect(document.getElementById('notice-body-release-notes')).toHaveTextContent( + 'Our servers, sponsored by dataforest cloud, and more.' + ); + expect(screen.getByText('rel.promise.text').querySelector('a')).toBeNull(); + }); + + it('FE-RN-018: with a support label the ask is set as a card like the promise', () => { + const n = releaseNotice(); + n.release!.supportLabelKey = 'rel.support.label'; + renderModal(n); + const card = document.querySelector('.rn-support .rn-support-card'); + expect(card).not.toBeNull(); + expect(card!.classList.contains('rn-promise')).toBe(true); + expect(card!.querySelector('.rn-promise-label')).toHaveTextContent('rel.support.label'); + expect(screen.getByText('rel.support.lead').tagName).toBe('B'); + expect(document.querySelector('.rn-support-text')).toBeNull(); + }); + + it('FE-RN-019: without a support label the ask stays a plain line', () => { + renderModal(); + expect(document.querySelector('.rn-support-card')).toBeNull(); + expect(document.querySelector('.rn-support-text')).toHaveTextContent('rel.support.lead'); + }); + + it('FE-RN-020: draws the search, sharing and routing cards', () => { + const n = releaseNotice(); + n.release!.features = [ + { iconName: 'Search', visual: 'search', titleKey: 'rel.s.title', bodyKey: 'rel.s.body' }, + { iconName: 'Share2', visual: 'share', titleKey: 'rel.sh.title', bodyKey: 'rel.sh.body' }, + { iconName: 'Route', visual: 'routing', titleKey: 'rel.r.title', bodyKey: 'rel.r.body' }, + ]; + renderModal(n); + expect(document.querySelectorAll('.rn-vis-icon')).toHaveLength(0); + // The right place on top: the first result is the highlighted one. + const rows = document.querySelectorAll('.rn-vis-search-row'); + expect(rows).toHaveLength(3); + expect(rows[0].classList.contains('rn-vis-search-row-top')).toBe(true); + // One place's details going out to three other TREKs. + expect(document.querySelectorAll('.rn-vis-share-field')).toHaveLength(4); + expect(document.querySelectorAll('.rn-vis-share-node')).toHaveLength(3); + // A day's five stops on one drawn route. + expect(document.querySelectorAll('.rn-vis-route-stop')).toHaveLength(5); + expect(document.querySelector('.rn-vis-route-line')).not.toBeNull(); + }); +}); diff --git a/client/src/components/SystemNotices/ReleaseNoticeModal.tsx b/client/src/components/SystemNotices/ReleaseNoticeModal.tsx index da49ec4f6..fd844a2d6 100644 --- a/client/src/components/SystemNotices/ReleaseNoticeModal.tsx +++ b/client/src/components/SystemNotices/ReleaseNoticeModal.tsx @@ -1,5 +1,5 @@ +import { ArrowRight, Heart, Infinity as InfinityIcon, ShieldCheck, X } from 'lucide-react'; import React from 'react'; -import { ArrowRight, Heart, Infinity as InfinityIcon, X } from 'lucide-react'; import { useTranslation } from '../../i18n/TranslationContext.js'; import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js'; import { ReleaseFeatureVisual } from './ReleaseNoticeVisuals.js'; @@ -16,8 +16,18 @@ interface Props { /** Lucide's coffee cup, drawn here so its three steam strokes can drift up in waves. */ function SteamingCoffee() { return ( -
+
{/* A child of the panel, not the note: stacked, the panel scrolls and the note is its second screen, so the button sticks to the panel's edge. */} {notice.dismissible && ( @@ -86,19 +118,21 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
{release.version}
-

{t(release.headlineKey)}

-

{t(release.introKey)}

+

+ {t(release.headlineKey)} +

+

+ {withSponsorLink(t(release.introKey))} +

{t(release.featuresLabelKey)}
- {release.features.map(f => ( + {release.features.map((f) => (