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} > -