From 05ee3d50e48992b250036ccfaeff2a7d24eccea8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 15 Sep 2026 23:50:15 +0200 Subject: [PATCH] feat(notices): bring the release notes back on every update The release modal takes the 4.3.0 design: the release and its headline cards on the left, the note and the support links on the right, at the design's own size. The cards carry small drawings of the features instead of an icon. The 4.0.0 entry is retired. In its place sits one entry, release-notes, that every big release rewrites and keeps. It recurs per version with no upper bound, so every user sees it once on each update, whatever they did with it before, and a reload of the same version keeps it closed. An integration test holds that promise through the real entry and the real dismiss route. All 23 locales carry the new copy and drop the 4.0.0 strings. --- .../SystemNotices/ReleaseNoticeModal.test.tsx | 72 +- .../SystemNotices/ReleaseNoticeModal.tsx | 105 +-- .../SystemNotices/ReleaseNoticeVisuals.tsx | 85 +++ .../SystemNotices/releaseNotice.css | 633 ++++++++++++------ server/src/systemNotices/registry.ts | 106 ++- server/src/systemNotices/types.ts | 16 +- .../tests/integration/systemNotices.test.ts | 43 +- .../tests/unit/systemNotices/registry.test.ts | 45 +- shared/scripts/i18n-selfhost-wording.mjs | 6 + shared/src/i18n/ar/system_notice.ts | 68 +- shared/src/i18n/br/system_notice.ts | 71 +- shared/src/i18n/ca/system_notice.ts | 69 +- shared/src/i18n/cs/system_notice.ts | 71 +- shared/src/i18n/de/system_notice.ts | 71 +- shared/src/i18n/en/system_notice.ts | 71 +- shared/src/i18n/es/system_notice.ts | 71 +- shared/src/i18n/fr/system_notice.ts | 69 +- shared/src/i18n/gr/system_notice.ts | 69 +- shared/src/i18n/hu/system_notice.ts | 69 +- shared/src/i18n/id/system_notice.ts | 69 +- shared/src/i18n/it/system_notice.ts | 69 +- shared/src/i18n/ja/system_notice.ts | 69 +- shared/src/i18n/ko/system_notice.ts | 69 +- shared/src/i18n/nl/system_notice.ts | 71 +- shared/src/i18n/pl/system_notice.ts | 71 +- shared/src/i18n/ru/system_notice.ts | 71 +- shared/src/i18n/sv/system_notice.ts | 71 +- shared/src/i18n/tr/system_notice.ts | 70 +- shared/src/i18n/uk/system_notice.ts | 71 +- shared/src/i18n/vi/system_notice.ts | 72 +- shared/src/i18n/zh-TW/system_notice.ts | 69 +- shared/src/i18n/zh/system_notice.ts | 67 +- .../system-notice.schema.spec.ts | 20 +- .../src/system-notice/system-notice.schema.ts | 29 +- 34 files changed, 1562 insertions(+), 1206 deletions(-) create mode 100644 client/src/components/SystemNotices/ReleaseNoticeVisuals.tsx diff --git a/client/src/components/SystemNotices/ReleaseNoticeModal.test.tsx b/client/src/components/SystemNotices/ReleaseNoticeModal.test.tsx index f3f9d2366..022c6d7c0 100644 --- a/client/src/components/SystemNotices/ReleaseNoticeModal.test.tsx +++ b/client/src/components/SystemNotices/ReleaseNoticeModal.test.tsx @@ -1,14 +1,14 @@ -// FE-RN-001 to FE-RN-011 +// FE-RN-001 to FE-RN-014 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' -/** A notice shaped like the 4.0.0 registry entry, with the pieces a test needs to vary. */ +/** A notice shaped like the release-notes registry entry, with the pieces a test needs to vary. */ function releaseNotice(overrides: Partial = {}): SystemNoticeDTO { return { - id: 'release-4-0-0', + id: 'release-notes', display: 'modal', severity: 'info', titleKey: 'rel.headline', @@ -18,25 +18,27 @@ function releaseNotice(overrides: Partial = {}): SystemNoticeDT cta: { kind: 'link', labelKey: 'rel.bmc', href: 'https://buymeacoffee.com/mauriceboe' }, secondaryCta: { kind: 'link', labelKey: 'rel.kofi', href: 'https://ko-fi.com/mauriceboe' }, release: { - version: '4.0.0', + version: '4.3.0', eyebrowKey: 'rel.eyebrow', - tagKey: 'rel.tag', headlineKey: 'rel.headline', introKey: 'rel.intro', + featuresLabelKey: 'rel.features.label', features: [ - { iconName: 'Smartphone', titleKey: 'rel.f1.title', bodyKey: 'rel.f1.body' }, - { iconName: 'BookOpen', titleKey: 'rel.f2.title', bodyKey: 'rel.f2.body', badgeKey: 'rel.f2.badge' }, + { iconName: 'Database', visual: 'places-api', titleKey: 'rel.f1.title', bodyKey: 'rel.f1.body' }, + { iconName: 'Route', visual: 'roadtrip', titleKey: 'rel.f2.title', bodyKey: 'rel.f2.body' }, + { iconName: 'MapPin', visual: 'dawarich', titleKey: 'rel.f3.title', bodyKey: 'rel.f3.body' }, ], note: { eyebrowKey: 'rel.note.eyebrow', titleKey: 'rel.note.title', bodyKey: 'rel.note.body', promiseLabelKey: 'rel.promise.label', + promiseLeadKey: 'rel.promise.lead', promiseTextKey: 'rel.promise.text', bodyAfterKey: 'rel.note.after', closingKey: 'rel.note.closing', - signatureKey: 'rel.note.signature', }, + supportLeadKey: 'rel.support.lead', supportTextKey: 'rel.support', }, ...overrides, @@ -69,14 +71,16 @@ describe('ReleaseNoticeModal', () => { it('FE-RN-002: shows the version as a plain figure, not a translation key', () => { renderModal() - expect(screen.getByText('4.0.0')).toBeInTheDocument() + expect(screen.getByText('4.3.0')).toBeInTheDocument() }) - it('FE-RN-003: renders one row per feature, with the badge only where declared', () => { + it('FE-RN-003: renders one card per feature, each with its drawing', () => { renderModal() expect(screen.getByText('rel.f1.title')).toBeInTheDocument() - expect(screen.getByText('rel.f2.badge')).toBeInTheDocument() - expect(document.querySelectorAll('.rn-feature')).toHaveLength(2) + expect(document.querySelectorAll('.rn-feature')).toHaveLength(3) + expect(document.querySelector('.rn-vis-places')).not.toBeNull() + expect(document.querySelector('.rn-vis-road')).not.toBeNull() + expect(document.querySelector('.rn-vis-trail')).not.toBeNull() }) it('FE-RN-004: splits the note body on the blank line into separate paragraphs', () => { @@ -88,17 +92,17 @@ describe('ReleaseNoticeModal', () => { expect(screen.getByText('second paragraph')).toBeInTheDocument() }) - it('FE-RN-005: omits the stats row when the release carries no stats', () => { + it('FE-RN-005: omits the foot when the release has neither a footnote nor notes', () => { renderModal() expect(document.querySelector('.rn-release-foot')).toBeNull() }) - it('FE-RN-006: shows the stats row and the notes link when both are present', () => { + it('FE-RN-006: shows the footnote and the notes link when both are present', () => { const n = releaseNotice() - n.release!.stats = [{ value: '~150', labelKey: 'rel.stat.bugs' }] + n.release!.footnoteKey = 'rel.footnote' n.release!.notes = { labelKey: 'rel.notes', href: 'https://example.test/notes' } renderModal(n) - expect(screen.getByText('~150')).toBeInTheDocument() + 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') @@ -152,9 +156,37 @@ describe('ReleaseNoticeModal', () => { renderModal() const dialog = screen.getByRole('dialog') expect(dialog).toHaveAttribute('aria-modal', 'true') - expect(dialog).toHaveAttribute('aria-labelledby', 'notice-title-release-4-0-0') - expect(dialog).toHaveAttribute('aria-describedby', 'notice-body-release-4-0-0') - expect(document.getElementById('notice-title-release-4-0-0')).toHaveTextContent('rel.headline') - expect(document.getElementById('notice-body-release-4-0-0')).toHaveTextContent('rel.intro') + 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() + }) + + it('FE-RN-013: the features aside is optional', () => { + 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() + }) + + it('FE-RN-014: a card whose drawing this client does not know falls back to its icon', () => { + 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) }) }) diff --git a/client/src/components/SystemNotices/ReleaseNoticeModal.tsx b/client/src/components/SystemNotices/ReleaseNoticeModal.tsx index c87074c4b..1c52c60ff 100644 --- a/client/src/components/SystemNotices/ReleaseNoticeModal.tsx +++ b/client/src/components/SystemNotices/ReleaseNoticeModal.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import * as LucideIcons from 'lucide-react'; -import { ArrowRight, Coffee, Heart, Infinity as InfinityIcon, Sparkles, X } from 'lucide-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'; import './releaseNotice.css'; interface Props { @@ -13,6 +13,20 @@ interface Props { onSecondaryCTA: () => void; } +/** Lucide's coffee cup, drawn here so its three steam strokes can drift up in waves. */ +function SteamingCoffee() { + return ( + + ); +} + /** Splits a translated block into paragraphs the way the notice bodies are written. */ function paragraphs(text: string): string[] { return text.split('\n\n').map(p => p.trim()).filter(Boolean); @@ -21,10 +35,10 @@ function paragraphs(text: string): string[] { /** * The release modal: what shipped on the left, a note from the maintainer on * the right. Rendered instead of the generic notice body whenever a notice - * carries `release` — every string comes from that block, so a later release - * only edits the registry entry. + * carries `release`. Every string comes from that block, so a later release + * only edits the registry entry and its keys. * - * Desktop only (the notices carrying it set `desktopOnly`), and it keeps the + * Desktop only (the notice carrying it sets `desktopOnly`), and it keeps the * generic modal's behaviour: the host hook still owns ESC, the scroll lock and * the dismissal, this component only draws. */ @@ -62,48 +76,37 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond {t(release.eyebrowKey)} -
-
{release.version}
-
{t(release.tagKey)}
-
+
{release.version}

{t(release.headlineKey)}

{t(release.introKey)}

-
- {release.features.map(f => { - const Icon: React.ElementType = - ((LucideIcons as Record)[f.iconName] as React.ElementType) ?? Sparkles; - return ( -
- - -
-
- {t(f.titleKey)} - {f.badgeKey && {t(f.badgeKey)}} -
-
{t(f.bodyKey)}
-
-
- ); - })} +
+ {t(release.featuresLabelKey)} +
- {((release.stats?.length ?? 0) > 0 || release.notes) && ( -
- {release.stats && release.stats.length > 0 && ( - {paragraphs(t(release.note.bodyAfterKey)).map((p, i) =>

{p}

)} -
-
{t(release.note.closingKey)}
-
{t(release.note.signatureKey)}
-
+
{t(release.note.closingKey)}
-
{t(release.supportTextKey)}
+
+ {t(release.supportLeadKey)} {t(release.supportTextKey)} +
{notice.cta && ( )} @@ -175,7 +176,7 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond className="rn-support-btn rn-support-kofi" onClick={onSecondaryCTA} > -