diff --git a/frontend/public/whats-new/rbac.png b/frontend/public/whats-new/rbac.png new file mode 100644 index 00000000..0975bba0 Binary files /dev/null and b/frontend/public/whats-new/rbac.png differ diff --git a/frontend/src/components/WhatsNewModal.tsx b/frontend/src/components/WhatsNewModal.tsx index af8c08a7..01f6b98c 100644 --- a/frontend/src/components/WhatsNewModal.tsx +++ b/frontend/src/components/WhatsNewModal.tsx @@ -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>(new Set()); + const [zoomedSrc, setZoomedSrc] = useState(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

{entry.title}

{entry.blurb}

{entry.screenshot && !failedScreenshots.has(entry.id) && ( - {entry.title} setFailedScreenshots((prev) => new Set(prev).add(entry.id))} - /> + )} {entry.docUrl && ( @@ -89,6 +105,54 @@ export function WhatsNewModal({ open, onOpenChange, onViewChangelog }: WhatsNewM } /> + {zoomedSrc && } ); } + +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( +
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" + > + + +
, + document.body, + ); +} diff --git a/frontend/src/components/__tests__/WhatsNewModal.test.tsx b/frontend/src/components/__tests__/WhatsNewModal.test.tsx index 4d1de523..0e7525b2 100644 --- a/frontend/src/components/__tests__/WhatsNewModal.test.tsx +++ b/frontend/src/components/__tests__/WhatsNewModal.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + }); }); diff --git a/frontend/src/whats-new/entries.json b/frontend/src/whats-new/entries.json index a3f517de..a506b99b 100644 --- a/frontend/src/whats-new/entries.json +++ b/frontend/src/whats-new/entries.json @@ -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" } ]