diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 10be5d72..035dae06 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -4,6 +4,8 @@ import { Plus, Loader2, ChevronLeft, AlertCircle, RefreshCw } from 'lucide-react import { UserProfileDropdown } from './UserProfileDropdown'; import { NotificationPanel } from './NotificationPanel'; import { TopBar } from './TopBar'; +import { WhatsNewTrigger } from './WhatsNewTrigger'; +import { WhatsNewModal } from './WhatsNewModal'; import { ViewRouter } from './EditorLayout/ViewRouter'; import { CreateStackDialog, type CreateMode } from './EditorLayout/CreateStackDialog'; import { AdoptExistingDialog } from './EditorLayout/AdoptExistingDialog'; @@ -198,6 +200,7 @@ export default function EditorLayout() { // Which mode the create dialog opens on (always empty after import tab removal). const [createDialogInitialMode, setCreateDialogInitialMode] = useState('empty'); const [adoptDialogOpen, setAdoptDialogOpen] = useState(false); + const [whatsNewOpen, setWhatsNewOpen] = useState(false); const adoptOpenedFromSetupRef = useRef(false); const openCreateDialog = useCallback((mode: CreateMode = 'empty') => { @@ -884,6 +887,19 @@ export default function EditorLayout() { /> ); + // Desktop only: the trigger lives in the TopBar, which the bespoke mobile + // screens drop, so this is rendered in the desktop branch alone. + const whatsNewModalEl = ( + { + setWhatsNewOpen(false); + handleNotificationNavigateChangelog(); + }} + /> + ); + return ( {(() => { @@ -980,6 +996,7 @@ export default function EditorLayout() { onNavigate={navHandler} mobileNavOpen={mobileNavOpen} onMobileNavOpenChange={setMobileNavOpen} + whatsNew={ setWhatsNewOpen(true)} />} search={} themeSwitch={themeSwitchEl} notifications={notificationsEl} @@ -1295,6 +1312,7 @@ export default function EditorLayout() { {workspaceEl} {adoptDialogEl} + {whatsNewModalEl} {shellOverlaysEl} {hydrationOverlay} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 1065cd09..8101ee1e 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -39,6 +39,7 @@ interface TopBarProps { mobileNavOpen: boolean; onMobileNavOpenChange: (open: boolean) => void; search?: ReactNode; + whatsNew?: ReactNode; themeSwitch?: ReactNode; notifications: ReactNode; userMenu: ReactNode; @@ -482,6 +483,7 @@ export function TopBar({ mobileNavOpen, onMobileNavOpenChange, search, + whatsNew, themeSwitch, notifications, userMenu, @@ -565,6 +567,7 @@ export function TopBar({ !centered && !stripLabels && navMode !== 'compact' && 'flex-1 min-w-0', )} > + {whatsNew} {search} {themeSwitch} {notifications} diff --git a/frontend/src/components/WhatsNewModal.tsx b/frontend/src/components/WhatsNewModal.tsx new file mode 100644 index 00000000..af8c08a7 --- /dev/null +++ b/frontend/src/components/WhatsNewModal.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from 'react'; +import { ExternalLink } from 'lucide-react'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal'; +import { Button } from '@/components/ui/button'; +import { whatsNewEntries } from '@/whats-new/entries'; +import { useWhatsNewPreference } from '@/hooks/useWhatsNewPreference'; + +interface WhatsNewModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onViewChangelog: () => void; +} + +// font-sans/normal-case/tracking-normal reset ModalFooter's inherited kicker styling (font-mono uppercase tracking-[0.22em]) so the footer link reads as a link, not a label. +const linkClassName = 'inline-flex items-center gap-1 text-xs text-brand hover:underline font-sans normal-case tracking-normal'; + +// whatsNewEntries is authored oldest-first; the modal shows newest first. +const entries = [...whatsNewEntries].reverse(); + +export function WhatsNewModal({ open, onOpenChange, onViewChangelog }: WhatsNewModalProps) { + const { markSeen, setEnabled } = useWhatsNewPreference(); + // Screenshots are authored by hand alongside the entry, so a typo'd or + // not-yet-added filename is a realistic mistake. Drop the image instead of + // leaving the browser's broken-image placeholder in the card. + const [failedScreenshots, setFailedScreenshots] = useState>(new Set()); + + useEffect(() => { + if (open) markSeen(); + }, [open, markSeen]); + + return ( + // xl (max-w-xl w-[95vw]), not the md default (max-w-md): cards carry + // screenshots and need more width than the default confirm-dialog size. + // className bounds the dialog to 85dvh and makes it a flex column so + // ModalBody's `fill` (flex-1 min-h-0) can actually constrain the body to + // scroll while the header and footer stay pinned, matching the pattern + // ConfirmModal uses (flex max-h-[85dvh] flex-col). + + + + {entries.length === 0 ? ( +

Nothing new to show yet.

+ ) : ( + entries.map((entry) => ( +
+

{entry.title}

+

{entry.blurb}

+ {entry.screenshot && !failedScreenshots.has(entry.id) && ( + {entry.title} setFailedScreenshots((prev) => new Set(prev).add(entry.id))} + /> + )} + {entry.docUrl && ( + + Learn more + + )} +
+ )) + )} +
+ + View full changelog + + } + secondary={ + // Turning the feature off also removes the nav icon, so leaving the + // modal open would strand the user in a surface they just dismissed. + + } + primary={ + + } + /> +
+ ); +} diff --git a/frontend/src/components/WhatsNewTrigger.tsx b/frontend/src/components/WhatsNewTrigger.tsx new file mode 100644 index 00000000..690747af --- /dev/null +++ b/frontend/src/components/WhatsNewTrigger.tsx @@ -0,0 +1,44 @@ +import { Sparkles } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; +import { useWhatsNewPreference } from '@/hooks/useWhatsNewPreference'; +import { whatsNewEntries } from '@/whats-new/entries'; +import { cn } from '@/lib/utils'; + +interface WhatsNewTriggerProps { + onClick: () => void; +} + +export function WhatsNewTrigger({ onClick }: WhatsNewTriggerProps) { + const { enabled, hasUnseen } = useWhatsNewPreference(); + + // Turning the feature off removes the affordance entirely, which is what + // "Never show again" means everywhere else. Settings > About is the way back. + if (!enabled) return null; + + // With nothing authored yet there is nothing to announce, so the icon stays + // out of the top bar rather than offering an empty modal. The modal keeps its + // own empty state as a fallback for entries that fail validation at runtime. + if (whatsNewEntries.length === 0) return null; + + return ( + + + + + + What's new + + + ); +} diff --git a/frontend/src/components/__tests__/TopBar.test.tsx b/frontend/src/components/__tests__/TopBar.test.tsx index 6d9fa103..1b7209b5 100644 --- a/frontend/src/components/__tests__/TopBar.test.tsx +++ b/frontend/src/components/__tests__/TopBar.test.tsx @@ -304,3 +304,24 @@ describe('TopBar smart and compact modes', () => { expect(screen.queryByRole('menuitem', { name: /Add to quick links/i })).toBeNull(); }); }); + +describe('TopBar whatsNew slot', () => { + it('renders the whatsNew slot before the search slot', () => { + renderTopBar({ + whatsNew: , + search: , + }); + const whatsNewEl = screen.getByRole('button', { name: 'whats-new-marker' }); + const searchEl = screen.getByRole('button', { name: 'search-marker' }); + // DOM order, not visual order: whatsNew's position relative to search in + // the source confirms it is placed before, not just present. + expect( + whatsNewEl.compareDocumentPosition(searchEl) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('renders nothing extra when whatsNew is omitted', () => { + renderTopBar(); + expect(screen.queryByLabelText('whats-new-marker')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/WhatsNewModal.empty.test.tsx b/frontend/src/components/__tests__/WhatsNewModal.empty.test.tsx new file mode 100644 index 00000000..e00feff5 --- /dev/null +++ b/frontend/src/components/__tests__/WhatsNewModal.empty.test.tsx @@ -0,0 +1,16 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('@/whats-new/entries', () => ({ whatsNewEntries: [] })); +vi.mock('@/hooks/useWhatsNewPreference', () => ({ + useWhatsNewPreference: () => ({ enabled: true, hasUnseen: false, setEnabled: vi.fn(), markSeen: vi.fn() }), +})); + +import { WhatsNewModal } from '../WhatsNewModal'; + +describe('WhatsNewModal with no entries', () => { + it('shows an empty state instead of an empty list', () => { + render(); + expect(screen.getByText('Nothing new to show yet.')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/WhatsNewModal.test.tsx b/frontend/src/components/__tests__/WhatsNewModal.test.tsx new file mode 100644 index 00000000..4d1de523 --- /dev/null +++ b/frontend/src/components/__tests__/WhatsNewModal.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// vi.mock factories are hoisted above all other module-scope code, so a plain +// array literal referenced from the factory would hit a temporal-dead-zone +// ReferenceError; vi.hoisted() hoists the declaration itself alongside it. +const mockEntries = vi.hoisted(() => [ + { id: 'entry-a', title: 'First feature', blurb: 'Does the first thing.', screenshot: 'first.png' }, + { id: 'entry-b', title: 'Second feature', blurb: 'Does the second thing.', docUrl: 'https://docs.sencho.io/features/second', screenshot: 'second.png' }, +]); +vi.mock('@/whats-new/entries', () => ({ whatsNewEntries: mockEntries })); + +const mockSetEnabled = vi.fn(); +const mockMarkSeen = vi.fn(); +vi.mock('@/hooks/useWhatsNewPreference', () => ({ + useWhatsNewPreference: () => ({ enabled: true, hasUnseen: true, setEnabled: mockSetEnabled, markSeen: mockMarkSeen }), +})); + +import { WhatsNewModal } from '../WhatsNewModal'; + +describe('WhatsNewModal', () => { + beforeEach(() => { + mockSetEnabled.mockClear(); + mockMarkSeen.mockClear(); + }); + + it('renders every entry, newest first, with title and blurb', () => { + render(); + const titles = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent); + expect(titles).toEqual(['Second feature', 'First feature']); + expect(screen.getByText('Does the second thing.')).toBeInTheDocument(); + }); + + it('renders a doc link only for entries that have one, and a screenshot for each that does', () => { + render(); + expect(screen.getAllByRole('link', { name: /Learn more/ })).toHaveLength(1); + expect(screen.getAllByRole('img').map((i) => i.getAttribute('src'))).toEqual([ + '/whats-new/second.png', + '/whats-new/first.png', + ]); + }); + + it('drops only the screenshot that fails to load, leaving the rest of the entry and other images intact', () => { + render(); + fireEvent.error(screen.getByAltText('Second feature')); + expect(screen.queryByAltText('Second feature')).not.toBeInTheDocument(); + // A sibling entry's screenshot must survive, which is what makes the + // failure set per-entry rather than a single global flag. + expect(screen.getByAltText('First feature')).toBeInTheDocument(); + // The failed entry keeps everything except its image. + expect(screen.getByText('Does the second thing.')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Learn more/ })).toBeInTheDocument(); + }); + + it('marks the newest entry seen when opened', () => { + render(); + expect(mockMarkSeen).toHaveBeenCalledTimes(1); + }); + + it('does not mark seen when closed', () => { + render(); + expect(mockMarkSeen).not.toHaveBeenCalled(); + }); + + it('"Never show again" disables the preference and closes the modal', async () => { + const onOpenChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Never show again' })); + expect(mockSetEnabled).toHaveBeenCalledWith(false); + // The nav trigger disappears with the preference, so the modal must not + // be left open behind it. + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('"Got it" closes the modal without touching the preference', async () => { + const onOpenChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Got it' })); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(mockSetEnabled).not.toHaveBeenCalled(); + }); + + it('"View full changelog" calls the callback', async () => { + const onViewChangelog = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'View full changelog' })); + expect(onViewChangelog).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/__tests__/WhatsNewTrigger.empty.test.tsx b/frontend/src/components/__tests__/WhatsNewTrigger.empty.test.tsx new file mode 100644 index 00000000..6426929f --- /dev/null +++ b/frontend/src/components/__tests__/WhatsNewTrigger.empty.test.tsx @@ -0,0 +1,18 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +// Separate file so entries can be mocked empty at module scope, matching the +// state that actually ships until the first entry is authored. +vi.mock('@/whats-new/entries', () => ({ whatsNewEntries: [] })); +vi.mock('@/hooks/useWhatsNewPreference', () => ({ + useWhatsNewPreference: () => ({ enabled: true, hasUnseen: false, setEnabled: vi.fn(), markSeen: vi.fn() }), +})); + +import { WhatsNewTrigger } from '../WhatsNewTrigger'; + +describe('WhatsNewTrigger with no entries authored', () => { + it('stays out of the top bar entirely, even with the preference enabled', () => { + render(); + expect(screen.queryByRole('button', { name: "What's new" })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/WhatsNewTrigger.test.tsx b/frontend/src/components/__tests__/WhatsNewTrigger.test.tsx new file mode 100644 index 00000000..d96aba0e --- /dev/null +++ b/frontend/src/components/__tests__/WhatsNewTrigger.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const mockPreference = vi.fn(); +vi.mock('@/hooks/useWhatsNewPreference', () => ({ + useWhatsNewPreference: () => mockPreference(), +})); + +// The committed entries.json is empty, which the trigger treats as "nothing to +// announce". Every case below except the empty-file one needs an entry to exist. +vi.mock('@/whats-new/entries', () => ({ + whatsNewEntries: [{ id: 'entry-a', title: 'A feature', blurb: 'Does a thing.' }], +})); + +import { WhatsNewTrigger } from '../WhatsNewTrigger'; + +function givenPreference(enabled: boolean, hasUnseen: boolean) { + mockPreference.mockReturnValue({ enabled, hasUnseen, setEnabled: vi.fn(), markSeen: vi.fn() }); +} + +describe('WhatsNewTrigger', () => { + it('breathes when enabled and there are unseen entries', () => { + givenPreference(true, true); + render(); + const icon = screen.getByRole('button', { name: "What's new" }).querySelector('svg'); + expect(icon).toHaveClass('animate-whats-new-breathe'); + }); + + it('renders nothing at all when the preference is disabled', () => { + givenPreference(false, true); + render(); + expect(screen.queryByRole('button', { name: "What's new" })).not.toBeInTheDocument(); + }); + + it('does not breathe when there is nothing unseen', () => { + givenPreference(true, false); + render(); + const icon = screen.getByRole('button', { name: "What's new" }).querySelector('svg'); + expect(icon).not.toHaveClass('animate-whats-new-breathe'); + }); + + it('opens the modal when clicked', async () => { + givenPreference(true, false); + const onClick = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: "What's new" })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('exposes a visible keyboard focus state', () => { + givenPreference(true, false); + render(); + expect(screen.getByRole('button', { name: "What's new" })).toHaveClass('focus-visible:ring-2'); + }); +}); diff --git a/frontend/src/components/settings/AboutSection.tsx b/frontend/src/components/settings/AboutSection.tsx index e36db45c..a3feee02 100644 --- a/frontend/src/components/settings/AboutSection.tsx +++ b/frontend/src/components/settings/AboutSection.tsx @@ -1,5 +1,7 @@ import { useLicense } from '@/context/LicenseContext'; import { TierBadge } from '@/components/TierBadge'; +import { TogglePill } from '@/components/ui/toggle-pill'; +import { useWhatsNewPreference } from '@/hooks/useWhatsNewPreference'; import { SettingsSection } from './SettingsSection'; import { SettingsField } from './SettingsField'; import { @@ -14,6 +16,7 @@ const linkClassName = export function AboutSection() { const { license } = useLicense(); + const { enabled: whatsNewEnabled, setEnabled: setWhatsNewEnabled } = useWhatsNewPreference(); return (
@@ -41,6 +44,19 @@ export function AboutSection() { ) : null} + + + + + + ({ TierBadge: () => Community, })); +const mockSetEnabled = vi.fn(); +vi.mock('@/hooks/useWhatsNewPreference', () => ({ + useWhatsNewPreference: () => ({ enabled: true, setEnabled: mockSetEnabled, hasUnseen: false, markSeen: vi.fn() }), +})); + describe('AboutSection', () => { it('renders Plan status and Source, License, and Licensing docs links with exact URLs', () => { render(); @@ -65,3 +71,11 @@ describe('AboutSection', () => { expect(screen.getByText('Licensing documentation')).toBeTruthy(); }); }); + +describe('AboutSection Preferences', () => { + it('toggling "Show What\'s New" calls setEnabled', async () => { + render(); + await userEvent.click(screen.getByRole('switch')); + expect(mockSetEnabled).toHaveBeenCalledWith(false); + }); +}); diff --git a/frontend/src/hooks/__tests__/useWhatsNewPreference.empty.test.ts b/frontend/src/hooks/__tests__/useWhatsNewPreference.empty.test.ts new file mode 100644 index 00000000..88fc1efa --- /dev/null +++ b/frontend/src/hooks/__tests__/useWhatsNewPreference.empty.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { vi } from 'vitest'; + +// This file exists separately from useWhatsNewPreference.test.ts purely to mock +// a different module state: latestWhatsNewEntryId === null, which is what ships +// whenever entries.json is still empty. That is the branch running in +// production today, so it needs its own coverage rather than relying on the +// sibling file's non-null mock. +vi.mock('@/whats-new/entries', () => ({ latestWhatsNewEntryId: null })); + +import { useWhatsNewPreference } from '../useWhatsNewPreference'; + +const LAST_SEEN_KEY = 'sencho.whatsNew.lastSeenId'; + +describe('useWhatsNewPreference with no entries authored yet', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('stamps an empty-string watermark on a fresh install so the next release is detected', () => { + const { result } = renderHook(() => useWhatsNewPreference()); + // The write itself is the point: without it, a later release adding its + // first entry would still see `stored === null` and silently catch up, + // swallowing the very first unseen signal on an existing install. + expect(localStorage.getItem(LAST_SEEN_KEY)).toBe(''); + expect(result.current.hasUnseen).toBe(false); + }); + + it('reports nothing unseen and leaves the preference enabled by default', () => { + const { result } = renderHook(() => useWhatsNewPreference()); + expect(result.current.hasUnseen).toBe(false); + expect(result.current.enabled).toBe(true); + }); + + it('markSeen is a no-op that does not overwrite the watermark', () => { + const { result } = renderHook(() => useWhatsNewPreference()); + act(() => { result.current.markSeen(); }); + expect(localStorage.getItem(LAST_SEEN_KEY)).toBe(''); + expect(result.current.hasUnseen).toBe(false); + }); + + it('still honours an explicit opt-out', () => { + const { result } = renderHook(() => useWhatsNewPreference()); + act(() => { result.current.setEnabled(false); }); + expect(result.current.enabled).toBe(false); + }); +}); diff --git a/frontend/src/hooks/__tests__/useWhatsNewPreference.test.ts b/frontend/src/hooks/__tests__/useWhatsNewPreference.test.ts new file mode 100644 index 00000000..cbf5406b --- /dev/null +++ b/frontend/src/hooks/__tests__/useWhatsNewPreference.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +vi.mock('@/whats-new/entries', () => ({ latestWhatsNewEntryId: 'entry-b' })); + +import { useWhatsNewPreference } from '../useWhatsNewPreference'; + +describe('useWhatsNewPreference', () => { + beforeEach(() => localStorage.clear()); + afterEach(() => localStorage.clear()); + + it('defaults enabled to true when never set', () => { + const { result } = renderHook(() => useWhatsNewPreference()); + expect(result.current.enabled).toBe(true); + }); + + it('respects a stored disabled preference', () => { + localStorage.setItem('sencho.whatsNew.enabled', '0'); + const { result } = renderHook(() => useWhatsNewPreference()); + expect(result.current.enabled).toBe(false); + }); + + it('setEnabled persists and updates state', () => { + const { result } = renderHook(() => useWhatsNewPreference()); + act(() => result.current.setEnabled(false)); + expect(result.current.enabled).toBe(false); + expect(localStorage.getItem('sencho.whatsNew.enabled')).toBe('0'); + }); + + it('a fresh install (no stored watermark) silently catches up, so hasUnseen starts false', () => { + const { result } = renderHook(() => useWhatsNewPreference()); + expect(localStorage.getItem('sencho.whatsNew.lastSeenId')).toBe('entry-b'); + expect(result.current.hasUnseen).toBe(false); + }); + + it('a release that shipped with zero entries stamps a "" watermark, so the next release\'s first real entry is detected as unseen for an existing install', () => { + // Simulates: release N had entries.json === [], the hook already ran once + // on this install and (per the fixed initializer) stamped '' as the + // watermark. Release N+1 now adds the first real entry. The install is + // NOT fresh (a key already exists), so it must not silently catch up. + localStorage.setItem('sencho.whatsNew.lastSeenId', ''); + const { result } = renderHook(() => useWhatsNewPreference()); + expect(result.current.hasUnseen).toBe(true); + }); + + it('an existing watermark behind the latest entry reports hasUnseen true', () => { + localStorage.setItem('sencho.whatsNew.lastSeenId', 'entry-a'); + const { result } = renderHook(() => useWhatsNewPreference()); + expect(result.current.hasUnseen).toBe(true); + }); + + it('markSeen clears hasUnseen and persists the watermark', () => { + localStorage.setItem('sencho.whatsNew.lastSeenId', 'entry-a'); + const { result } = renderHook(() => useWhatsNewPreference()); + expect(result.current.hasUnseen).toBe(true); + act(() => result.current.markSeen()); + expect(result.current.hasUnseen).toBe(false); + expect(localStorage.getItem('sencho.whatsNew.lastSeenId')).toBe('entry-b'); + }); + + it('a second hook instance picks up a change made by the first (window event sync)', () => { + const a = renderHook(() => useWhatsNewPreference()); + const b = renderHook(() => useWhatsNewPreference()); + act(() => a.result.current.setEnabled(false)); + expect(b.result.current.enabled).toBe(false); + }); +}); diff --git a/frontend/src/hooks/useWhatsNewPreference.ts b/frontend/src/hooks/useWhatsNewPreference.ts new file mode 100644 index 00000000..de752ca9 --- /dev/null +++ b/frontend/src/hooks/useWhatsNewPreference.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useState } from 'react'; +import { latestWhatsNewEntryId } from '@/whats-new/entries'; + +const ENABLED_KEY = 'sencho.whatsNew.enabled'; +const LAST_SEEN_KEY = 'sencho.whatsNew.lastSeenId'; +const CHANGE_EVENT = 'sencho:whats-new-changed'; + +function readEnabled(): boolean { + try { + const raw = localStorage.getItem(ENABLED_KEY); + return raw === null ? true : raw === '1'; + } catch { + return true; + } +} + +function readLastSeenId(): string | null { + try { + return localStorage.getItem(LAST_SEEN_KEY); + } catch { + return null; + } +} + +function writeSetting(key: string, value: string) { + try { + localStorage.setItem(key, value); + } catch { + // Quota exhaustion is non-fatal for this preference; in-memory state still + // reflects the user's choice for this session. + } + window.dispatchEvent(new Event(CHANGE_EVENT)); +} + +export interface UseWhatsNewPreferenceResult { + enabled: boolean; + setEnabled: (next: boolean) => void; + hasUnseen: boolean; + markSeen: () => void; +} + +export function useWhatsNewPreference(): UseWhatsNewPreferenceResult { + const [enabled, setEnabledState] = useState(readEnabled); + // The watermark is stamped unconditionally the first time this hook ever + // runs, even when entries.json is currently empty (using '' as the seed). + // This is deliberate: if we only stamped when latestWhatsNewEntryId was + // non-null, an install that first loads while entries are empty would + // never get a watermark written, so `stored === null` would still be true + // once a later release adds its first real entry, indistinguishable from + // a genuinely fresh install, silently swallowing the very first "unseen" + // signal. Stamping '' up front means any later real id no longer equals + // the stored watermark, so hasUnseen correctly flips to true for existing + // installs while a truly fresh install (no key at all) still catches up + // silently. Strict Mode may invoke this initializer twice; the write is + // idempotent. + const [lastSeenId, setLastSeenIdState] = useState(() => { + const stored = readLastSeenId(); + if (stored !== null) return stored; + const seed = latestWhatsNewEntryId ?? ''; + writeSetting(LAST_SEEN_KEY, seed); + return seed; + }); + + useEffect(() => { + const handler = () => { + setEnabledState(readEnabled()); + setLastSeenIdState(readLastSeenId()); + }; + window.addEventListener(CHANGE_EVENT, handler); + window.addEventListener('storage', handler); + return () => { + window.removeEventListener(CHANGE_EVENT, handler); + window.removeEventListener('storage', handler); + }; + }, []); + + const setEnabled = useCallback((next: boolean) => { + writeSetting(ENABLED_KEY, next ? '1' : '0'); + setEnabledState(next); + }, []); + + const markSeen = useCallback(() => { + if (latestWhatsNewEntryId === null) return; + writeSetting(LAST_SEEN_KEY, latestWhatsNewEntryId); + setLastSeenIdState(latestWhatsNewEntryId); + }, []); + + const hasUnseen = latestWhatsNewEntryId !== null && lastSeenId !== latestWhatsNewEntryId; + + return { enabled, setEnabled, hasUnseen, markSeen }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 2544178b..3b4c2982 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -480,6 +480,9 @@ html[data-effects="reduced"] [data-sn-glass="mobile-tabbar"] { html[data-effects="reduced"] .animate-pulse { animation: none; } +html[data-effects="reduced"] .animate-whats-new-breathe { + animation: none; +} /* Decorative masthead rails: tied to reduced material effects (Calm, Readability, or manual Reduced effects), not to data-motion. Motion remains independently @@ -767,6 +770,10 @@ body { 0%, 100% { opacity: 0.15; } 50% { opacity: 0.55; } } +@keyframes whats-new-breathe { + 0%, 100% { transform: scale(1); opacity: 0.85; } + 50% { transform: scale(1.08); opacity: 1; } +} /* ───────────────────────────────────────────────────────────── ANIMATION UTILITIES @@ -795,6 +802,9 @@ body { .masthead-rail-glow { animation: masthead-rail-glow 4s ease-in-out infinite alternate; } +.animate-whats-new-breathe { + animation: whats-new-breathe 2.6s ease-in-out infinite; +} /* Stagger delay helpers */ .animate-delay-50 { animation-delay: 50ms; } diff --git a/frontend/src/whats-new/entries.json b/frontend/src/whats-new/entries.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/frontend/src/whats-new/entries.json @@ -0,0 +1 @@ +[] diff --git a/frontend/src/whats-new/entries.test.ts b/frontend/src/whats-new/entries.test.ts new file mode 100644 index 00000000..46032758 --- /dev/null +++ b/frontend/src/whats-new/entries.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import rawEntries from './entries.json'; +import { isWhatsNewEntry, WHATS_NEW_CAP } from './types'; + +// These are authoring guards on the committed data, not tests of behaviour: +// they fail the build when a hand-written entry is malformed, duplicated, or +// pushes the file past the cap. The loader's own behaviour is covered in +// loader.test.ts. While entries.json is still empty they pass trivially, which +// is expected; they start biting as soon as the first entry lands. +describe('whats-new entries.json', () => { + it('contains only valid entries', () => { + for (const entry of rawEntries) { + expect(isWhatsNewEntry(entry)).toBe(true); + } + }); + + it('has unique ids', () => { + const ids = rawEntries.map((e) => (e as { id: string }).id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it(`does not exceed the ${WHATS_NEW_CAP}-entry cap`, () => { + expect(rawEntries.length).toBeLessThanOrEqual(WHATS_NEW_CAP); + }); +}); diff --git a/frontend/src/whats-new/entries.ts b/frontend/src/whats-new/entries.ts new file mode 100644 index 00000000..01a0b93d --- /dev/null +++ b/frontend/src/whats-new/entries.ts @@ -0,0 +1,17 @@ +// frontend/src/whats-new/entries.ts +import rawEntries from './entries.json'; +import { isWhatsNewEntry, type WhatsNewEntry } from './types'; + +function loadEntries(): WhatsNewEntry[] { + return (rawEntries as unknown[]).filter((entry): entry is WhatsNewEntry => { + const valid = isWhatsNewEntry(entry); + if (!valid) console.error('[WhatsNew] dropping malformed entry:', entry); + return valid; + }); +} + +/** Oldest-first, matching authoring (append) order. */ +export const whatsNewEntries: WhatsNewEntry[] = loadEntries(); + +export const latestWhatsNewEntryId: string | null = + whatsNewEntries.length > 0 ? whatsNewEntries[whatsNewEntries.length - 1].id : null; diff --git a/frontend/src/whats-new/loader.test.ts b/frontend/src/whats-new/loader.test.ts new file mode 100644 index 00000000..76bbdca3 --- /dev/null +++ b/frontend/src/whats-new/loader.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// entries.ts reads entries.json at module load, so each case has to mock the +// JSON and re-import the module rather than calling a function. +async function loadWith(raw: unknown) { + vi.resetModules(); + vi.doMock('./entries.json', () => ({ default: raw })); + return import('./entries'); +} + +describe('whats-new entries loader', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.doUnmock('./entries.json'); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it('treats the last entry as the newest, which is the contract the watermark relies on', async () => { + const { whatsNewEntries, latestWhatsNewEntryId } = await loadWith([ + { id: 'oldest', title: 'Oldest', blurb: 'First shipped.' }, + { id: 'newest', title: 'Newest', blurb: 'Shipped most recently.' }, + ]); + expect(whatsNewEntries.map((e) => e.id)).toEqual(['oldest', 'newest']); + expect(latestWhatsNewEntryId).toBe('newest'); + }); + + it('drops malformed entries but keeps the valid ones', async () => { + const { whatsNewEntries, latestWhatsNewEntryId } = await loadWith([ + { id: 'good-1', title: 'Good', blurb: 'Valid.' }, + { id: 'missing-blurb', title: 'Bad' }, + { title: 'No id', blurb: 'Bad.' }, + null, + 'not an object', + { id: 'good-2', title: 'Also good', blurb: 'Valid.' }, + ]); + expect(whatsNewEntries.map((e) => e.id)).toEqual(['good-1', 'good-2']); + expect(latestWhatsNewEntryId).toBe('good-2'); + }); + + it('yields no entries and a null latest id for an empty file', async () => { + const { whatsNewEntries, latestWhatsNewEntryId } = await loadWith([]); + expect(whatsNewEntries).toEqual([]); + expect(latestWhatsNewEntryId).toBeNull(); + }); + + it('keeps the optional docUrl and screenshot fields when present', async () => { + const { whatsNewEntries } = await loadWith([ + { + id: 'full', + title: 'Full entry', + blurb: 'Has both optional fields.', + docUrl: 'https://docs.sencho.io/features/full', + screenshot: 'full.png', + }, + ]); + expect(whatsNewEntries[0]).toMatchObject({ + docUrl: 'https://docs.sencho.io/features/full', + screenshot: 'full.png', + }); + }); +}); diff --git a/frontend/src/whats-new/types.ts b/frontend/src/whats-new/types.ts new file mode 100644 index 00000000..44fcdfe7 --- /dev/null +++ b/frontend/src/whats-new/types.ts @@ -0,0 +1,22 @@ +// frontend/src/whats-new/types.ts +export interface WhatsNewEntry { + id: string; + title: string; + blurb: string; + docUrl?: string; + screenshot?: string; +} + +export const WHATS_NEW_CAP = 20; + +export function isWhatsNewEntry(value: unknown): value is WhatsNewEntry { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + return ( + typeof v.id === 'string' && v.id.length > 0 && + typeof v.title === 'string' && v.title.length > 0 && + typeof v.blurb === 'string' && v.blurb.length > 0 && + (v.docUrl === undefined || typeof v.docUrl === 'string') && + (v.screenshot === undefined || typeof v.screenshot === 'string') + ); +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 043c080d..041500af 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -7,6 +7,7 @@ "module": "ESNext", "types": ["vite/client"], "skipLibCheck": true, + "resolveJsonModule": true, /* Bundler mode */ "moduleResolution": "bundler",