Просмотр исходного кода

Open What's new from a tab on the edge of the screen and restyle the panel (#598)

* Open What's new from a tab on the edge of the screen and restyle the panel

The header icon is replaced by a vertical What's new tab on the right edge, on desktop only, which can be hidden and switched back on in Settings, About. The panel is a timeline of cards in the theme colour and loads ten entries at a time as it is scrolled. The account menu and Settings, About also open it. What's new is now Torqvoice Cloud only, and an empty list is checked again after a minute.

* Show a YouTube link on an entry, ask for a comment only on thumbs down, and make paging recover from a failed page
Bernt Christian Egeland 7 часов назад
Родитель
Сommit
92e7071f3a
44 измененных файлов с 856 добавлено и 149 удалено
  1. 5 0
      messages/de/common.json
  2. 5 1
      messages/de/settings.json
  3. 5 0
      messages/en/common.json
  4. 5 1
      messages/en/settings.json
  5. 5 0
      messages/es/common.json
  6. 5 1
      messages/es/settings.json
  7. 5 0
      messages/fr/common.json
  8. 5 1
      messages/fr/settings.json
  9. 5 0
      messages/it/common.json
  10. 5 1
      messages/it/settings.json
  11. 5 0
      messages/lt/common.json
  12. 5 1
      messages/lt/settings.json
  13. 5 0
      messages/nb/common.json
  14. 5 1
      messages/nb/settings.json
  15. 5 0
      messages/nl/common.json
  16. 5 1
      messages/nl/settings.json
  17. 5 0
      messages/pl/common.json
  18. 5 1
      messages/pl/settings.json
  19. 5 0
      messages/pt-BR/common.json
  20. 5 1
      messages/pt-BR/settings.json
  21. 5 0
      messages/pt-PT/common.json
  22. 5 1
      messages/pt-PT/settings.json
  23. 5 0
      messages/ru/common.json
  24. 5 1
      messages/ru/settings.json
  25. 5 0
      messages/tr/common.json
  26. 5 1
      messages/tr/settings.json
  27. 1 0
      src/__tests__/features/whats-new/select.test.ts
  28. 144 5
      src/__tests__/features/whats-new/whats-new-panel.test.tsx
  29. 10 4
      src/app/(authenticated)/layout.tsx
  30. 2 0
      src/app/(authenticated)/settings/about/page.tsx
  31. 2 0
      src/components/app-sidebar.tsx
  32. 0 2
      src/components/page-header.tsx
  33. 52 0
      src/features/whats-new/Actions/loadMoreWhatsNew.ts
  34. 41 0
      src/features/whats-new/Components/WhatsNewAboutRows.tsx
  35. 0 37
      src/features/whats-new/Components/WhatsNewButton.tsx
  36. 21 19
      src/features/whats-new/Components/WhatsNewFeedback.tsx
  37. 42 0
      src/features/whats-new/Components/WhatsNewMenuItem.tsx
  38. 189 60
      src/features/whats-new/Components/WhatsNewPanel.tsx
  39. 107 8
      src/features/whats-new/Components/WhatsNewProvider.tsx
  40. 71 0
      src/features/whats-new/Components/WhatsNewTab.tsx
  41. 13 1
      src/features/whats-new/Lib/feed.server.ts
  42. 6 0
      src/features/whats-new/Lib/paging.ts
  43. 23 0
      src/features/whats-new/Lib/tabPreference.ts
  44. 2 0
      src/features/whats-new/Schema/whatsNewSchema.ts

+ 5 - 0
messages/de/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Neuigkeiten: Updates, die Sie noch nicht gesehen haben",
     "description": "Die neuesten Funktionen, die aktuellsten zuerst.",
     "unread": "Neu seit Ihrem letzten Besuch",
+    "newCount": "{count} neu",
+    "showOlder": "Ältere anzeigen",
+    "hideTab": "Diesen Reiter ausblenden",
+    "tabHidden": "Reiter ausgeblendet. Sie können ihn unter Einstellungen, Info wieder einschalten.",
     "kindNew": "Neu",
     "kindImproved": "Verbessert",
     "open": "Öffnen",
     "guide": "Anleitung lesen",
+    "video": "Video ansehen",
     "version": "Version {version}",
     "allReleaseNotes": "Alle Versionshinweise",
     "feedback": {

+ 5 - 1
messages/de/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Version",
     "website": "Webseite",
     "documentation": "Dokumentation",
-    "changelog": "Anderungsprotokoll"
+    "changelog": "Anderungsprotokoll",
+    "whatsNew": "Neuigkeiten",
+    "whatsNewOpen": "Neuigkeiten ansehen",
+    "whatsNewTab": "Den Reiter Neuigkeiten am Bildschirmrand anzeigen",
+    "whatsNewTabHint": "Nur auf größeren Bildschirmen."
   },
   "portal": {
     "title": "Kundenportal",

+ 5 - 0
messages/en/common.json

@@ -59,10 +59,15 @@
     "openUnread": "What's new: updates you have not seen",
     "description": "The latest additions, newest first.",
     "unread": "New since you last looked",
+    "newCount": "{count} new",
+    "showOlder": "Show older updates",
+    "hideTab": "Hide this tab",
+    "tabHidden": "Tab hidden. You can turn it back on in Settings, About.",
     "kindNew": "New",
     "kindImproved": "Improved",
     "open": "Open",
     "guide": "Read the guide",
+    "video": "Watch the video",
     "version": "Version {version}",
     "allReleaseNotes": "All release notes",
     "feedback": {

+ 5 - 1
messages/en/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Version",
     "website": "Website",
     "documentation": "Documentation",
-    "changelog": "Changelog"
+    "changelog": "Changelog",
+    "whatsNew": "What's new",
+    "whatsNewOpen": "See what is new",
+    "whatsNewTab": "Show the What's new tab at the side of the screen",
+    "whatsNewTabHint": "On larger screens only."
   },
   "portal": {
     "title": "Customer Portal",

+ 5 - 0
messages/es/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Novedades: actualizaciones que aún no ha visto",
     "description": "Las últimas incorporaciones, las más recientes primero.",
     "unread": "Nuevo desde su última visita",
+    "newCount": "{count, plural, one {# nueva} other {# nuevas}}",
+    "showOlder": "Ver anteriores",
+    "hideTab": "Ocultar esta pestaña",
+    "tabHidden": "Pestaña oculta. Puede volver a activarla en Configuración, Acerca de.",
     "kindNew": "Nuevo",
     "kindImproved": "Mejorado",
     "open": "Abrir",
     "guide": "Leer la guía",
+    "video": "Ver el vídeo",
     "version": "Versión {version}",
     "allReleaseNotes": "Todas las notas de versión",
     "feedback": {

+ 5 - 1
messages/es/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versión",
     "website": "Sitio web",
     "documentation": "Documentacion",
-    "changelog": "Registro de cambios"
+    "changelog": "Registro de cambios",
+    "whatsNew": "Novedades",
+    "whatsNewOpen": "Ver las novedades",
+    "whatsNewTab": "Mostrar la pestaña Novedades en el lateral de la pantalla",
+    "whatsNewTabHint": "Solo en pantallas grandes."
   },
   "portal": {
     "title": "Portal del cliente",

+ 5 - 0
messages/fr/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Nouveautés : mises à jour que vous n'avez pas encore vues",
     "description": "Les derniers ajouts, du plus récent au plus ancien.",
     "unread": "Nouveau depuis votre dernière visite",
+    "newCount": "{count, plural, one {# nouveauté} other {# nouveautés}}",
+    "showOlder": "Afficher les plus anciennes",
+    "hideTab": "Masquer cet onglet",
+    "tabHidden": "Onglet masqué. Vous pouvez le réactiver dans Paramètres, A propos.",
     "kindNew": "Nouveau",
     "kindImproved": "Amélioré",
     "open": "Ouvrir",
     "guide": "Lire le guide",
+    "video": "Voir la vidéo",
     "version": "Version {version}",
     "allReleaseNotes": "Toutes les notes de version",
     "feedback": {

+ 5 - 1
messages/fr/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Version",
     "website": "Site web",
     "documentation": "Documentation",
-    "changelog": "Journal des modifications"
+    "changelog": "Journal des modifications",
+    "whatsNew": "Nouveautés",
+    "whatsNewOpen": "Voir les nouveautés",
+    "whatsNewTab": "Afficher l'onglet Nouveautés sur le côté de l'écran",
+    "whatsNewTabHint": "Uniquement sur les grands écrans."
   },
   "portal": {
     "title": "Portail client",

+ 5 - 0
messages/it/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Novità: aggiornamenti che non ha ancora visto",
     "description": "Le ultime aggiunte, dalle più recenti.",
     "unread": "Nuovo dalla Sua ultima visita",
+    "newCount": "{count, plural, one {# nuova} other {# nuove}}",
+    "showOlder": "Mostra le precedenti",
+    "hideTab": "Nascondi questa scheda",
+    "tabHidden": "Scheda nascosta. Può riattivarla in Impostazioni, Informazioni.",
     "kindNew": "Nuovo",
     "kindImproved": "Migliorato",
     "open": "Apri",
     "guide": "Leggi la guida",
+    "video": "Guarda il video",
     "version": "Versione {version}",
     "allReleaseNotes": "Tutte le note di rilascio",
     "feedback": {

+ 5 - 1
messages/it/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versione",
     "website": "Sito web",
     "documentation": "Documentazione",
-    "changelog": "Registro modifiche"
+    "changelog": "Registro modifiche",
+    "whatsNew": "Novità",
+    "whatsNewOpen": "Scopri le novità",
+    "whatsNewTab": "Mostra la scheda Novità sul lato dello schermo",
+    "whatsNewTabHint": "Solo su schermi più grandi."
   },
   "portal": {
     "title": "Portale clienti",

+ 5 - 0
messages/lt/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Naujienos: atnaujinimai, kurių dar nematėte",
     "description": "Naujausi papildymai, nuo naujausių.",
     "unread": "Nauja nuo paskutinio apsilankymo",
+    "newCount": "Naujų: {count}",
+    "showOlder": "Rodyti senesnius",
+    "hideTab": "Slėpti šį skirtuką",
+    "tabHidden": "Skirtukas paslėptas. Vėl įjungti galite skiltyje Nustatymai, Apie.",
     "kindNew": "Nauja",
     "kindImproved": "Patobulinta",
     "open": "Atidaryti",
     "guide": "Skaityti vadovą",
+    "video": "Žiūrėti vaizdo įrašą",
     "version": "Versija {version}",
     "allReleaseNotes": "Visos versijų pastabos",
     "feedback": {

+ 5 - 1
messages/lt/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versija",
     "website": "Svetainė",
     "documentation": "Dokumentacija",
-    "changelog": "Pakeitimų žurnalas"
+    "changelog": "Pakeitimų žurnalas",
+    "whatsNew": "Naujienos",
+    "whatsNewOpen": "Žiūrėti naujienas",
+    "whatsNewTab": "Rodyti skirtuką Naujienos ekrano šone",
+    "whatsNewTabHint": "Tik didesniuose ekranuose."
   },
   "portal": {
     "title": "Kliento portalas",

+ 5 - 0
messages/nb/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Hva er nytt: oppdateringer du ikke har sett",
     "description": "De siste nyhetene, nyeste først.",
     "unread": "Nytt siden sist",
+    "newCount": "{count, plural, one {# ny} other {# nye}}",
+    "showOlder": "Vis eldre",
+    "hideTab": "Skjul denne fanen",
+    "tabHidden": "Fanen er skjult. Du kan slå den på igjen under Innstillinger, Om.",
     "kindNew": "Nytt",
     "kindImproved": "Forbedret",
     "open": "Åpne",
     "guide": "Les veiledningen",
+    "video": "Se videoen",
     "version": "Versjon {version}",
     "allReleaseNotes": "Alle versjonsmerknader",
     "feedback": {

+ 5 - 1
messages/nb/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versjon",
     "website": "Nettsted",
     "documentation": "Dokumentasjon",
-    "changelog": "Endringslogg"
+    "changelog": "Endringslogg",
+    "whatsNew": "Hva er nytt",
+    "whatsNewOpen": "Se hva som er nytt",
+    "whatsNewTab": "Vis Hva er nytt-fanen på siden av skjermen",
+    "whatsNewTabHint": "Bare på større skjermer."
   },
   "portal": {
     "title": "Kundeportal",

+ 5 - 0
messages/nl/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Wat is er nieuw: updates die u nog niet hebt gezien",
     "description": "De nieuwste toevoegingen, nieuwste eerst.",
     "unread": "Nieuw sinds uw laatste bezoek",
+    "newCount": "{count} nieuw",
+    "showOlder": "Oudere tonen",
+    "hideTab": "Dit tabblad verbergen",
+    "tabHidden": "Tabblad verborgen. U kunt het weer aanzetten via Instellingen, Over.",
     "kindNew": "Nieuw",
     "kindImproved": "Verbeterd",
     "open": "Openen",
     "guide": "Lees de handleiding",
+    "video": "Bekijk de video",
     "version": "Versie {version}",
     "allReleaseNotes": "Alle release notes",
     "feedback": {

+ 5 - 1
messages/nl/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versie",
     "website": "Website",
     "documentation": "Documentatie",
-    "changelog": "Wijzigingslog"
+    "changelog": "Wijzigingslog",
+    "whatsNew": "Wat is er nieuw",
+    "whatsNewOpen": "Bekijk wat er nieuw is",
+    "whatsNewTab": "Het tabblad Wat is er nieuw aan de zijkant van het scherm tonen",
+    "whatsNewTabHint": "Alleen op grotere schermen."
   },
   "portal": {
     "title": "Klantenportaal",

+ 5 - 0
messages/pl/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Nowości: aktualizacje, których jeszcze nie znasz",
     "description": "Ostatnie zmiany, od najnowszych.",
     "unread": "Nowe od Twojej ostatniej wizyty",
+    "newCount": "{count, plural, one {# nowość} few {# nowości} many {# nowości} other {# nowości}}",
+    "showOlder": "Pokaż starsze",
+    "hideTab": "Ukryj tę kartę",
+    "tabHidden": "Karta ukryta. Możesz ją ponownie włączyć w sekcji Ustawienia, Informacje.",
     "kindNew": "Nowość",
     "kindImproved": "Ulepszone",
     "open": "Otwórz",
     "guide": "Przeczytaj przewodnik",
+    "video": "Obejrzyj film",
     "version": "Wersja {version}",
     "allReleaseNotes": "Wszystkie informacje o wydaniach",
     "feedback": {

+ 5 - 1
messages/pl/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Wersja",
     "website": "Strona",
     "documentation": "Dokumentacja",
-    "changelog": "Dziennik zmian"
+    "changelog": "Dziennik zmian",
+    "whatsNew": "Nowości",
+    "whatsNewOpen": "Zobacz nowości",
+    "whatsNewTab": "Pokazuj kartę Nowości z boku ekranu",
+    "whatsNewTabHint": "Tylko na większych ekranach."
   },
   "portal": {
     "title": "Portal klienta",

+ 5 - 0
messages/pt-BR/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Novidades: atualizações que você ainda não viu",
     "description": "As últimas adições, das mais recentes para as mais antigas.",
     "unread": "Novo desde sua última visita",
+    "newCount": "{count, plural, one {# nova} other {# novas}}",
+    "showOlder": "Mostrar anteriores",
+    "hideTab": "Ocultar esta aba",
+    "tabHidden": "Aba oculta. Você pode reativá-la em Configurações, Sobre.",
     "kindNew": "Novo",
     "kindImproved": "Melhorado",
     "open": "Abrir",
     "guide": "Ler o guia",
+    "video": "Assistir ao vídeo",
     "version": "Versão {version}",
     "allReleaseNotes": "Todas as notas de versão",
     "feedback": {

+ 5 - 1
messages/pt-BR/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versão",
     "website": "Site",
     "documentation": "Documentação",
-    "changelog": "Registro de alterações"
+    "changelog": "Registro de alterações",
+    "whatsNew": "Novidades",
+    "whatsNewOpen": "Ver as novidades",
+    "whatsNewTab": "Mostrar a aba Novidades na lateral da tela",
+    "whatsNewTabHint": "Somente em telas maiores."
   },
   "portal": {
     "title": "Portal do cliente",

+ 5 - 0
messages/pt-PT/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Novidades: atualizações que ainda não viu",
     "description": "As últimas adições, das mais recentes para as mais antigas.",
     "unread": "Novo desde a sua última visita",
+    "newCount": "{count, plural, one {# nova} other {# novas}}",
+    "showOlder": "Mostrar anteriores",
+    "hideTab": "Ocultar este separador",
+    "tabHidden": "Separador oculto. Pode voltar a ativá-lo em Definições, Sobre.",
     "kindNew": "Novo",
     "kindImproved": "Melhorado",
     "open": "Abrir",
     "guide": "Ler o guia",
+    "video": "Ver o vídeo",
     "version": "Versão {version}",
     "allReleaseNotes": "Todas as notas de versão",
     "feedback": {

+ 5 - 1
messages/pt-PT/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versão",
     "website": "Site",
     "documentation": "Documentação",
-    "changelog": "Registo de alterações"
+    "changelog": "Registo de alterações",
+    "whatsNew": "Novidades",
+    "whatsNewOpen": "Ver as novidades",
+    "whatsNewTab": "Mostrar o separador Novidades na lateral do ecrã",
+    "whatsNewTabHint": "Apenas em ecrãs maiores."
   },
   "portal": {
     "title": "Portal do cliente",

+ 5 - 0
messages/ru/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Что нового: обновления, которые вы ещё не видели",
     "description": "Последние новинки, сначала самые свежие.",
     "unread": "Новое с вашего последнего визита",
+    "newCount": "{count, plural, one {# новое} few {# новых} many {# новых} other {# новых}}",
+    "showOlder": "Показать более ранние",
+    "hideTab": "Скрыть эту вкладку",
+    "tabHidden": "Вкладка скрыта. Включить её снова можно в разделе Настройки, О программе.",
     "kindNew": "Новое",
     "kindImproved": "Улучшено",
     "open": "Открыть",
     "guide": "Читать руководство",
+    "video": "Смотреть видео",
     "version": "Версия {version}",
     "allReleaseNotes": "Все примечания к выпускам",
     "feedback": {

+ 5 - 1
messages/ru/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Версия",
     "website": "Веб-сайт",
     "documentation": "Документация",
-    "changelog": "Журнал изменений"
+    "changelog": "Журнал изменений",
+    "whatsNew": "Что нового",
+    "whatsNewOpen": "Посмотреть, что нового",
+    "whatsNewTab": "Показывать вкладку «Что нового» сбоку экрана",
+    "whatsNewTabHint": "Только на больших экранах."
   },
   "portal": {
     "title": "Портал клиента",

+ 5 - 0
messages/tr/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Yenilikler: henüz görmediğiniz güncellemeler",
     "description": "En son eklenenler, en yenisi önce.",
     "unread": "Son ziyaretinizden bu yana yeni",
+    "newCount": "{count} yeni",
+    "showOlder": "Daha eskileri göster",
+    "hideTab": "Bu sekmeyi gizle",
+    "tabHidden": "Sekme gizlendi. Ayarlar, Hakkında bölümünden yeniden açabilirsiniz.",
     "kindNew": "Yeni",
     "kindImproved": "İyileştirildi",
     "open": "Aç",
     "guide": "Kılavuzu oku",
+    "video": "Videoyu izle",
     "version": "Sürüm {version}",
     "allReleaseNotes": "Tüm sürüm notları",
     "feedback": {

+ 5 - 1
messages/tr/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Sürüm",
     "website": "Web Sitesi",
     "documentation": "Belgeler",
-    "changelog": "Değişiklik Günlüğü"
+    "changelog": "Değişiklik Günlüğü",
+    "whatsNew": "Yenilikler",
+    "whatsNewOpen": "Yenilikleri gör",
+    "whatsNewTab": "Yenilikler sekmesini ekranın kenarında göster",
+    "whatsNewTabHint": "Yalnızca büyük ekranlarda."
   },
   "portal": {
     "title": "Müşteri Portalı",

+ 1 - 0
src/__tests__/features/whats-new/select.test.ts

@@ -21,6 +21,7 @@ function entry(overrides: Partial<WhatsNewEntry> = {}): WhatsNewEntry {
     ctaLabel: null,
     ctaHref: null,
     docsHref: null,
+    videoUrl: null,
     minVersion: null,
     audience: 'all',
     subject: null,

+ 144 - 5
src/__tests__/features/whats-new/whats-new-panel.test.tsx

@@ -22,11 +22,24 @@ vi.mock('@/features/whats-new/Actions/sendWhatsNewFeedback', () => ({
   sendWhatsNewFeedback: (input: unknown) => sendWhatsNewFeedback(input),
 }))
 
+const loadMoreWhatsNew = vi.fn(async (_offset: unknown) => ({
+  success: true,
+  data: { entries: [] as WhatsNewEntry[], hasMore: false },
+}))
+vi.mock('@/features/whats-new/Actions/loadMoreWhatsNew', () => ({
+  loadMoreWhatsNew: (offset: unknown) => loadMoreWhatsNew(offset),
+}))
+
 const toastError = vi.fn()
-vi.mock('sonner', () => ({ toast: { error: (message: string) => toastError(message) } }))
+const toastPlain = vi.fn()
+vi.mock('sonner', () => ({
+  toast: Object.assign((message: string) => toastPlain(message), {
+    error: (message: string) => toastError(message),
+  }),
+}))
 
 const { WhatsNewProvider } = await import('@/features/whats-new/Components/WhatsNewProvider')
-const { WhatsNewButton } = await import('@/features/whats-new/Components/WhatsNewButton')
+const { WhatsNewAboutRows } = await import('@/features/whats-new/Components/WhatsNewAboutRows')
 
 const ENTRIES: WhatsNewEntry[] = [
   {
@@ -38,6 +51,7 @@ const ENTRIES: WhatsNewEntry[] = [
     ctaLabel: 'Open the calendar',
     ctaHref: '/calendar',
     docsHref: '/docs/features/calendar',
+    videoUrl: 'https://youtu.be/abc123',
     minVersion: null,
     audience: 'all',
     subject: null,
@@ -52,6 +66,7 @@ const ENTRIES: WhatsNewEntry[] = [
     ctaLabel: null,
     ctaHref: null,
     docsHref: null,
+    videoUrl: null,
     minVersion: null,
     audience: 'all',
     subject: null,
@@ -63,17 +78,21 @@ function show({
   entries = ENTRIES,
   unread = ['appointments'],
   feedback = 'anonymous' as 'identified' | 'anonymous' | null,
+  tabHidden = false,
+  hasMore = false,
 } = {}) {
   return render(
     <WhatsNewProvider
       entries={entries}
+      hasMore={hasMore}
       unreadIds={unread}
+      initialTabHidden={tabHidden}
       timeZone="Europe/Oslo"
       appVersion="v2.14.0"
       releaseNotesUrl="https://example.test/releases"
       feedback={feedback}
     >
-      <WhatsNewButton />
+      <WhatsNewAboutRows />
     </WhatsNewProvider>
   )
 }
@@ -84,9 +103,10 @@ beforeEach(() => {
 })
 
 describe("What's new", () => {
-  it('offers no button when there is nothing to read', () => {
+  it('offers nothing to press when there is nothing to read', () => {
     show({ entries: [], unread: [] })
     expect(screen.queryByRole('button')).not.toBeInTheDocument()
+    expect(screen.queryByRole('switch')).not.toBeInTheDocument()
   })
 
   it('says there is something unseen, then stops saying so once the panel was opened', async () => {
@@ -151,7 +171,7 @@ describe("What's new", () => {
     show({ feedback: 'identified' })
     await user.click(screen.getByRole('button', { name: /What's new/ }))
     const first = screen.getByText('Appointments on the calendar').closest('article') as HTMLElement
-    await user.click(within(first).getByRole('button', { name: 'Yes, this is useful' }))
+    await user.click(within(first).getByRole('button', { name: 'No, not what I expected' }))
     expect(
       await within(first).findByText(
         'Sent to the Torqvoice team with your email address, so we can reply.'
@@ -159,6 +179,19 @@ describe("What's new", () => {
     ).toBeInTheDocument()
   })
 
+  it('sends a thumb up and asks for nothing more', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /What's new/ }))
+    const first = screen.getByText('Appointments on the calendar').closest('article') as HTMLElement
+    const up = within(first).getByRole('button', { name: 'Yes, this is useful' })
+    await user.click(up)
+
+    await waitFor(() => expect(up).toHaveAttribute('aria-pressed', 'true'))
+    expect(sendWhatsNewFeedback).toHaveBeenCalledWith({ entryId: 'appointments', vote: 'up' })
+    expect(within(first).queryByRole('textbox')).not.toBeInTheDocument()
+  })
+
   it('takes the thumb back and says so when the vote could not be sent', async () => {
     sendWhatsNewFeedback.mockResolvedValueOnce({ success: false })
     const user = userEvent.setup()
@@ -224,3 +257,109 @@ describe("What's new, when things go wrong", () => {
     expect(screen.queryByRole('link', { name: /Open the calendar/ })).not.toBeInTheDocument()
   })
 })
+
+describe("the What's new tab on the edge of the screen", () => {
+  it('can be sent away, says where to get it back, and stays away', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: 'Hide this tab' }))
+
+    expect(screen.queryByRole('button', { name: /^What's new/ })).not.toBeInTheDocument()
+    expect(toastPlain).toHaveBeenCalledWith(
+      'Tab hidden. You can turn it back on in Settings, About.'
+    )
+    expect(document.cookie).toContain('whatsNewTab=hidden')
+  })
+
+  it('is not drawn for somebody who hid it, but the news can still be opened from About', async () => {
+    const user = userEvent.setup()
+    show({ tabHidden: true })
+    expect(screen.queryByRole('button', { name: /^What's new/ })).not.toBeInTheDocument()
+
+    await user.click(screen.getByRole('button', { name: 'See what is new' }))
+    expect(await screen.findByRole('dialog', { name: "What's new" })).toBeInTheDocument()
+  })
+
+  it('comes back when it is switched on again in About', async () => {
+    const user = userEvent.setup()
+    show({ tabHidden: true })
+    const toggle = screen.getByRole('switch', {
+      name: /Show the What's new tab at the side of the screen/,
+    })
+    expect(toggle).not.toBeChecked()
+    await user.click(toggle)
+
+    expect(screen.getByRole('button', { name: /^What's new/ })).toBeInTheDocument()
+    expect(document.cookie).toContain('whatsNewTab=shown')
+  })
+})
+
+describe("older highlights in the What's new panel", () => {
+  it('offers nothing more when the first page is all there is', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    expect(screen.queryByRole('button', { name: 'Show older updates' })).not.toBeInTheDocument()
+  })
+
+  it('adds the next page under the ones already shown, and stops offering at the end', async () => {
+    loadMoreWhatsNew.mockResolvedValueOnce({
+      success: true,
+      data: {
+        entries: [{ ...ENTRIES[1], id: 'closures', title: 'Closed days and staff leave' }],
+        hasMore: false,
+      },
+    })
+    const user = userEvent.setup()
+    show({ hasMore: true })
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    await user.click(screen.getByRole('button', { name: 'Show older updates' }))
+
+    expect(loadMoreWhatsNew).toHaveBeenCalledWith(ENTRIES.length)
+    expect(await screen.findByText('Closed days and staff leave')).toBeInTheDocument()
+    expect(screen.getByText('Appointments on the calendar')).toBeInTheDocument()
+    expect(screen.queryByRole('button', { name: 'Show older updates' })).not.toBeInTheDocument()
+  })
+
+  it('keeps the button after a page fails, and a second press brings the page', async () => {
+    loadMoreWhatsNew.mockRejectedValueOnce(new Error('offline'))
+    loadMoreWhatsNew.mockResolvedValueOnce({
+      success: true,
+      data: {
+        entries: [{ ...ENTRIES[1], id: 'closures', title: 'Closed days and staff leave' }],
+        hasMore: false,
+      },
+    })
+    const user = userEvent.setup()
+    show({ hasMore: true })
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    await user.click(screen.getByRole('button', { name: 'Show older updates' }))
+
+    const again = await screen.findByRole('button', { name: 'Show older updates' })
+    await waitFor(() => expect(again).toBeEnabled())
+    await user.click(again)
+    expect(await screen.findByText('Closed days and staff leave')).toBeInTheDocument()
+  })
+})
+
+describe("a video on a What's new entry", () => {
+  it('links to YouTube from the entry that has one, and only that one', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    const links = screen.getAllByRole('link', { name: 'Watch the video' })
+    expect(links).toHaveLength(1)
+    expect(links[0]).toHaveAttribute('href', 'https://youtu.be/abc123')
+    expect(links[0]).toHaveAttribute('target', '_blank')
+  })
+
+  it('shows no video link for an address that is not YouTube', async () => {
+    const user = userEvent.setup()
+    show({
+      entries: [{ ...ENTRIES[0], videoUrl: 'https://youtube.com.evil.example/watch' }],
+      unread: [],
+    })
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    expect(screen.queryByRole('link', { name: 'Watch the video' })).not.toBeInTheDocument()
+  })
+})

+ 10 - 4
src/app/(authenticated)/layout.tsx

@@ -60,7 +60,9 @@ import { SaveShortcutListener } from '@/components/save-shortcut-listener'
 import { AnalyticsIdentity } from '@/components/analytics-identity'
 import { WhatsNewProvider } from '@/features/whats-new/Components/WhatsNewProvider'
 import { getWhatsNewFeed } from '@/features/whats-new/Lib/feed.server'
+import { WHATS_NEW_PAGE_SIZE } from '@/features/whats-new/Lib/paging'
 import { selectEntries, unreadIds } from '@/features/whats-new/Lib/select'
+import { isWhatsNewTabHidden, WHATS_NEW_TAB_COOKIE } from '@/features/whats-new/Lib/tabPreference'
 
 export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
   const data = await getLayoutData()
@@ -177,12 +179,12 @@ export default async function DashboardLayout({ children }: { children: React.Re
   }
 
   // The highlights written on torqvoice.com, narrowed to what this person can
-  // actually open on this install. A licensed self-hosted install runs under
-  // the workshop's own name, and news signed by Torqvoice has no place there.
+  // actually open. Torqvoice Cloud only: a self-hosted install is the
+  // workshop's own, and news from us has no place appearing inside it.
   const appVersion = process.env.APP_VERSION || 'development'
   const releaseNotesUrl =
     process.env.RELEASE_NOTES_URL || 'https://github.com/Torqvoice/torqvoice/releases'
-  const whatsNewHidden = !isCloudMode() && features.brandingRemoved
+  const whatsNewHidden = !isCloudMode()
   const whatsNewEntries = whatsNewHidden
     ? []
     : selectEntries(await getWhatsNewFeed(await getLocale()), {
@@ -190,6 +192,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
         cloud: isCloudMode(),
         visibleSubjects,
       })
+  const whatsNewTabHidden = isWhatsNewTabHidden((await cookies()).get(WHATS_NEW_TAB_COOKIE)?.value)
   const whatsNewUnread = unreadIds(whatsNewEntries, {
     seenAt: data.whatsNewSeenAt,
     accountCreatedAt: data.userCreatedAt,
@@ -332,8 +335,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
           >
             <WhiteLabelCtaProvider show={showWhiteLabelCta}>
               <WhatsNewProvider
-                entries={whatsNewEntries}
+                // The first page only; the panel asks for older ones as it is scrolled.
+                entries={whatsNewEntries.slice(0, WHATS_NEW_PAGE_SIZE)}
+                hasMore={whatsNewEntries.length > WHATS_NEW_PAGE_SIZE}
                 unreadIds={whatsNewUnread}
+                initialTabHidden={whatsNewTabHidden}
                 timeZone={safeTimeZone(data.timezone)}
                 appVersion={appVersion}
                 releaseNotesUrl={releaseNotesUrl}

+ 2 - 0
src/app/(authenticated)/settings/about/page.tsx

@@ -3,6 +3,7 @@ import Link from 'next/link'
 import { getTranslations } from 'next-intl/server'
 import { Separator } from '@/components/ui/separator'
 import { ExternalLink, Info } from 'lucide-react'
+import { WhatsNewAboutRows } from '@/features/whats-new/Components/WhatsNewAboutRows'
 
 export default async function AboutSettingsPage() {
   const t = await getTranslations('settings')
@@ -45,6 +46,7 @@ export default async function AboutSettingsPage() {
               </Link>
             </div>
           ))}
+          <WhatsNewAboutRows />
         </div>
       </AppCard>
     </div>

+ 2 - 0
src/components/app-sidebar.tsx

@@ -87,6 +87,7 @@ import {
   NotificationPanel,
 } from '@/features/notifications/Components/NotificationPanel'
 import { InstallMenuItem } from '@/components/pwa-install-prompt'
+import { WhatsNewMenuItem } from '@/features/whats-new/Components/WhatsNewMenuItem'
 import { FullscreenLauncher, FullscreenMenuItem } from '@/components/fullscreen-toggle'
 import { FeatureHint } from '@/components/feature-hint'
 import { ANNOUNCEMENTS } from '@/features/settings/Lib/featureHints'
@@ -736,6 +737,7 @@ export function AppSidebar({
                 {/* Device conveniences, out of the navigation where they
                     used to pass for pages. Each renders nothing where it
                     does not apply. */}
+                <WhatsNewMenuItem />
                 <InstallMenuItem />
                 <FullscreenMenuItem />
                 <DropdownMenuSeparator />

+ 0 - 2
src/components/page-header.tsx

@@ -22,7 +22,6 @@ import { useLicenseExpiry } from '@/components/license-expiry-context'
 import { BANNER_PRIORITY, useBannerSlot } from '@/components/banner-slot'
 import { QuickCreateMenu } from '@/components/quick-create-menu'
 import { DocsLink } from '@/components/docs-link'
-import { WhatsNewButton } from '@/features/whats-new/Components/WhatsNewButton'
 import { RunningClockPill } from '@/features/time-tracking/Components/RunningClockPill'
 import { useServiceType } from '@/components/service-type-context'
 import { SETTINGS_PAGES } from '@/features/settings/Lib/settingsPages'
@@ -346,7 +345,6 @@ export function PageHeader() {
           <RunningClockPill />
           <PlateTrigger />
           <SearchTrigger />
-          <WhatsNewButton />
           <QuickCreateMenu />
           {showWhiteLabelCta && (
             <Button asChild variant="outline" size="sm" className="hidden sm:inline-flex">

+ 52 - 0
src/features/whats-new/Actions/loadMoreWhatsNew.ts

@@ -0,0 +1,52 @@
+'use server'
+
+import { getLocale } from 'next-intl/server'
+import { z } from 'zod'
+import { getCachedMembership } from '@/lib/cached-session'
+import { isCloudMode } from '@/lib/features'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { getWhatsNewFeed } from '../Lib/feed.server'
+import { WHATS_NEW_PAGE_SIZE } from '../Lib/paging'
+import { selectEntries } from '../Lib/select'
+
+/**
+ * The screens this person can open, by the same rule the signed-in layout
+ * uses for the sidebar: owners and admins see everything, a member sees what
+ * their role can read, and a member without a role sees nothing.
+ */
+async function readableSubjects(userId: string, role: string): Promise<string[]> {
+  const all = Object.values(PermissionSubject)
+  if (role === 'owner' || role === 'admin' || role === 'super_admin') return all
+  const membership = await getCachedMembership(userId)
+  if (!membership?.roleId) return []
+  const permissions = membership.customRole?.permissions ?? []
+  return all.filter((subject) =>
+    hasPermission(permissions, { action: PermissionAction.READ, subject })
+  )
+}
+
+/**
+ * The next page of highlights for the panel, from where the reader has
+ * scrolled to. Read from the list this server already holds, so scrolling
+ * never sends anything to torqvoice.com, and narrowed for this person
+ * exactly as the first page was.
+ */
+export async function loadMoreWhatsNew(offset: unknown) {
+  return withAuth(
+    async ({ userId, role }) => {
+      const from = z.number().int().min(0).max(1000).parse(offset)
+      if (!isCloudMode()) return { entries: [], hasMore: false }
+      const all = selectEntries(await getWhatsNewFeed(await getLocale()), {
+        appVersion: process.env.APP_VERSION || 'development',
+        cloud: true,
+        visibleSubjects: await readableSubjects(userId, role),
+      })
+      return {
+        entries: all.slice(from, from + WHATS_NEW_PAGE_SIZE),
+        hasMore: all.length > from + WHATS_NEW_PAGE_SIZE,
+      }
+    },
+    { allowDuringRestore: true }
+  )
+}

+ 41 - 0
src/features/whats-new/Components/WhatsNewAboutRows.tsx

@@ -0,0 +1,41 @@
+'use client'
+
+import { useId } from 'react'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/ui/button'
+import { Switch } from '@/components/ui/switch'
+import { useWhatsNew } from './WhatsNewProvider'
+
+/**
+ * The "What's new" rows on Settings, About: a button that opens the panel,
+ * and the switch for the tab on the edge of the screen, which is where
+ * somebody who hid the tab comes to get it back. Nothing is drawn when there
+ * is no news to open, because there is no tab to switch either.
+ */
+export function WhatsNewAboutRows() {
+  const t = useTranslations('settings.about')
+  const switchId = useId()
+  const { available, open, tabHidden, setTabHidden } = useWhatsNew()
+  if (!available) return null
+  return (
+    <>
+      <div id="about-whats-new" className="flex items-center justify-between gap-4">
+        <span className="text-sm text-muted-foreground">{t('whatsNew')}</span>
+        <Button type="button" variant="outline" size="sm" onClick={open}>
+          {t('whatsNewOpen')}
+        </Button>
+      </div>
+      <div className="hidden items-center justify-between gap-4 md:flex">
+        <label htmlFor={switchId} className="flex flex-col text-sm text-muted-foreground">
+          {t('whatsNewTab')}
+          <span className="text-xs">{t('whatsNewTabHint')}</span>
+        </label>
+        <Switch
+          id={switchId}
+          checked={!tabHidden}
+          onCheckedChange={(checked) => setTabHidden(!checked)}
+        />
+      </div>
+    </>
+  )
+}

+ 0 - 37
src/features/whats-new/Components/WhatsNewButton.tsx

@@ -1,37 +0,0 @@
-'use client'
-
-import { Gift } from 'lucide-react'
-import { useTranslations } from 'next-intl'
-import { useWhatsNew } from './WhatsNewProvider'
-
-/**
- * The header's way into the "What's new" panel.
- *
- * A dot, not a number: the count pills in the sidebar mean work waiting on
- * somebody, and three unread announcements are not that. Renders nothing when
- * there is no news to open, so the header never offers an empty panel.
- */
-export function WhatsNewButton() {
-  const t = useTranslations('common.whatsNew')
-  const { available, hasUnread, open } = useWhatsNew()
-  if (!available) return null
-
-  const label = hasUnread ? t('openUnread') : t('title')
-  return (
-    <button
-      type="button"
-      onClick={open}
-      aria-label={label}
-      title={label}
-      className="relative flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
-    >
-      <Gift className="h-4 w-4" />
-      {hasUnread && (
-        <span
-          aria-hidden
-          className="absolute right-1 top-1 size-2 rounded-full bg-primary ring-2 ring-background"
-        />
-      )}
-    </button>
-  )
-}

+ 21 - 19
src/features/whats-new/Components/WhatsNewFeedback.tsx

@@ -34,9 +34,10 @@ function rememberVote(entryId: string, vote: Vote) {
 /**
  * "Was this useful?" under a highlight.
  *
- * The thumb is the whole answer and is sent the moment it is pressed. The
- * comment box that opens under it is an invitation, not a second step: most
- * people will not type anything, and their vote must not depend on it.
+ * The thumb is the whole answer and is sent the moment it is pressed. A thumb
+ * down opens a comment box under it, as an invitation, not a second step:
+ * most people will not type anything, and their vote must not depend on it.
+ * A thumb up opens nothing.
  *
  * A bare thumb names nobody, anywhere. Only a comment from the cloud carries
  * the sender's email, and the line under the box says so before Send.
@@ -68,7 +69,8 @@ export function WhatsNewFeedback({
     setBusy(true)
     const previous = vote
     setVote(next)
-    setWriting(true)
+    // A thumb up is the whole answer. Only a thumb down asks what went wrong.
+    setWriting(next === 'down')
     setCommentSent(false)
     const result = await sendWhatsNewFeedback({ entryId, vote: next })
     setBusy(false)
@@ -99,14 +101,23 @@ export function WhatsNewFeedback({
     cn(
       'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
       vote === which
-        ? 'bg-primary/15 text-foreground'
+        ? 'bg-primary text-primary-foreground'
         : 'text-muted-foreground hover:bg-muted hover:text-foreground'
     )
 
   return (
-    <div className="flex flex-col gap-2 pt-1">
+    <div className="flex flex-col gap-2">
       <div className="flex items-center gap-1">
-        <span className="mr-1 text-xs text-muted-foreground">{t('question')}</span>
+        <span className="mr-auto flex items-center gap-2 text-xs text-muted-foreground">
+          {commentSent ? (
+            <span className="flex items-center gap-1" role="status">
+              <Check className="h-3.5 w-3.5 text-primary" />
+              {t('thanks')}
+            </span>
+          ) : (
+            t('question')
+          )}
+        </span>
         <button
           type="button"
           onClick={() => cast('up')}
@@ -129,20 +140,11 @@ export function WhatsNewFeedback({
         >
           <ThumbsDown className="h-3.5 w-3.5" />
         </button>
-        {commentSent && (
-          <span
-            className="ml-1 flex items-center gap-1 text-xs text-muted-foreground"
-            role="status"
-          >
-            <Check className="h-3.5 w-3.5" />
-            {t('thanks')}
-          </span>
-        )}
       </div>
-      {writing && vote && (
-        <div className="flex flex-col gap-2 rounded-lg border bg-muted/30 p-3">
+      {writing && vote === 'down' && (
+        <div className="flex flex-col gap-2 pb-1.5 pt-1">
           <label htmlFor={commentId} className="text-xs font-medium">
-            {vote === 'down' ? t('askDown') : t('askUp')}
+            {t('askDown')}
           </label>
           <Textarea
             id={commentId}

+ 42 - 0
src/features/whats-new/Components/WhatsNewMenuItem.tsx

@@ -0,0 +1,42 @@
+'use client'
+
+import { Megaphone, PanelRightOpen } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
+import { useWhatsNew } from './WhatsNewProvider'
+
+/**
+ * "What's new" in the account menu: the link that is there on every screen
+ * size, with or without the tab, and under it the way to bring a hidden tab
+ * back. Renders nothing when there is no news to open.
+ */
+export function WhatsNewMenuItem() {
+  const t = useTranslations('common.whatsNew')
+  const tAbout = useTranslations('settings.about')
+  const { available, hasUnread, open, tabHidden, setTabHidden } = useWhatsNew()
+  if (!available) return null
+  return (
+    <>
+      <DropdownMenuItem onClick={open}>
+        <Megaphone className="mr-2 size-4" />
+        {t('title')}
+        {hasUnread && (
+          <span
+            role="img"
+            aria-label={t('unread')}
+            className="ml-auto size-2 rounded-full bg-primary"
+          />
+        )}
+      </DropdownMenuItem>
+      {/* The way back for somebody who hid the tab and whose role cannot
+          open Settings, where the switch for it is. Larger screens only,
+          like the tab itself. */}
+      {tabHidden && (
+        <DropdownMenuItem className="hidden md:flex" onClick={() => setTabHidden(false)}>
+          <PanelRightOpen className="mr-2 size-4 shrink-0" />
+          {tAbout('whatsNewTab')}
+        </DropdownMenuItem>
+      )}
+    </>
+  )
+}

+ 189 - 60
src/features/whats-new/Components/WhatsNewPanel.tsx

@@ -1,7 +1,8 @@
 'use client'
 
+import { useEffect, useRef } from 'react'
 import Link from 'next/link'
-import { ArrowRight } from 'lucide-react'
+import { ArrowRight, Loader2 } from 'lucide-react'
 import { useFormatter, useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
 import {
@@ -26,6 +27,32 @@ function docsUrl(href: string | null): string | null {
   return href?.startsWith('/docs/') ? `${MARKETING_URL}${href}` : null
 }
 
+const YOUTUBE_HOSTS = ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be']
+
+/** An https address on YouTube, checked here too: the feed is another server's word. */
+function youtubeUrl(href: string | null): string | null {
+  if (!href) return null
+  try {
+    const url = new URL(href)
+    return url.protocol === 'https:' && YOUTUBE_HOSTS.includes(url.hostname) ? url.href : null
+  } catch {
+    return null
+  }
+}
+
+/** YouTube's own mark, drawn from its path: the icon set has dropped brand icons. */
+function YouTubeMark({ className }: { className?: string }) {
+  return (
+    <svg viewBox="0 0 24 24" aria-hidden className={className}>
+      <path d="M6 7h12v10H6z" fill="#fff" />
+      <path
+        fill="#f00"
+        d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
+      />
+    </svg>
+  )
+}
+
 const BODY_STYLES = cn(
   'text-sm leading-relaxed text-foreground/80',
   '[&_p+p]:mt-2 [&_p]:m-0',
@@ -54,24 +81,30 @@ function Entry({
   const format = useFormatter()
   const cta = appPath(entry.ctaHref)
   const guide = docsUrl(entry.docsHref)
+  const video = youtubeUrl(entry.videoUrl)
 
   return (
-    <article className="flex flex-col gap-2.5 border-b px-6 py-5 last:border-b-0">
-      <div className="flex items-center gap-2">
-        {marked && (
-          <span className="size-1.5 rounded-full bg-primary" role="img" aria-label={t('unread')} />
+    <li className="group relative pb-7 pl-7 last:pb-0">
+      {/* The line that runs from this entry down to the next one. */}
+      <span
+        aria-hidden
+        className="absolute bottom-0 left-[5px] top-4 w-px bg-primary/25 group-last:hidden"
+      />
+      {/* Its point on the line: filled while the entry is news to the reader. */}
+      <span
+        role={marked ? 'img' : undefined}
+        aria-label={marked ? t('unread') : undefined}
+        aria-hidden={marked ? undefined : true}
+        className={cn(
+          'absolute left-0 top-[3px] size-[11px] rounded-full border-2 border-primary',
+          marked ? 'bg-primary ring-4 ring-primary/20' : 'bg-background'
         )}
-        <span
-          className={cn(
-            'rounded px-1.5 py-0.5 text-[10.5px] font-semibold uppercase tracking-wider',
-            entry.kind === 'new'
-              ? 'bg-primary/15 text-foreground'
-              : 'bg-muted text-muted-foreground'
-          )}
+      />
+      <div className="flex items-center gap-2">
+        <time
+          dateTime={entry.publishedAt}
+          className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
         >
-          {entry.kind === 'new' ? t('kindNew') : t('kindImproved')}
-        </span>
-        <time dateTime={entry.publishedAt} className="text-xs text-muted-foreground">
           {/* The workshop's calendar day, like every other date in the app. */}
           {format.dateTime(new Date(entry.publishedAt), {
             day: 'numeric',
@@ -80,42 +113,81 @@ function Entry({
             timeZone,
           })}
         </time>
+        <span
+          className={cn(
+            'rounded-full px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-wider',
+            entry.kind === 'new'
+              ? 'bg-primary text-primary-foreground'
+              : 'border border-primary/40 text-foreground'
+          )}
+        >
+          {entry.kind === 'new' ? t('kindNew') : t('kindImproved')}
+        </span>
       </div>
-      <h3 className="text-[15px] font-semibold leading-snug">{entry.title}</h3>
-      {entry.imageUrl && (
-        <img
-          src={entry.imageUrl}
-          alt=""
-          loading="lazy"
-          className="w-full rounded-lg border bg-muted object-cover"
+      <article className="mt-2.5 overflow-hidden rounded-xl border bg-card shadow-xs">
+        {/* The card hairline the rest of the app uses: primary, gone by the far edge. */}
+        <div
+          aria-hidden
+          className="h-0.5 bg-linear-to-r from-primary via-primary/40 to-transparent"
         />
-      )}
-      {/* Sanitized on the server by Lib/sanitizeBody before it reaches here. */}
-      <div className={BODY_STYLES} dangerouslySetInnerHTML={{ __html: entry.body }} />
-      {(cta || guide) && (
-        <div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-0.5">
-          {cta && (
-            <Button asChild variant="outline" size="sm">
-              <Link href={cta} onClick={onNavigate}>
-                {entry.ctaLabel || t('open')}
-                <ArrowRight className="h-3.5 w-3.5" />
-              </Link>
-            </Button>
+        <div className="flex flex-col gap-2.5 p-4">
+          <h3 className="text-base font-semibold leading-snug">{entry.title}</h3>
+          {entry.imageUrl && (
+            <img
+              src={entry.imageUrl}
+              alt=""
+              loading="lazy"
+              className="w-full rounded-lg border bg-muted object-cover"
+            />
           )}
-          {guide && (
-            <a
-              href={guide}
-              target="_blank"
-              rel="noopener noreferrer"
-              className="rounded text-sm font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
-            >
-              {t('guide')}
-            </a>
+          {/* Sanitized on the server by Lib/sanitizeBody before it reaches here. */}
+          <div className={BODY_STYLES} dangerouslySetInnerHTML={{ __html: entry.body }} />
+          {(cta || guide || video) && (
+            <div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-1">
+              {cta && (
+                <Button
+                  asChild
+                  variant="outline"
+                  size="sm"
+                  className="border-primary/35 bg-primary/10 hover:bg-primary/15"
+                >
+                  <Link href={cta} onClick={onNavigate}>
+                    {entry.ctaLabel || t('open')}
+                    <ArrowRight className="h-3.5 w-3.5" />
+                  </Link>
+                </Button>
+              )}
+              {video && (
+                <a
+                  href={video}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="flex items-center gap-1.5 rounded text-sm font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+                >
+                  <YouTubeMark className="h-4 w-4 shrink-0" />
+                  {t('video')}
+                </a>
+              )}
+              {guide && (
+                <a
+                  href={guide}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="rounded text-sm font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+                >
+                  {t('guide')}
+                </a>
+              )}
+            </div>
           )}
         </div>
-      )}
-      {feedback && <WhatsNewFeedback entryId={entry.id} identified={feedback === 'identified'} />}
-    </article>
+        {feedback && (
+          <div className="border-t bg-muted/40 px-4 py-2">
+            <WhatsNewFeedback entryId={entry.id} identified={feedback === 'identified'} />
+          </div>
+        )}
+      </article>
+    </li>
   )
 }
 
@@ -130,6 +202,10 @@ export function WhatsNewPanel({
   open,
   onOpenChange,
   entries,
+  hasMore,
+  loadingMore,
+  autoLoad = true,
+  onLoadMore,
   markedIds,
   timeZone,
   appVersion,
@@ -139,6 +215,12 @@ export function WhatsNewPanel({
   open: boolean
   onOpenChange: (open: boolean) => void
   entries: WhatsNewEntry[]
+  /** Whether older entries exist beyond the ones given. */
+  hasMore: boolean
+  loadingMore: boolean
+  /** False after a page failed: only the button asks again, not scrolling. */
+  autoLoad?: boolean
+  onLoadMore: () => void
   markedIds: ReadonlySet<string>
   /** The workshop's time zone, for the day an entry was published. */
   timeZone: string
@@ -148,26 +230,73 @@ export function WhatsNewPanel({
 }) {
   const t = useTranslations('common.whatsNew')
 
+  // Older entries load as the end of the list scrolls into view. The button
+  // under the list does the same thing, for a keyboard and for a browser
+  // without the observer.
+  const scroller = useRef<HTMLDivElement>(null)
+  const sentinel = useRef<HTMLDivElement>(null)
+  useEffect(() => {
+    const target = sentinel.current
+    if (!open || !hasMore || !autoLoad || !target || typeof IntersectionObserver === 'undefined')
+      return
+    const observer = new IntersectionObserver(
+      (hits) => {
+        if (hits.some((hit) => hit.isIntersecting)) onLoadMore()
+      },
+      { root: scroller.current, rootMargin: '200px' }
+    )
+    observer.observe(target)
+    return () => observer.disconnect()
+  }, [open, hasMore, autoLoad, onLoadMore])
+
   return (
     <Sheet open={open} onOpenChange={onOpenChange}>
       <SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-md">
-        <SheetHeader className="border-b px-6 py-5">
-          <SheetTitle className="text-lg">{t('title')}</SheetTitle>
+        <SheetHeader className="relative gap-1 bg-linear-to-b from-primary/15 to-transparent px-6 pb-5 pt-6">
+          <div className="flex items-center gap-2.5">
+            <SheetTitle className="text-xl">{t('title')}</SheetTitle>
+            {markedIds.size > 0 && (
+              <span className="rounded-full bg-primary px-2 py-0.5 text-xs font-semibold text-primary-foreground">
+                {t('newCount', { count: markedIds.size })}
+              </span>
+            )}
+          </div>
           <SheetDescription>{t('description')}</SheetDescription>
+          <span
+            aria-hidden
+            className="absolute inset-x-0 bottom-0 h-px bg-linear-to-r from-primary via-primary/35 to-transparent"
+          />
         </SheetHeader>
-        <div className="min-h-0 flex-1 overflow-y-auto">
-          {entries.map((entry) => (
-            <Entry
-              key={entry.id}
-              entry={entry}
-              marked={markedIds.has(entry.id)}
-              timeZone={timeZone}
-              feedback={feedback}
-              onNavigate={() => onOpenChange(false)}
-            />
-          ))}
+        <div ref={scroller} className="min-h-0 flex-1 overflow-y-auto bg-muted/30">
+          <ol className="px-6 py-6">
+            {entries.map((entry) => (
+              <Entry
+                key={entry.id}
+                entry={entry}
+                marked={markedIds.has(entry.id)}
+                timeZone={timeZone}
+                feedback={feedback}
+                onNavigate={() => onOpenChange(false)}
+              />
+            ))}
+          </ol>
+          {hasMore && (
+            <div ref={sentinel} className="flex justify-center px-6 pb-6">
+              <Button
+                type="button"
+                variant="ghost"
+                size="sm"
+                onClick={onLoadMore}
+                disabled={loadingMore}
+                className="text-muted-foreground"
+              >
+                {loadingMore && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
+                {t('showOlder')}
+              </Button>
+            </div>
+          )}
         </div>
-        <div className="flex items-center justify-between gap-4 border-t bg-muted/40 px-6 py-3 text-sm">
+        <div className="flex items-center justify-between gap-4 border-t px-6 py-3 text-sm">
           <span className="text-muted-foreground">
             {appVersion === 'development' ? appVersion : t('version', { version: appVersion })}
           </span>

+ 107 - 8
src/features/whats-new/Components/WhatsNewProvider.tsx

@@ -1,16 +1,30 @@
 'use client'
 
-import { createContext, useCallback, useContext, useMemo, useState } from 'react'
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
+import { useLocale, useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { loadMoreWhatsNew } from '../Actions/loadMoreWhatsNew'
 import { markWhatsNewSeen } from '../Actions/markWhatsNewSeen'
+import { rememberWhatsNewTabHidden } from '../Lib/tabPreference'
 import type { WhatsNewEntry } from '../Schema/whatsNewSchema'
 import { WhatsNewPanel } from './WhatsNewPanel'
+import { WhatsNewTab } from './WhatsNewTab'
 
 const Context = createContext<{
   /** False when there is nothing to show: the feed is empty, hidden or unreachable. */
   available: boolean
   hasUnread: boolean
   open: () => void
-}>({ available: false, hasUnread: false, open: () => undefined })
+  /** Whether this browser has sent the tab on the edge of the screen away. */
+  tabHidden: boolean
+  setTabHidden: (hidden: boolean) => void
+}>({
+  available: false,
+  hasUnread: false,
+  open: () => undefined,
+  tabHidden: false,
+  setTabHidden: () => undefined,
+})
 
 export function useWhatsNew() {
   return useContext(Context)
@@ -19,23 +33,30 @@ export function useWhatsNew() {
 /**
  * Holds the "What's new" panel for the signed-in app.
  *
- * One panel, here, rather than one per button: the header button and the
- * update banner's link both open it, and they sit in different parts of the
- * layout. The entries arrive from the server with the page, so opening the
+ * One panel, here, rather than one per button: the tab on the edge of the
+ * screen, the account menu and the update banner's link all open it, and
+ * they sit in different parts of the layout. The entries arrive from the server with the page, so opening the
  * panel never waits on the network.
  */
 export function WhatsNewProvider({
   entries,
+  hasMore: initialHasMore = false,
   unreadIds,
+  initialTabHidden = false,
   timeZone,
   appVersion,
   releaseNotesUrl,
   feedback,
   children,
 }: {
+  /** The newest highlights: the first page, sent with the page itself. */
   entries: WhatsNewEntry[]
+  /** Whether there are older ones the panel can ask for. */
+  hasMore?: boolean
   /** Entries published since this person last opened the panel. */
   unreadIds: string[]
+  /** This browser's remembered choice, read from its cookie on the server. */
+  initialTabHidden?: boolean
   timeZone: string
   appVersion: string
   releaseNotesUrl: string
@@ -47,7 +68,13 @@ export function WhatsNewProvider({
   feedback: 'identified' | 'anonymous' | null
   children: React.ReactNode
 }) {
+  const t = useTranslations('common.whatsNew')
   const [isOpen, setIsOpen] = useState(false)
+  const [tabHidden, setTabHiddenState] = useState(initialTabHidden)
+  const setTabHidden = useCallback((hidden: boolean) => {
+    setTabHiddenState(hidden)
+    rememberWhatsNewTabHidden(hidden)
+  }, [])
   // What has been acknowledged in this tab. The server's list catches up on
   // the next navigation; until then this is what clears the dot at once.
   const [acknowledged, setAcknowledged] = useState<ReadonlySet<string>>(new Set())
@@ -55,6 +82,57 @@ export function WhatsNewProvider({
   // their marker while it is being read, which is the whole point of one.
   const [marked, setMarked] = useState<ReadonlySet<string>>(new Set())
 
+  // Older entries the reader has scrolled to. Thrown away when the first
+  // page changes, because offsets into the old list no longer mean anything,
+  // and when the language does, because they were written in the old one.
+  const locale = useLocale()
+  const firstPageKey = `${locale}:${entries.map((entry) => entry.id).join(',')}`
+  const [older, setOlder] = useState<WhatsNewEntry[]>([])
+  const [hasMore, setHasMore] = useState(initialHasMore)
+  const [loadingMore, setLoadingMore] = useState(false)
+  // A page that could not be fetched. Scrolling stops asking until the
+  // button is pressed, so a server that is down is not asked in a loop.
+  const [loadFailed, setLoadFailed] = useState(false)
+  // Which first page a request belongs to. An answer that comes back after
+  // the list was replaced is for a list that is no longer on screen.
+  const generation = useRef(0)
+  const inFlight = useRef(false)
+  useEffect(() => {
+    generation.current += 1
+    inFlight.current = false
+    setOlder([])
+    setHasMore(initialHasMore)
+    setLoadingMore(false)
+    setLoadFailed(false)
+  }, [firstPageKey, initialHasMore])
+
+  const loadMore = useCallback(async () => {
+    if (inFlight.current || !hasMore) return
+    const asked = generation.current
+    inFlight.current = true
+    setLoadingMore(true)
+    let page: { entries: WhatsNewEntry[]; hasMore: boolean } | null = null
+    try {
+      const result = await loadMoreWhatsNew(entries.length + older.length)
+      if (result.success && result.data) page = result.data
+    } catch {
+      // The network, or a deploy in between. Handled as a failed page below.
+    }
+    if (asked !== generation.current) return
+    inFlight.current = false
+    setLoadingMore(false)
+    if (!page) {
+      setLoadFailed(true)
+      return
+    }
+    setLoadFailed(false)
+    const known = new Set([...entries, ...older].map((entry) => entry.id))
+    const fresh = page.entries.filter((entry) => !known.has(entry.id))
+    setOlder((current) => [...current, ...fresh])
+    // Nothing new came back: stop asking rather than ask forever.
+    setHasMore(page.hasMore && fresh.length > 0)
+  }, [hasMore, entries, older])
+
   const pending = useMemo(
     () => unreadIds.filter((id) => !acknowledged.has(id)),
     [unreadIds, acknowledged]
@@ -77,18 +155,39 @@ export function WhatsNewProvider({
   }, [pending])
 
   const value = useMemo(
-    () => ({ available: entries.length > 0, hasUnread: pending.length > 0, open }),
-    [entries.length, pending.length, open]
+    () => ({
+      available: entries.length > 0,
+      hasUnread: pending.length > 0,
+      open,
+      tabHidden,
+      setTabHidden,
+    }),
+    [entries.length, pending.length, open, tabHidden, setTabHidden]
   )
 
   return (
     <Context.Provider value={value}>
       {children}
+      {entries.length > 0 && !tabHidden && (
+        <WhatsNewTab
+          hasUnread={pending.length > 0}
+          onOpen={open}
+          onHide={() => {
+            setTabHidden(true)
+            // Said once, at the moment it goes, so the way back is known.
+            toast(t('tabHidden'))
+          }}
+        />
+      )}
       {entries.length > 0 && (
         <WhatsNewPanel
           open={isOpen}
           onOpenChange={setIsOpen}
-          entries={entries}
+          entries={older.length > 0 ? [...entries, ...older] : entries}
+          hasMore={hasMore}
+          loadingMore={loadingMore}
+          autoLoad={!loadFailed}
+          onLoadMore={loadMore}
           markedIds={marked}
           timeZone={timeZone}
           appVersion={appVersion}

+ 71 - 0
src/features/whats-new/Components/WhatsNewTab.tsx

@@ -0,0 +1,71 @@
+'use client'
+
+import { X } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+
+/**
+ * The way into the "What's new" panel: a tab on the right edge of the
+ * screen, written sideways, where the panel itself comes out from.
+ *
+ * A word, not an icon. An icon in the header went unnoticed, because nothing
+ * about it said what it was; a tab that says "What's new" needs no learning.
+ * It is always there, loud while something is unseen and quiet once it has
+ * been read, and it carries its own way out for people who would rather not
+ * have it. Desktop only: on a phone the edge of the screen is the edge of
+ * the content, and the account menu has the same link.
+ */
+export function WhatsNewTab({
+  hasUnread,
+  onOpen,
+  onHide,
+}: {
+  hasUnread: boolean
+  onOpen: () => void
+  onHide: () => void
+}) {
+  const t = useTranslations('common.whatsNew')
+  return (
+    <div
+      className={cn(
+        'fixed right-0 top-1/2 z-40 hidden -translate-y-1/2 flex-col items-center overflow-hidden rounded-l-lg border border-r-0 shadow-md md:flex print:hidden',
+        hasUnread
+          ? 'border-primary bg-primary text-primary-foreground'
+          : 'bg-card text-foreground/80'
+      )}
+    >
+      <button
+        type="button"
+        onClick={onOpen}
+        aria-label={hasUnread ? t('openUnread') : t('title')}
+        className={cn(
+          'flex cursor-pointer items-center gap-2.5 px-2 pb-3.5 pt-4 text-[13px] font-semibold tracking-wide transition-colors [writing-mode:vertical-rl] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
+          hasUnread ? 'hover:bg-primary-foreground/10' : 'hover:bg-muted hover:text-foreground'
+        )}
+      >
+        {hasUnread && (
+          <span aria-hidden className="relative flex size-2">
+            <span className="absolute inline-flex size-full animate-ping rounded-full bg-primary-foreground/70 motion-reduce:hidden" />
+            <span className="relative inline-flex size-2 rounded-full bg-primary-foreground" />
+          </span>
+        )}
+        {/* Read from the bottom up, the way a label on a spine is. */}
+        <span className="rotate-180">{t('title')}</span>
+      </button>
+      <button
+        type="button"
+        onClick={onHide}
+        aria-label={t('hideTab')}
+        title={t('hideTab')}
+        className={cn(
+          'flex h-7 w-full cursor-pointer items-center justify-center border-t transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
+          hasUnread
+            ? 'border-primary-foreground/25 text-primary-foreground/80 hover:bg-primary-foreground/10 hover:text-primary-foreground'
+            : 'hover:bg-muted hover:text-foreground'
+        )}
+      >
+        <X className="size-3.5" />
+      </button>
+    </div>
+  )
+}

+ 13 - 1
src/features/whats-new/Lib/feed.server.ts

@@ -45,7 +45,13 @@ async function load(locale: string): Promise<WhatsNewEntry[]> {
 function refresh(locale: string, previous: WhatsNewEntry[]): Promise<void> {
   return load(locale)
     .then((entries) => {
-      cache.set(locale, { entries, expiresAt: Date.now() + FRESH_MS })
+      // An empty list is checked again soon: the first entry ever published,
+      // or the first one tried on staging, should not take ten minutes to
+      // turn up.
+      cache.set(locale, {
+        entries,
+        expiresAt: Date.now() + (entries.length > 0 ? FRESH_MS : RETRY_MS),
+      })
     })
     .catch(() => {
       // torqvoice.com is unreachable, or an install has no way out to the
@@ -82,6 +88,12 @@ export async function getWhatsNewFeed(locale: string): Promise<WhatsNewEntry[]>
   }
   if (Date.now() >= cached.expiresAt && !cached.refreshing) {
     cached.refreshing = refresh(locale, cached.entries)
+    // Nothing worth showing meanwhile, so wait for the answer rather than
+    // hand back the empty list one more time.
+    if (cached.entries.length === 0) {
+      await cached.refreshing
+      return cache.get(locale)?.entries ?? []
+    }
   }
   return cached.entries
 }

+ 6 - 0
src/features/whats-new/Lib/paging.ts

@@ -0,0 +1,6 @@
+/**
+ * How many highlights the panel is given at a time. The first ten arrive
+ * with the page; older ones are asked for as the reader scrolls to them, so
+ * a long history costs nothing to the people who never open the panel.
+ */
+export const WHATS_NEW_PAGE_SIZE = 10

+ 23 - 0
src/features/whats-new/Lib/tabPreference.ts

@@ -0,0 +1,23 @@
+/**
+ * Whether this browser shows the "What's new" tab on the edge of the screen.
+ *
+ * The tab is an invitation, and somebody who does not want it should be able
+ * to send it away for good: news stays reachable from the account menu and
+ * from Settings, About, where the tab can also be switched back on.
+ *
+ * A cookie, like the other remembered choices: the signed-in layout is
+ * rendered on the server, and reading the choice there means a hidden tab
+ * never flashes up before the browser has had its say.
+ */
+export const WHATS_NEW_TAB_COOKIE = 'whatsNewTab'
+
+const MAX_AGE = 60 * 60 * 24 * 365
+
+export function isWhatsNewTabHidden(raw: string | undefined | null): boolean {
+  return raw === 'hidden'
+}
+
+/** Client only: remember the choice for the next server render. */
+export function rememberWhatsNewTabHidden(hidden: boolean) {
+  document.cookie = `${WHATS_NEW_TAB_COOKIE}=${hidden ? 'hidden' : 'shown'}; path=/; max-age=${MAX_AGE}; SameSite=Lax`
+}

+ 2 - 0
src/features/whats-new/Schema/whatsNewSchema.ts

@@ -19,6 +19,8 @@ export const whatsNewEntrySchema = z.object({
   ctaHref: z.string().max(300).nullable().catch(null),
   /** A docs path on torqvoice.com, without a locale. */
   docsHref: z.string().max(300).nullable().catch(null),
+  /** A YouTube address. Absent from a site that does not send it yet. */
+  videoUrl: z.string().max(300).nullable().catch(null),
   /** The first release that has the feature. Older installs never see the entry. */
   minVersion: z.string().max(32).nullable().catch(null),
   audience: z.enum(['all', 'cloud', 'self-hosted']).catch('all'),