chore: add in-app What's New scaffolding (#1767)

* feat: add whats-new entries data model

* feat: add useWhatsNewPreference hook

* feat: add whats-new breathing animation

* feat: add WhatsNewTrigger sparkle icon

* feat: add whatsNew slot to TopBar

* feat: add WhatsNewModal

* feat: wire whats-new sparkle icon and modal into EditorLayout

* feat: add What's New opt-out toggle to Settings

* fix: seed What's New watermark for zero-entry releases

A release that ships with entries.json still empty must stamp a
watermark on first run, or an existing install can never distinguish
itself from a genuinely fresh install once a later release adds its
first real entry, silently swallowing that entry's unseen signal.

* fix: constrain WhatsNewModal height and clarify settings copy

Bound the dialog to 85vh as a flex column so ModalBody's fill can
constrain the entry list to scroll while the header and footer stay
pinned, matching ConfirmModal's pattern. Also clarifies the "Show
What's New" helper text in Settings.

* fix: drop What's New screenshots that fail to load

Screenshots are authored by hand alongside the entry, so a typo'd or
not-yet-added filename is a realistic mistake. Previously that left the
browser's broken-image placeholder and alt text inside the card; now the
image is dropped and the title, blurb, and doc link still render.

* style: replace em dash in watermark comment with a comma

* fix: make "Never show again" actually hide What's New

Turning the feature off previously only stopped the breathing animation
and left the sparkle icon in the top bar, which is not what that label
means anywhere else. Opting out now removes the trigger entirely and
closes the modal, and Settings > About is the single way back.

Also brings the trigger in line with its top bar siblings: it now uses
the search trigger's hover treatment and gains a visible keyboard focus
ring, and the modal bounds itself with dvh rather than vh so the footer
cannot sit under a mobile URL bar. The modal is rendered in the desktop
branch only, since the bespoke mobile screens drop the top bar that
carries its trigger.

* test: cover the empty-entries and loader paths of What's New

The shipped state has an empty entries.json, so the branch where there is
no newest entry is the one actually running, yet nothing exercised it.
Adds a sibling hook test mocking that state to pin the empty-string
watermark write, and a loader test for the newest-is-last contract the
watermark depends on plus the malformed-entry filter.

Drops the unreachable array check in the loader and the assertion that
mirrored it: TypeScript types the JSON import, so a non-array file fails
the build rather than reaching that branch.

* refactor: fold the What's New storage writers into one helper

writeEnabled and writeLastSeenId were identical apart from the key and
the value encoding, duplicating the comment explaining why a failed
write is non-fatal. The boolean encoding now sits at its single call
site. Also hoists the reversed entry list to module scope, since the
source array is a module constant, and factors the repeated preference
mock in the trigger test behind a helper.

* chore: keep the What's New icon hidden until an entry exists

With entries.json empty there is nothing to announce, so a permanent
sparkle in the top bar would only ever open a modal reading "Nothing new
to show yet". The trigger now renders nothing in that state, leaving the
modal's empty state as a runtime fallback for entries that fail
validation rather than the shipping experience.

This keeps the scaffolding invisible until the first entry is authored,
which is the change that actually surfaces the feature to users.
This commit is contained in:
Anso
2026-08-04 02:20:11 -04:00
committed by GitHub
parent 4be3319a07
commit 0b046bfa52
21 changed files with 737 additions and 0 deletions
+18
View File
@@ -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<CreateMode>('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 = (
<WhatsNewModal
open={whatsNewOpen}
onOpenChange={setWhatsNewOpen}
onViewChangelog={() => {
setWhatsNewOpen(false);
handleNotificationNavigateChangelog();
}}
/>
);
return (
<GlobalCommandPaletteProvider>
{(() => {
@@ -980,6 +996,7 @@ export default function EditorLayout() {
onNavigate={navHandler}
mobileNavOpen={mobileNavOpen}
onMobileNavOpenChange={setMobileNavOpen}
whatsNew={<WhatsNewTrigger onClick={() => setWhatsNewOpen(true)} />}
search={<GlobalCommandPaletteTrigger />}
themeSwitch={themeSwitchEl}
notifications={notificationsEl}
@@ -1295,6 +1312,7 @@ export default function EditorLayout() {
{workspaceEl}
</div>
{adoptDialogEl}
{whatsNewModalEl}
{shellOverlaysEl}
{hydrationOverlay}
</div>
+3
View File
@@ -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}
+94
View File
@@ -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<Set<string>>(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).
<Modal open={open} onOpenChange={onOpenChange} size="xl" className="max-h-[85dvh] flex flex-col">
<ModalHeader kicker="Sencho" title="What's New" />
<ModalBody fill className="space-y-6">
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing new to show yet.</p>
) : (
entries.map((entry) => (
<div key={entry.id} className="space-y-2 border-b border-card-border/40 pb-6 last:border-b-0 last:pb-0">
<h3 className="text-sm font-medium text-stat-value">{entry.title}</h3>
<p className="text-sm leading-relaxed text-stat-subtitle">{entry.blurb}</p>
{entry.screenshot && !failedScreenshots.has(entry.id) && (
<img
src={`/whats-new/${entry.screenshot}`}
alt={entry.title}
className="rounded-md border border-card-border/60"
loading="lazy"
onError={() => setFailedScreenshots((prev) => new Set(prev).add(entry.id))}
/>
)}
{entry.docUrl && (
<a href={entry.docUrl} target="_blank" rel="noopener noreferrer" className={linkClassName}>
Learn more <ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
)}
</div>
))
)}
</ModalBody>
<ModalFooter
hint={
<button type="button" onClick={onViewChangelog} className={linkClassName}>
View full changelog
</button>
}
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.
<Button
variant="ghost"
size="sm"
onClick={() => {
setEnabled(false);
onOpenChange(false);
}}
>
Never show again
</Button>
}
primary={
<Button size="sm" onClick={() => onOpenChange(false)}>
Got it
</Button>
}
/>
</Modal>
);
}
@@ -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 (
<TooltipProvider delayDuration={300} disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
aria-label="What's new"
// Matches the sibling search trigger in the same TopBar cluster.
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-foreground/80 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Sparkles
className={cn('h-4 w-4', hasUnseen && 'animate-whats-new-breathe')}
strokeWidth={1.5}
/>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">What's new</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -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: <button aria-label="whats-new-marker">sparkle</button>,
search: <button aria-label="search-marker">search</button>,
});
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();
});
});
@@ -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(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
expect(screen.getByText('Nothing new to show yet.')).toBeInTheDocument();
});
});
@@ -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(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
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(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
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(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
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(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
expect(mockMarkSeen).toHaveBeenCalledTimes(1);
});
it('does not mark seen when closed', () => {
render(<WhatsNewModal open={false} onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
expect(mockMarkSeen).not.toHaveBeenCalled();
});
it('"Never show again" disables the preference and closes the modal', async () => {
const onOpenChange = vi.fn();
render(<WhatsNewModal open onOpenChange={onOpenChange} onViewChangelog={vi.fn()} />);
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(<WhatsNewModal open onOpenChange={onOpenChange} onViewChangelog={vi.fn()} />);
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(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={onViewChangelog} />);
await userEvent.click(screen.getByRole('button', { name: 'View full changelog' }));
expect(onViewChangelog).toHaveBeenCalledTimes(1);
});
});
@@ -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(<WhatsNewTrigger onClick={vi.fn()} />);
expect(screen.queryByRole('button', { name: "What's new" })).not.toBeInTheDocument();
});
});
@@ -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(<WhatsNewTrigger onClick={vi.fn()} />);
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(<WhatsNewTrigger onClick={vi.fn()} />);
expect(screen.queryByRole('button', { name: "What's new" })).not.toBeInTheDocument();
});
it('does not breathe when there is nothing unseen', () => {
givenPreference(true, false);
render(<WhatsNewTrigger onClick={vi.fn()} />);
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(<WhatsNewTrigger onClick={onClick} />);
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(<WhatsNewTrigger onClick={vi.fn()} />);
expect(screen.getByRole('button', { name: "What's new" })).toHaveClass('focus-visible:ring-2');
});
});
@@ -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 (
<div className="flex flex-col gap-10">
@@ -41,6 +44,19 @@ export function AboutSection() {
) : null}
</SettingsSection>
<SettingsSection title="Preferences">
<SettingsField
label="Show What's New"
helper="Highlight the sparkle icon in the top bar when a new feature ships."
>
<TogglePill
id="whats-new-enabled"
checked={whatsNewEnabled}
onChange={setWhatsNewEnabled}
/>
</SettingsField>
</SettingsSection>
<SettingsSection title="Links">
<SettingsField
label="Source code"
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AboutSection } from '../AboutSection';
import { ABOUT_LINK_URLS } from '../aboutLinks';
@@ -36,6 +37,11 @@ vi.mock('@/components/TierBadge', () => ({
TierBadge: () => <span>Community</span>,
}));
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(<AboutSection />);
@@ -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(<AboutSection />);
await userEvent.click(screen.getByRole('switch'));
expect(mockSetEnabled).toHaveBeenCalledWith(false);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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<string | null>(() => {
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 };
}
+10
View File
@@ -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; }
+1
View File
@@ -0,0 +1 @@
[]
+25
View File
@@ -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);
});
});
+17
View File
@@ -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;
+65
View File
@@ -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',
});
});
});
+22
View File
@@ -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<string, unknown>;
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')
);
}
+1
View File
@@ -7,6 +7,7 @@
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"resolveJsonModule": true,
/* Bundler mode */
"moduleResolution": "bundler",