From e9901aa24bf3ddc1b00816a3a7e2fcfc2226c7b8 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 9 Aug 2026 19:24:23 -0400 Subject: [PATCH] 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. --- frontend/public/whats-new/rbac.png | Bin 0 -> 14915 bytes frontend/src/components/WhatsNewModal.tsx | 82 ++++++++++++++++-- .../__tests__/WhatsNewModal.test.tsx | 44 ++++++++++ frontend/src/whats-new/entries.json | 7 ++ 4 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 frontend/public/whats-new/rbac.png diff --git a/frontend/public/whats-new/rbac.png b/frontend/public/whats-new/rbac.png new file mode 100644 index 0000000000000000000000000000000000000000..0975bba0cd1ad782850b55cfde13b2b31dc6cea3 GIT binary patch literal 14915 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 CKF+)V literal 0 HcmV?d00001 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" } ]