fix(whats-new): add RBAC entry and a zoomable screenshot lightbox (#1806)

RBAC is the largest capability change in this release, so it leads the
list. Every entry's screenshot now opens full-viewport on click; the
overlay is portaled to document.body so it isn't capped at the modal's
own frame, dismissible via backdrop click, a close button, or Escape.
This commit is contained in:
Anso
2026-08-09 19:24:23 -04:00
committed by GitHub
parent 88fc5470e8
commit e9901aa24b
4 changed files with 124 additions and 9 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+73 -9
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ExternalLink, X } from 'lucide-react';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import { whatsNewEntries } from '@/whats-new/entries';
@@ -23,11 +24,20 @@ export function WhatsNewModal({ open, onOpenChange, onViewChangelog }: WhatsNewM
// 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());
const [zoomedSrc, setZoomedSrc] = useState<string | null>(null);
const closeZoom = useCallback(() => setZoomedSrc(null), []);
useEffect(() => {
if (open) markSeen();
}, [open, markSeen]);
// WhatsNewModal stays mounted for the app's lifetime (EditorLayout renders it
// with a controlled `open`), so without this reset a lightbox left open at
// close time would resurface on the next open.
useEffect(() => {
if (!open) closeZoom();
}, [open, closeZoom]);
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.
@@ -46,13 +56,19 @@ export function WhatsNewModal({ open, onOpenChange, onViewChangelog }: WhatsNewM
<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))}
/>
<button
type="button"
onClick={() => setZoomedSrc(`/whats-new/${entry.screenshot}`)}
aria-label={`Zoom in on ${entry.title} screenshot`}
className="block w-full cursor-zoom-in overflow-hidden rounded-md border border-card-border/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<img
src={`/whats-new/${entry.screenshot}`}
alt={entry.title}
loading="lazy"
onError={() => setFailedScreenshots((prev) => new Set(prev).add(entry.id))}
/>
</button>
)}
{entry.docUrl && (
<a href={entry.docUrl} target="_blank" rel="noopener noreferrer" className={linkClassName}>
@@ -89,6 +105,54 @@ export function WhatsNewModal({ open, onOpenChange, onViewChangelog }: WhatsNewM
</Button>
}
/>
{zoomedSrc && <ScreenshotLightbox src={zoomedSrc} onClose={closeZoom} />}
</Modal>
);
}
interface ScreenshotLightboxProps {
src: string;
onClose: () => void;
}
function ScreenshotLightbox({ src, onClose }: ScreenshotLightboxProps) {
useEffect(() => {
// Capture phase, ahead of Radix's own document-level capture listener for
// the parent Dialog's Escape handling: stopping propagation here is what
// keeps Escape from also dismissing the whole modal while zoomed.
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
onClose();
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [onClose]);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label="Zoomed screenshot"
onClick={onClose}
// Radix sets an inline pointer-events: none on <body> while the parent
// Dialog is open (to keep interaction scoped to its own content); this
// portal renders as a body child too, so it needs an explicit inline
// override (a Tailwind class here would be inert: nothing overrides an
// ancestor's inline style except another inline style).
style={{ pointerEvents: 'auto' }}
className="fixed inset-0 z-[60] flex cursor-zoom-out items-center justify-center bg-[var(--scrim)] p-8 backdrop-blur-sm"
>
<img src={src} alt="" className="max-h-full max-w-full rounded-md object-contain" />
<button
type="button"
onClick={onClose}
aria-label="Close zoomed screenshot"
className="absolute right-4 top-4 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-popover/80 text-popover-foreground hover:bg-popover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" strokeWidth={1.5} />
</button>
</div>,
document.body,
);
}
@@ -87,4 +87,48 @@ describe('WhatsNewModal', () => {
await userEvent.click(screen.getByRole('button', { name: 'View full changelog' }));
expect(onViewChangelog).toHaveBeenCalledTimes(1);
});
it('clicking a screenshot opens a zoomed overlay with the same image', async () => {
render(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: 'Zoom in on Second feature screenshot' }));
const zoomed = screen.getByRole('dialog', { name: 'Zoomed screenshot' });
expect(zoomed.querySelector('img')).toHaveAttribute('src', '/whats-new/second.png');
});
it('the close button dismisses only the zoom overlay, leaving the parent modal open', async () => {
const onOpenChange = vi.fn();
render(<WhatsNewModal open onOpenChange={onOpenChange} onViewChangelog={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: 'Zoom in on Second feature screenshot' }));
await userEvent.click(screen.getByRole('button', { name: 'Close zoomed screenshot' }));
expect(screen.queryByRole('dialog', { name: 'Zoomed screenshot' })).not.toBeInTheDocument();
// The regression this guards against: an earlier implementation portaled
// the overlay to document.body, outside Radix's Dialog content subtree,
// so Radix treated every zoom-dismiss click as an outside click and also
// closed the parent. If that regressed, onOpenChange(false) fires here.
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: "What's New" })).toBeInTheDocument();
});
it('clicking the backdrop dismisses only the zoom overlay, leaving the parent modal open', async () => {
const onOpenChange = vi.fn();
render(<WhatsNewModal open onOpenChange={onOpenChange} onViewChangelog={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: 'Zoom in on Second feature screenshot' }));
await userEvent.click(screen.getByRole('dialog', { name: 'Zoomed screenshot' }));
expect(screen.queryByRole('dialog', { name: 'Zoomed screenshot' })).not.toBeInTheDocument();
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: "What's New" })).toBeInTheDocument();
});
it('Escape dismisses only the zoom overlay, leaving the parent modal open', async () => {
const onOpenChange = vi.fn();
render(<WhatsNewModal open onOpenChange={onOpenChange} onViewChangelog={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: 'Zoom in on Second feature screenshot' }));
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('dialog', { name: 'Zoomed screenshot' })).not.toBeInTheDocument();
// Radix's own Escape handling for the parent Dialog is a document-level
// capture listener; without stopPropagation this fires too and also
// requests the parent close.
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: "What's New" })).toBeInTheDocument();
});
});
+7
View File
@@ -40,5 +40,12 @@
"blurb": "Drag the divider in the Files tab to give the tree pane more room, or shrink it out of the way. Your preferred width is remembered.",
"docUrl": "https://docs.sencho.io/features/stack-file-explorer",
"screenshot": "file-explorer.png"
},
{
"id": "rbac",
"title": "Role-based access control",
"blurb": "Every instance ships with five built-in roles, Admin, Viewer, Deployer, Node Admin, and Auditor, plus scoped permissions to grant access to one stack or node without elevating anywhere else.",
"docUrl": "https://docs.sencho.io/features/rbac",
"screenshot": "rbac.png"
}
]