diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 370de8019a35..ce5d479764e7 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -69,6 +69,10 @@ const config = defineMain({ directory: '../addons/a11y/src', titlePrefix: 'addons/accessibility', }, + { + directory: '../addons/review/src', + titlePrefix: 'addons/review', + }, { directory: '../addons/a11y/template/stories', titlePrefix: 'addons/accessibility', @@ -122,6 +126,7 @@ const config = defineMain({ '@storybook/addon-vitest', '@storybook/addon-a11y', '@storybook/addon-mcp', + '@storybook/addon-review', 'storybook-addon-pseudo-states', '@chromatic-com/storybook', './services-preset.ts', diff --git a/code/addons/review/README.md b/code/addons/review/README.md new file mode 100644 index 000000000000..e8b4ebf771da --- /dev/null +++ b/code/addons/review/README.md @@ -0,0 +1,53 @@ +# @storybook/addon-review + +Renders an agent-pushed review of a code change inside Storybook. + +An ADE agent pushes a review payload via the companion MCP server +(`@storybook/addon-mcp`); the dev server enriches it with the current git +branch, caches it, and broadcasts it over the Storybook channel. This addon's +page receives the payload (and requests a replay on mount so late or refreshed +tabs catch up) and renders it as a dedicated review experience: + +- **Summary** — the review title and narrative, with the affected stories + grouped into Collections (agent-curated clusters) or by Components, plus + search and expand/collapse. +- **Details** — a focused, full-screen story preview with previous/next + navigation through the reviewed stories and a link back to the summary. + +## Channel contract + +Event names live in `src/constants.ts` and are the cross-repo contract with +`@storybook/addon-mcp`. They must match the emitter's constants exactly. + +- `…/push-review` — agent → server: a new review payload. +- `…/display-review` — server → tabs: broadcast a review (`createdAt`-stamped). +- `…/request-review` — tab → server: replay the cached review (on mount). + +## Review state shape + +See `src/review-state.ts`. It is a duplicate of the canonical valibot schema +that lives in the MCP addon; this side only renders, so it needs the type, not +the validator. + +## Baseline comparison + +The detail screen can render the reviewed story side-by-side against a baseline +Storybook. The baseline source is configured with a single environment variable: + +```sh +STORYBOOK_REVIEW_BASELINE=... +``` + +It accepts either of: + +- **A project-relative path to a static build** (e.g. `storybook-static`). The + directory is served directly. Paths must stay inside the project — absolute + paths and paths that escape the working directory via `..` are rejected. +- **A remote origin URL** (e.g. `https://my-app.chromatic.com`). Requests are + proxied to that origin. + +The dev server exposes the baseline under an internal proxy path, so baseline +previews and the baseline index load from the same origin as Storybook. If the +variable is unset, or is neither a valid relative path nor a valid URL, no +baseline is served (a warning is logged for invalid values) and the comparison +controls stay hidden. diff --git a/code/addons/review/build-config.ts b/code/addons/review/build-config.ts new file mode 100644 index 000000000000..22ccaa8e3bd6 --- /dev/null +++ b/code/addons/review/build-config.ts @@ -0,0 +1,26 @@ +import type { BuildEntries } from '../../../scripts/build/utils/entry-utils.ts'; + +const config: BuildEntries = { + entries: { + browser: [ + { + exportEntries: ['.'], + entryPoint: './src/index.ts', + }, + { + exportEntries: ['./manager'], + entryPoint: './src/manager.tsx', + dts: false, + }, + ], + node: [ + { + exportEntries: ['./preset'], + entryPoint: './src/preset.ts', + dts: false, + }, + ], + }, +}; + +export default config; diff --git a/code/addons/review/manager.js b/code/addons/review/manager.js new file mode 100644 index 000000000000..bb3432a1730f --- /dev/null +++ b/code/addons/review/manager.js @@ -0,0 +1 @@ +export * from './dist/manager.js'; diff --git a/code/addons/review/package.json b/code/addons/review/package.json new file mode 100644 index 000000000000..7bb7d12894ef --- /dev/null +++ b/code/addons/review/package.json @@ -0,0 +1,63 @@ +{ + "name": "@storybook/addon-review", + "version": "10.5.0-alpha.6", + "description": "Storybook addon that renders agent-pushed reviews", + "keywords": [ + "storybook", + "storybook-addon", + "review", + "mcp", + "agent" + ], + "homepage": "https://github.com/storybookjs/storybook/tree/next/code/addons/review", + "bugs": { + "url": "https://github.com/storybookjs/storybook/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/storybookjs/storybook.git", + "directory": "code/addons/review" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "code": "./src/index.ts", + "default": "./dist/index.js" + }, + "./manager": "./dist/manager.js", + "./package.json": "./package.json", + "./preset": "./dist/preset.js" + }, + "files": [ + "dist/**/*", + "README.md", + "*.js", + "*.d.ts", + "!src/**/*" + ], + "devDependencies": { + "http-proxy-middleware": "^4.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sirv": "^2.0.4", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "storybook": "workspace:^" + }, + "publishConfig": { + "access": "public" + }, + "storybook": { + "displayName": "Review", + "unsupportedFrameworks": [ + "react-native" + ] + } +} diff --git a/code/addons/review/preset.js b/code/addons/review/preset.js new file mode 100644 index 000000000000..4bd63d324002 --- /dev/null +++ b/code/addons/review/preset.js @@ -0,0 +1 @@ +export * from './dist/preset.js'; diff --git a/code/addons/review/project.json b/code/addons/review/project.json new file mode 100644 index 000000000000..4a042f15c2ee --- /dev/null +++ b/code/addons/review/project.json @@ -0,0 +1,10 @@ +{ + "name": "addon-review", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "targets": { + "compile": {}, + "check": {} + }, + "tags": ["library"] +} diff --git a/code/addons/review/src/ReviewPage.stories.tsx b/code/addons/review/src/ReviewPage.stories.tsx new file mode 100644 index 000000000000..8b0d6590db37 --- /dev/null +++ b/code/addons/review/src/ReviewPage.stories.tsx @@ -0,0 +1,282 @@ +import { expect, fn, waitFor, within } from 'storybook/test'; + +import { + ManagerContext, + type API, + type State, + internal_fullStatusStore, +} from 'storybook/manager-api'; +import { MemoryRouter } from 'storybook/internal/router'; + +import preview from '../../../.storybook/preview.tsx'; +import { EVENTS, RESTORE_NAV_SESSION_KEY } from './constants.ts'; +import type { ReviewState } from './review-state.ts'; +import { ReviewPage } from './ReviewPage.tsx'; +import { sessionStore } from './session-store.ts'; + +type EventListener = (payload?: unknown) => void; + +const eventListeners = new Map>(); +const removeEventListener = (eventName: string, listener: EventListener) => { + eventListeners.get(eventName)?.delete(listener); +}; +const onMock = fn((eventName: string, listener: EventListener): (() => void) => { + if (!eventListeners.has(eventName)) { + eventListeners.set(eventName, new Set()); + } + eventListeners.get(eventName)?.add(listener); + return () => removeEventListener(eventName, listener); +}); +const offMock = fn((eventName: string, listener: EventListener) => { + removeEventListener(eventName, listener); +}); +const emitMock = fn((eventName: string, payload?: unknown) => { + eventListeners.get(eventName)?.forEach((listener) => { + listener(payload); + }); +}); +const toggleNavMock = fn(); +const managerState: State = { + index: { + 'manager-settings-checklist--default': { + type: 'story', + id: 'manager-settings-checklist--default', + title: 'Manager/Settings/Checklist', + name: 'Default', + }, + 'manager-settings-guidepage--default': { + type: 'story', + id: 'manager-settings-guidepage--default', + title: 'Manager/Settings/Guide Page', + name: 'Default', + }, + 'manager-settings-aboutscreen--default': { + type: 'story', + id: 'manager-settings-aboutscreen--default', + title: 'Manager/Settings/About Screen', + name: 'Default', + }, + }, +} as unknown as State; +const managerApi: API = { + on: onMock, + off: offMock, + emit: emitMock, + getIsNavShown: () => true, + toggleNav: toggleNavMock, + getStoryHrefs: (storyId: string, options?: { freeze?: boolean }) => ({ + managerHref: `?path=/story/${storyId}`, + previewHref: `iframe.html?id=${storyId}&viewMode=story${options?.freeze ? '&freeze=finished' : ''}`, + }), +} as unknown as API; + +const reviewState: ReviewState = { + title: 'Manager settings polish', + description: 'Updated settings views and spacing.', + // A baseline exists, so the detail screen renders the baseline/latest + // comparison for stories that aren't newly added. + hasBaseline: true, + // Drives the baseline-index fetch (keyed on createdAt) for "New" detection. + // Anchored to "now" so the "Created … ago" label stays small and stable + // instead of computing minutes against a fixed past timestamp. + createdAt: Date.now(), + collections: [ + { + title: 'Settings', + rationale: 'Primary settings surfaces changed.', + storyIds: [ + 'manager-settings-checklist--default', + 'manager-settings-guidepage--default', + 'manager-settings-aboutscreen--default', + ], + }, + ], +}; + +// Baseline index served via the dev-server proxy. Intentionally omits +// `manager-settings-checklist--default` so it reads as a newly added story, +// while guidepage/aboutscreen already exist in the baseline. +const baselineIndex = { + v: 5, + entries: { + 'manager-settings-guidepage--default': { + type: 'story', + id: 'manager-settings-guidepage--default', + title: 'Manager/Settings/Guide Page', + name: 'Default', + }, + 'manager-settings-aboutscreen--default': { + type: 'story', + id: 'manager-settings-aboutscreen--default', + title: 'Manager/Settings/About Screen', + name: 'Default', + }, + }, +}; + +const originalFetch = globalThis.fetch; +const fetchMock = fn(async (input: RequestInfo | URL): Promise => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/__review-baseline/index.json')) { + return new Response(JSON.stringify(baselineIndex), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(null, { status: 404 }); +}); + +const applyReviewState = () => { + expect(onMock).toHaveBeenCalledWith(EVENTS.DISPLAY_REVIEW, expect.any(Function)); + emitMock(EVENTS.DISPLAY_REVIEW, reviewState); +}; + +const meta = preview.meta({ + component: ReviewPage, + parameters: { + layout: 'fullscreen', + chromatic: { + ignoreSelectors: [ + '[data-testid="review-collection-grid-cell"] iframe', + '[data-testid="review-details-screen-preview"] iframe', + ], + }, + }, + decorators: [ + (Story, { parameters }) => ( + + + + + + ), + ], + beforeEach: () => { + eventListeners.clear(); + onMock.mockReset(); + offMock.mockReset(); + emitMock.mockReset(); + toggleNavMock.mockReset(); + fetchMock.mockClear(); + sessionStore.remove(RESTORE_NAV_SESSION_KEY); + // Reset change-detection statuses so a story marking one "new" doesn't leak + // into stories that assert the absence of the badge. + internal_fullStatusStore.unset(); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + return () => { + globalThis.fetch = originalFetch; + }; + }, +}); + +export const Collections = meta.story({ + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText(/Waiting for the agent/i)).toBeInTheDocument(); + await expect(emitMock).toHaveBeenCalledWith(EVENTS.REQUEST_REVIEW); + + applyReviewState(); + + await expect(await canvas.findByText('Manager settings polish')).toBeInTheDocument(); + await expect(await canvas.findByText('Settings')).toBeInTheDocument(); + }, +}); + +export const Details = meta.story({ + parameters: { + routerInitialEntries: ['/?path=/review/0/manager-settings-guidepage--default'], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(emitMock).toHaveBeenCalledWith(EVENTS.REQUEST_REVIEW); + + applyReviewState(); + + await expect(await canvas.findByRole('button', { name: '2/3' })).toBeInTheDocument(); + await expect(await canvas.findByRole('heading', { name: 'Settings' })).toBeInTheDocument(); + await expect( + await canvas.findByTitle('Latest manager-settings-guidepage--default') + ).toBeInTheDocument(); + // guidepage exists in the baseline index, so it must not be flagged "New". + await expect(canvas.queryByText('New')).not.toBeInTheDocument(); + }, +}); + +export const DetailsFocusesTitle = meta.story({ + parameters: { + routerInitialEntries: ['/?path=/review/0/manager-settings-guidepage--default'], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(emitMock).toHaveBeenCalledWith(EVENTS.REQUEST_REVIEW); + + applyReviewState(); + + // Opening a detail moves focus to its heading so users are oriented by what + // they opened. The summary's heading is inert/aria-hidden, so the detail + // heading is the only one the accessibility tree exposes. + const heading = await canvas.findByRole('heading', { name: 'Settings' }); + await waitFor(() => expect(heading).toHaveFocus()); + + // The summary stays mounted behind the detail screen… + const summaryHeading = canvas.getByText('Manager settings polish'); + expect(summaryHeading).toBeInTheDocument(); + // …but is inert and hidden from assistive tech so focus can't reach it. + const inertWrapper = summaryHeading.closest('[inert]'); + expect(inertWrapper).not.toBeNull(); + expect(inertWrapper).toHaveAttribute('aria-hidden', 'true'); + }, +}); + +export const DetailsNewStory = meta.story({ + parameters: { + routerInitialEntries: ['/?path=/review/0/manager-settings-checklist--default'], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(emitMock).toHaveBeenCalledWith(EVENTS.REQUEST_REVIEW); + + applyReviewState(); + + await expect( + await canvas.findByTitle('Latest manager-settings-checklist--default') + ).toBeInTheDocument(); + // checklist is absent from the baseline index, so it is newly added. + await expect(await canvas.findByText('New')).toBeInTheDocument(); + }, +}); + +export const DetailsChangeDetectedNew = meta.story({ + parameters: { + routerInitialEntries: ['/?path=/review/0/manager-settings-guidepage--default'], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // guidepage exists in the baseline index, so the baseline check alone would + // not flag it. Mark it new via the change-detection status store instead. + internal_fullStatusStore.set([ + { + storyId: 'manager-settings-guidepage--default', + typeId: 'storybook/change-detection', + value: 'status-value:new', + title: 'Change Detection', + description: '', + }, + ]); + + await expect(emitMock).toHaveBeenCalledWith(EVENTS.REQUEST_REVIEW); + + applyReviewState(); + + await expect( + await canvas.findByTitle('Latest manager-settings-guidepage--default') + ).toBeInTheDocument(); + // Flagged "New" by change-detection despite existing in the baseline. + await expect(await canvas.findByText('New')).toBeInTheDocument(); + }, +}); diff --git a/code/addons/review/src/ReviewPage.tsx b/code/addons/review/src/ReviewPage.tsx new file mode 100644 index 000000000000..0805eb2fe1d8 --- /dev/null +++ b/code/addons/review/src/ReviewPage.tsx @@ -0,0 +1,306 @@ +import React, { + type FC, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { + experimental_useStatusStore, + useChannel, + useStorybookApi, + useStorybookState, +} from 'storybook/manager-api'; +import { Location, useNavigate } from 'storybook/internal/router'; +import type { StatusesByStoryIdAndTypeId } from 'storybook/internal/types'; + +import type { StoryInfo } from './components/CollectionGrid.tsx'; +import { + BASELINE_INDEX_URL, + EVENTS, + RESTORE_NAV_SESSION_KEY, + REVIEW_CHANGES_URL, +} from './constants.ts'; +import { + buildReviewChangesDetailHref, + buildReviewChangesSummaryHref, + parseReviewChangesDetailLocation, +} from './review-navigation.ts'; +import type { ReviewState } from './review-state.ts'; +import { sessionStore } from './session-store.ts'; +import { DetailsScreen } from './screens/DetailsScreen.tsx'; +import { SummaryScreen } from './screens/SummaryScreen.tsx'; + +// Reading `location.search` from the router (rather than window.location) +// makes the page re-render on every in-page navigation, so the detail screen +// can swap stories without a manager reload. +export const ReviewPage: FC = () => ( + {({ location }) => } +); + +const ReviewPageContent: FC<{ search: string }> = ({ search }) => { + const [state, setState] = useState(null); + const [isStale, setIsStale] = useState(false); + // Story IDs present in the baseline Storybook's index. `null` means the + // baseline is unresolved or unavailable (no fetch yet, network/proxy error, + // or an unparseable index) — in which case it contributes nothing to "New" + // detection (a story can still be flagged "New" by change detection). + const [baselineStoryIds, setBaselineStoryIds] = useState | null>(null); + + const api = useStorybookApi(); + const { index } = useStorybookState(); + const navigate = useNavigate(); + + // Frozen preview thumbnails for the summary grid, built via the manager's + // canonical URL builder so they inherit globals like the rest of Storybook. + const getStoryPreviewHref = useCallback( + (storyId: string) => api.getStoryHrefs(storyId, { freeze: true }).previewHref, + [api] + ); + + const emit = useChannel({ + [EVENTS.DISPLAY_REVIEW]: (next: ReviewState) => { + setState(next); + // A fresh review resets staleness; a replayed (already-stale) one restores it. + setIsStale(!!next.stale); + }, + [EVENTS.REVIEW_STALE]: () => { + setIsStale(true); + }, + }); + + // Late/refreshed tab: ask the server to replay the cached overlay. + useEffect(() => { + emit(EVENTS.REQUEST_REVIEW); + }, [emit]); + + // Resolve which stories exist in the baseline so newly added stories can be + // flagged. Keyed on `createdAt`: a freshly pushed review re-fetches the + // baseline index. Any non-OK outcome leaves the set `null` (no badge). + const reviewCreatedAt = state?.createdAt; + useEffect(() => { + if (reviewCreatedAt === undefined) { + // Clear any prior baseline so it can't leak into the next review's "New" + // badges before its own index is fetched. + setBaselineStoryIds(null); + return undefined; + } + let cancelled = false; + setBaselineStoryIds(null); + fetch(BASELINE_INDEX_URL) + .then((response) => (response.ok ? response.json() : null)) + .then((data: { entries?: Record; stories?: Record }) => { + if (cancelled || !data) { + return; + } + const entries = data.entries ?? data.stories; + if (!entries || typeof entries !== 'object') { + return; + } + setBaselineStoryIds(new Set(Object.keys(entries))); + }) + .catch(() => { + // Baseline unavailable — leave `null` so it contributes nothing to + // "New" detection (change detection can still flag stories). + }); + return () => { + cancelled = true; + }; + }, [reviewCreatedAt]); + + const detailLocation = parseReviewChangesDetailLocation(search); + + // The review page is a focused, full-width surface — hide the manager + // sidebar while it is open and restore it on the way out. The user's prior + // sidebar state is stashed in sessionStorage so it survives the full-reload + // navigations between review screens; the cleanup restores it when the user + // leaves (unmount also fires on browser back/forward, which the manager + // router handles as an SPA transition). A user who keeps the sidebar + // collapsed by choice ('keep') is left untouched. + useEffect(() => { + if (sessionStore.read(RESTORE_NAV_SESSION_KEY) === null) { + sessionStore.write(RESTORE_NAV_SESSION_KEY, api.getIsNavShown() ? 'restore' : 'keep'); + } + api.toggleNav(false); + + return () => { + if (sessionStore.read(RESTORE_NAV_SESSION_KEY) === 'restore') { + api.toggleNav(true); + } + sessionStore.remove(RESTORE_NAV_SESSION_KEY); + }; + }, [api]); + + // Resolve each story's component title + name from the Storybook index. + // A story with no index entry is omitted: it has no resolvable metadata, so + // consumers treat it as invalid rather than guessing a label. + const storyInfo = useMemo(() => { + const info: Record = {}; + if (!state) { + return info; + } + for (const collection of state.collections) { + for (const storyId of collection.storyIds) { + if (storyId in info) { + continue; + } + const entry = index?.[storyId]; + if (entry && 'title' in entry && entry.title) { + info[storyId] = { title: entry.title, name: entry.name }; + } + } + } + return info; + }, [index, state]); + + // The single source of truth for "newly added" stories, resolved from two + // independent inputs so the render path can do a plain `.has(storyId)`: + // 1. Change detection (server-computed, delivered via the reactive status + // store): flags stories added/changed vs the git baseline. A story can + // be new here even when a deployed baseline exists (added on this branch). + // 2. The deployed baseline index, once resolved: flags stories absent from + // the deployed build. While it is unresolved/unavailable (`null`) it + // contributes nothing — only the change-detection signal applies. + const allStatuses = experimental_useStatusStore() as StatusesByStoryIdAndTypeId; + const newlyAddedStoryIds = useMemo(() => { + const ids = new Set(); + if (!state) { + return ids; + } + const isChangeDetectedNew = (storyId: string) => + Object.values(allStatuses[storyId] ?? {}).some( + (status) => status.value === 'status-value:new' + ); + for (const collection of state.collections) { + for (const storyId of collection.storyIds) { + const absentFromBaseline = baselineStoryIds !== null && !baselineStoryIds.has(storyId); + if (isChangeDetectedNew(storyId) || absentFromBaseline) { + ids.add(storyId); + } + } + } + return ids; + }, [allStatuses, baselineStoryIds, state]); + + // SPA navigation: imperatively attach a click listener to the container so + // left-clicks on in-page review links push history and swap the iframe URL + // without a manager reload (no flash). Real hrefs are kept for accessibility + // / middle-click / open-in-new-tab, which fall through untouched. Done via + // an effect rather than `onClick` on a div so `jsx-a11y/no-static-element- + // interactions` doesn't flag the delegation root — the div isn't itself + // interactive, it's just catching bubbled clicks from real elements. + const containerRef = useRef(null); + const summaryWrapperRef = useRef(null); + useEffect(() => { + const container = containerRef.current; + if (!container) { + return undefined; + } + const onClick = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + // `event.target` can be a non-Element node (e.g. a Text node), which has + // no `closest`; guard before treating it as an Element. + const { target } = event; + const anchor = target instanceof Element ? target.closest('a') : null; + const href = anchor?.getAttribute('href'); + if (!href || !href.startsWith(`?path=${REVIEW_CHANGES_URL}`)) { + return; + } + event.preventDefault(); + navigate(href, { plain: true }); + }; + container.addEventListener('click', onClick); + return () => container.removeEventListener('click', onClick); + }, [navigate]); + + let detailScreen: ReactNode = null; + if (state && detailLocation) { + const collection = state.collections[detailLocation.collectionIndex]; + if (collection && collection.storyIds.length > 0) { + const detailStoryIds = collection.storyIds; + const totalStories = detailStoryIds.length; + const resolvedIndexFromStoryId = + detailLocation.storyId !== undefined + ? detailStoryIds.findIndex((storyId) => storyId === detailLocation.storyId) + : -1; + const currentStoryIndex = resolvedIndexFromStoryId >= 0 ? resolvedIndexFromStoryId : 0; + const previousStoryIndex = (currentStoryIndex - 1 + totalStories) % totalStories; + const nextStoryIndex = (currentStoryIndex + 1) % totalStories; + + const currentStoryId = detailStoryIds[currentStoryIndex]; + const currentStoryInfo = storyInfo[currentStoryId]; + const currentStoryHrefs = api.getStoryHrefs(currentStoryId); + detailScreen = ( + + ); + } + } + + const hasDetailScreen = detailScreen !== null; + + // While the detail screen is open the summary stays mounted behind it, but + // must drop out of the tab order and the accessibility tree so keyboard and + // screen-reader focus can't reach it. React 18 doesn't serialize a boolean + // `inert` prop to the DOM, so toggle the property imperatively. + useEffect(() => { + const node = summaryWrapperRef.current; + if (node) { + node.inert = hasDetailScreen; + } + }, [hasDetailScreen]); + + return ( +
+
+
+ +
+ {hasDetailScreen ? ( +
{detailScreen}
+ ) : null} +
+
+ ); +}; diff --git a/code/addons/review/src/components/CollectionGrid.stories.tsx b/code/addons/review/src/components/CollectionGrid.stories.tsx new file mode 100644 index 000000000000..0b1424c45577 --- /dev/null +++ b/code/addons/review/src/components/CollectionGrid.stories.tsx @@ -0,0 +1,87 @@ +import { expect, within } from 'storybook/test'; + +import preview from '../../../../.storybook/preview.tsx'; +import { CollectionGrid, type StoryInfo } from './CollectionGrid.tsx'; + +const demoStoryIds = [ + 'button-component--base', + 'button-component--variants', + 'button-component--sizes', + 'manager-main--default', + 'manager-sidebar-sidebar--simple', + 'manager-settings-aboutscreen--default', + 'components-tabs-tabsview--basic', + 'bench--es-build-analyzer', +]; + +const titleCase = (value: string) => + value + .split(/[-/]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + +// Stand-in for the Storybook index: every demo story resolves to a title + +// name so the grid renders it (stories with no index entry are skipped). +const demoStoryInfo: Record = Object.fromEntries( + demoStoryIds.map((id) => { + const [componentId, ...rest] = id.split('--'); + return [id, { title: titleCase(componentId), name: titleCase(rest.join('--')) || 'Story' }]; + }) +); + +const meta = preview.meta({ + component: CollectionGrid, + parameters: { + layout: 'fullscreen', + chromatic: { + ignoreSelectors: ['[data-testid="review-collection-grid-cell"] iframe'], + }, + }, + args: { + storyIds: demoStoryIds, + storyInfo: demoStoryInfo, + getStoryPreviewHref: (storyId: string) => + `iframe.html?id=${encodeURIComponent(storyId)}&viewMode=story&freeze=finished`, + }, +}); + +export const Default = meta.story({}); + +// On a narrow (mobile) container the grid drops to a single column and caps at +// two rows, so eight stories overflow into the "Review all" affordance. +export const ManyStoriesOverflow = meta.story({ + globals: { viewport: { value: 'mobile1' } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole('button', { name: /Review all/i })).toBeInTheDocument(); + }, +}); + +export const FewStories = meta.story({ + args: { + storyIds: ['manager-main--default', 'manager-settings-aboutscreen--default'], + }, + globals: { viewport: { value: 'desktop' } }, + play: async ({ canvasElement }) => { + const cells = canvasElement.querySelectorAll('[data-testid="review-collection-grid-cell"]'); + await expect(cells.length).toBe(2); + await expect( + canvasElement.querySelector('[data-review-all] button') + ).not.toBeVisible(); + }, +}); + +// A single preview clamps to 400px instead of stretching to fill the card, so +// the grid layout stays consistent regardless of story count. +export const SingleCellClamped = meta.story({ + args: { + storyIds: ['manager-main--default'], + }, + globals: { viewport: { value: 'desktop' } }, + play: async ({ canvasElement }) => { + const cell = canvasElement.querySelector('[data-testid="review-collection-grid-cell"]'); + await expect(cell).toBeTruthy(); + await expect((cell as HTMLElement).getBoundingClientRect().width).toBeLessThanOrEqual(401); + }, +}); diff --git a/code/addons/review/src/components/CollectionGrid.tsx b/code/addons/review/src/components/CollectionGrid.tsx new file mode 100644 index 000000000000..32ce0645828f --- /dev/null +++ b/code/addons/review/src/components/CollectionGrid.tsx @@ -0,0 +1,493 @@ +import React, { useCallback, useEffect, useRef, useState, type FC } from 'react'; + +import { Button } from 'storybook/internal/components'; +import { styled } from 'storybook/theming'; + +import { Highlight } from './Highlight.tsx'; + +const PREVIEW_SCALE = 0.5; + +/** + * Each thumbnail is a full Storybook preview iframe, and booting one fires + * dozens of module requests. Mounting every in-view thumbnail at once (wide + * grids, or "Review all") floods the browser's connection pool and trips + * `net::ERR_INSUFFICIENT_RESOURCES`, leaving some iframes permanently blank. + * + * The scheduler below caps how many previews boot concurrently across the + * whole review page. A cell enqueues a task before it sets the iframe `src`; + * the task starts when a slot is free and finishes once the iframe loads + * (or errors / settles), so previews drain in waves instead of all at once. + * Hovering a cell promotes its task to start immediately, bypassing the cap. + */ +const MAX_CONCURRENT_PREVIEWS = 3; +/** + * A started preview frees its slot when its iframe fires `load`, or after this + * delay — whichever comes first. The timeout guarantees the queue keeps + * draining automatically even when `load` is slow or never fires (so previews + * advance in steady waves rather than waiting on a single stuck frame), while + * still rate-limiting how fast new iframes boot to avoid flooding the network. + */ +const PREVIEW_SETTLE_TIMEOUT_MS = 1500; + +// IntersectionObserver `rootMargin`s for the preview lifecycle: mount a cell's +// iframe within one root-height of the fold, evict it past two. The gap is +// hysteresis so scrolling near a boundary doesn't thrash. These margins only +// take effect when the observer's `root` is the real scroll container (see +// `getScrollRoot`); `rootMargin` is not applied to intermediate scrollers, so +// against the default viewport root they would be silently clipped to zero. +const PREVIEW_MOUNT_ROOT_MARGIN = '100% 0px'; +const PREVIEW_EVICT_ROOT_MARGIN = '200% 0px'; + +interface PreviewTask { + /** Assigns the iframe src, kicking off the actual load. */ + start: () => void; + started: boolean; + finished: boolean; +} + +let activePreviewLoads = 0; +const previewQueue: PreviewTask[] = []; + +function startQueuedPreviews(): void { + while (activePreviewLoads < MAX_CONCURRENT_PREVIEWS) { + const task = previewQueue.shift(); + if (!task) { + return; + } + if (task.started || task.finished) { + continue; + } + task.started = true; + activePreviewLoads += 1; + task.start(); + } +} + +function enqueuePreview(task: PreviewTask): void { + previewQueue.push(task); + startQueuedPreviews(); +} + +/** Mark a task done (load/error/settle/unmount) and let the next one start. */ +function finishPreview(task: PreviewTask): void { + if (task.finished) { + return; + } + task.finished = true; + if (task.started) { + activePreviewLoads = Math.max(0, activePreviewLoads - 1); + } else { + const index = previewQueue.indexOf(task); + if (index !== -1) { + previewQueue.splice(index, 1); + } + } + startQueuedPreviews(); +} + +/** Hover/focus: start a still-queued preview right away, bypassing the cap. */ +function forceStartPreview(task: PreviewTask): void { + if (task.started || task.finished) { + return; + } + const index = previewQueue.indexOf(task); + if (index !== -1) { + previewQueue.splice(index, 1); + } + task.started = true; + activePreviewLoads += 1; + task.start(); +} + +export interface StoryInfo { + title: string; + name: string; +} + +// Per-breakpoint grid: `cols` columns (each cell clamped to 400px) capped at +// two rows. Overflow beyond the cap is hidden and a "Review all" cell takes the +// last slot — all via CSS (`:has()` + `:nth-child`), no JS measurement. +const band = (cols: number) => { + const cap = cols * 2; + return { + gridTemplateColumns: `repeat(${cols}, minmax(0, 400px))`, + [`&:not([data-show-all]):has(> [data-cell]:nth-child(${cap + 1})) > [data-cell]:nth-child(n + ${cap})`]: + { + display: 'none', + }, + [`&:not([data-show-all]):has(> [data-cell]:nth-child(${cap + 1})) > [data-review-all]`]: { + display: 'grid', + }, + }; +}; + +const GridContainer = styled.div({ + containerType: 'inline-size', + containerName: 'review-grid', +}); + +const Grid = styled.div({ + display: 'grid', + gap: 12, + padding: 12, + justifyContent: 'start', + // Fallback for browsers without container-query support: a single column and + // no two-row cap (every story is shown). + gridTemplateColumns: 'minmax(0, 400px)', + // Bands are mutually exclusive (ranged) so a narrower band's overflow rules + // never bleed into a wider one. The .98 upper bounds sit just below the next + // band's integer `min-width` so the two never both match at the boundary. + '@container review-grid (max-width: 629.98px)': band(1), + '@container review-grid (min-width: 630px) and (max-width: 844.98px)': band(2), + '@container review-grid (min-width: 845px) and (max-width: 1259.98px)': band(3), + '@container review-grid (min-width: 1260px)': band(4), +}); + +const Cell = styled.div({ + display: 'flex', + flexDirection: 'column', + minWidth: 0, +}); + +// The bordered, clickable preview frame. Rendered as an
when a detail href +// is provided, otherwise a plain
. Hover and keyboard focus are indicated +// here (not on the surrounding cell) since the frame is the interactive target. +const Frame = styled.a(({ theme }) => ({ + position: 'relative', + display: 'block', + width: '100%', + aspectRatio: '3 / 2', + borderRadius: 6, + overflow: 'hidden', + background: theme.background.app, + border: `1px solid ${theme.appBorderColor}`, + transition: 'border-color 120ms ease', + textDecoration: 'none', + outline: 'none', + '&[href]:hover': { + borderColor: theme.color.secondary, + }, + '&:focus-visible': { + outline: `${theme.barSelectedColor} solid 2px`, + outlineOffset: 2, + }, +})); + +const Preview = styled.iframe({ + position: 'absolute', + inset: 0, + width: `${(1 / PREVIEW_SCALE) * 100}%`, + height: `${(1 / PREVIEW_SCALE) * 100}%`, + border: 0, + display: 'block', + transform: `scale(${PREVIEW_SCALE})`, + transformOrigin: 'top left', + pointerEvents: 'none', +}); + +// The info/action bar below the preview: the component/story label stretches +// and ellipsizes on the left; the action slot on the right never wraps. +const ActionBar = styled.div({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + minHeight: 36, +}); + +const Label = styled.div({ + display: 'flex', + alignItems: 'center', + gap: 4, + flex: 1, + minWidth: 0, + marginLeft: 10, + overflow: 'hidden', +}); + +const LabelComponent = styled.span({ + fontWeight: 700, + whiteSpace: 'nowrap', + flexShrink: 0, + maxWidth: '60%', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); + +const LabelSeparator = styled.span(({ theme }) => ({ + color: theme.textMutedColor, + flexShrink: 0, +})); + +const LabelStory = styled.span({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); + +const ActionSlot = styled.div({ + display: 'flex', + alignItems: 'center', + gap: 4, + flexShrink: 0, + whiteSpace: 'nowrap', +}); + +const ReviewAllCell = styled.div(({ theme }) => ({ + display: 'none', + placeItems: 'center', + width: '100%', + aspectRatio: '3 / 2', + borderRadius: 6, + background: theme.background.app, + border: `1px dashed ${theme.appBorderColor}`, +})); + +// The cell lives inside a scrollable container (the review page keeps a single +// Radix ScrollArea as its scroller), not the document viewport. The lifecycle +// observers must use that container as their `root`, otherwise `rootMargin` is +// ignored and cells evict the moment they leave the scroller. Falls back to the +// viewport (null) when there is no scroll container, e.g. fullscreen stories. +const getScrollRoot = (element: HTMLElement): HTMLElement | null => { + const radixViewport = element.closest('[data-radix-scroll-area-viewport]'); + if (radixViewport) { + return radixViewport; + } + let current = element.parentElement; + while (current) { + const { overflowY } = window.getComputedStyle(current); + if (overflowY === 'auto' || overflowY === 'scroll') { + return current; + } + current = current.parentElement; + } + return null; +}; + +const isWithinPreloadRange = ( + element: HTMLElement, + root: HTMLElement | null, + margin: number +): boolean => { + const rect = element.getBoundingClientRect(); + // Hidden cells (e.g. overflow beyond the two-row cap) have a zero-size box; + // don't seed them in-view or they'd boot iframes that never show. + if (rect.width === 0 && rect.height === 0) { + return false; + } + if (root) { + const rootRect = root.getBoundingClientRect(); + return rect.bottom >= rootRect.top - margin && rect.top <= rootRect.bottom + margin; + } + const viewportHeight = + typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerHeight || 0; + return rect.bottom >= -margin && rect.top <= viewportHeight + margin; +}; + +const deriveStoryInfo = (info: StoryInfo): { component: string; name: string } => ({ + component: info.title.split('/').pop() ?? info.title, + name: info.name, +}); + +const StoryPreviewCell: FC<{ + storyId: string; + href?: string; + info: StoryInfo; + query: string; + getPreviewHref: (storyId: string) => string; +}> = ({ storyId, href, info, query, getPreviewHref }) => { + const hostRef = useRef(null); + const [isInView, setIsInView] = useState(false); + // `src` stays unset until the scheduler starts this preview; the iframe only + // mounts (and starts requesting) once it does. + const [src, setSrc] = useState(undefined); + const taskRef = useRef(null); + + useEffect(() => { + const host = hostRef.current; + if (!host) { + return undefined; + } + if (typeof IntersectionObserver === 'undefined') { + setIsInView(true); + return undefined; + } + const scrollRoot = getScrollRoot(host); + // Snappy first paint for above-the-fold cells: the observers' initial + // callbacks are deferred to the next frame, so seed the state synchronously. + const seedMargin = scrollRoot + ? scrollRoot.clientHeight + : typeof window === 'undefined' + ? 0 + : window.innerHeight; + if (isWithinPreloadRange(host, scrollRoot, seedMargin)) { + setIsInView(true); + } + // Mount when the cell comes within the mount margin of the scroll root. + const mountObserver = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsInView(true); + } + }, + { root: scrollRoot, rootMargin: PREVIEW_MOUNT_ROOT_MARGIN } + ); + // Evict once the cell moves beyond the (larger) evict margin, unmounting + // its iframe to free the preview runtime. The margin gap is hysteresis. + const evictObserver = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) { + setIsInView(false); + } + }, + { root: scrollRoot, rootMargin: PREVIEW_EVICT_ROOT_MARGIN } + ); + mountObserver.observe(host); + evictObserver.observe(host); + return () => { + mountObserver.disconnect(); + evictObserver.disconnect(); + }; + }, []); + + // Eviction: when the cell scrolls well out of range, drop the iframe so its + // preview runtime is reclaimed. It re-enqueues (below) if it scrolls back. + useEffect(() => { + if (!isInView) { + setSrc(undefined); + } + }, [isInView]); + + // Once in view, enqueue a scheduler task; it sets `src` when a slot frees. + useEffect(() => { + if (!isInView) { + return undefined; + } + const task: PreviewTask = { + start: () => setSrc(getPreviewHref(storyId)), + started: false, + finished: false, + }; + taskRef.current = task; + enqueuePreview(task); + return () => { + // Unmounting / scrolling away before load frees the slot (or dequeues). + finishPreview(task); + taskRef.current = null; + }; + }, [isInView, storyId, getPreviewHref]); + + const finishCurrent = useCallback(() => { + if (taskRef.current) { + finishPreview(taskRef.current); + } + }, []); + + // Hover or keyboard focus promotes this preview to start immediately. + const forceStartCurrent = useCallback(() => { + if (taskRef.current) { + forceStartPreview(taskRef.current); + } + }, []); + + // Free this preview's slot a short time after it starts even if `load` + // hasn't fired, so the queue keeps draining automatically in steady waves. + useEffect(() => { + if (!src) { + return undefined; + } + const timer = setTimeout(finishCurrent, PREVIEW_SETTLE_TIMEOUT_MS); + return () => clearTimeout(timer); + }, [src, finishCurrent]); + + const { component, name } = deriveStoryInfo(info); + + return ( + + } + aria-label={href ? `Review story ${storyId}` : undefined} + onMouseEnter={forceStartCurrent} + onFocus={forceStartCurrent} + > + {src ? ( + + ) : null} + + + + + + + ); +}; + +export interface CollectionGridProps { + storyIds: string[]; + getStoryHref?: (storyId: string, storyIndex: number) => string | undefined; + /** Builds the (frozen) preview iframe src for a story thumbnail. */ + getStoryPreviewHref: (storyId: string) => string; + /** Persisted "review all" state from the parent list. */ + showAll?: boolean; + /** Called when the user expands to "Review all". */ + onShowAll?: () => void; + /** Story id → component title + story name, for the cell label. */ + storyInfo: Record; + /** Active search query — matches in the cell label are highlighted. */ + query?: string; +} + +export const CollectionGrid: FC = ({ + storyIds, + getStoryHref, + getStoryPreviewHref, + showAll = false, + onShowAll, + storyInfo, + query = '', +}) => ( + + + {storyIds.map((storyId, storyIndex) => { + // A story with no index entry has no resolvable label and is treated as + // invalid, so it is skipped rather than rendered with a guessed name. + const info = storyInfo[storyId]; + if (!info) { + return null; + } + return ( + + ); + })} + + + + + +); diff --git a/code/addons/review/src/components/CopyButton.tsx b/code/addons/review/src/components/CopyButton.tsx new file mode 100644 index 000000000000..92ba6d1feb13 --- /dev/null +++ b/code/addons/review/src/components/CopyButton.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react'; +import React from 'react'; +import { + Button, + type ButtonProps, + type UseCopyButtonOptions, + useCopyButton, +} from 'storybook/internal/components'; + +export type CopyButtonProps = Omit & + UseCopyButtonOptions; + +/** Button that copies text to the clipboard and shows a copied state. */ +export function CopyButton({ + children, + childrenOnCopy, + content, + onCopy, + ariaLabel, + ariaLabelOnCopy, + duration, + ...buttonProps +}: CopyButtonProps) { + const { children: buttonChildren, buttonProps: copyButtonProps } = useCopyButton({ + children, + childrenOnCopy, + content, + onCopy, + ariaLabel, + ariaLabelOnCopy, + duration, + }); + + return ( + + ); +} diff --git a/code/addons/review/src/components/Highlight.tsx b/code/addons/review/src/components/Highlight.tsx new file mode 100644 index 000000000000..809328e9f3a8 --- /dev/null +++ b/code/addons/review/src/components/Highlight.tsx @@ -0,0 +1,44 @@ +import React, { type FC, type ReactNode } from 'react'; + +import { styled } from 'storybook/theming'; + +const Mark = styled.mark(({ theme }) => ({ + background: theme.color.secondary, + color: theme.color.lightest, + borderRadius: 2, + padding: '0 1px', + margin: '0 -1px', + fontWeight: 'inherit', + '@media (forced-colors: active)': { + color: 'HighlightText', + background: 'Highlight', + }, +})); + +// Render `text`, wrapping every case-insensitive occurrence of `query` in a +// . With no query the text renders untouched. +export const Highlight: FC<{ text: string; query: string }> = ({ text, query }) => { + const searchQuery = query.trim().toLowerCase(); + if (!searchQuery) { + return <>{text}; + } + const lowerCaseText = text.toLowerCase(); + const segments: ReactNode[] = []; + let cursor = 0; + let matchIndex = lowerCaseText.indexOf(searchQuery); + let key = 0; + while (matchIndex !== -1) { + if (matchIndex > cursor) { + segments.push(text.slice(cursor, matchIndex)); + } + segments.push( + {text.slice(matchIndex, matchIndex + searchQuery.length)} + ); + cursor = matchIndex + searchQuery.length; + matchIndex = lowerCaseText.indexOf(searchQuery, cursor); + } + if (cursor < text.length) { + segments.push(text.slice(cursor)); + } + return <>{segments}; +}; diff --git a/code/addons/review/src/components/ReviewHeader.tsx b/code/addons/review/src/components/ReviewHeader.tsx new file mode 100644 index 000000000000..831264fe5a56 --- /dev/null +++ b/code/addons/review/src/components/ReviewHeader.tsx @@ -0,0 +1,127 @@ +import React, { type FC, type ReactNode, useEffect, useRef } from 'react'; + +import { styled } from 'storybook/theming'; + +const Root = styled.header(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + background: theme.background.content, + color: theme.color.defaultText, + borderBottom: `1px solid ${theme.appBorderColor}`, +})); + +const TopRow = styled.div({ + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-start', + gap: 8, + padding: '16px 16px 8px 16px', + minHeight: 40, + '&:last-of-type': { + paddingBottom: 16, + }, +}); + +const Leading = styled.div({ + display: 'flex', + alignItems: 'center', + flexShrink: 0, +}); + +const TextBlock = styled.div({ + display: 'flex', + flexDirection: 'column', + gap: 2, + flexGrow: 1, + minWidth: 0, +}); + +const Title = styled.h1(({ theme }) => ({ + margin: 0, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: theme.typography.size.m1, + fontWeight: theme.typography.weight.bold, + lineHeight: '24px', + // The heading is only focused programmatically on route change (see + // autoFocusTitle); it is not an interactive control, so suppress the ring. + '&:focus': { + outline: 'none', + }, +})); + +const Subtitle = styled.div(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: 5, + color: theme.textMutedColor, + fontSize: theme.typography.size.s2, + lineHeight: '20px', +})); + +const Actions = styled.div({ + display: 'flex', + alignItems: 'center', + gap: 6, + flexShrink: 0, +}); + +const SecondRow = styled.div({ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '8px 12px 8px 16px', + minHeight: 40, +}); + +export interface ReviewHeaderProps { + /** Optional control rendered before the title (e.g. a back button). */ + leading?: ReactNode; + title: ReactNode; + subtitle?: ReactNode; + /** Trailing cluster on the right of the top row. */ + actions?: ReactNode; + /** Optional full-width second row (e.g. search or comparison controls). */ + secondRow?: ReactNode; + /** + * Move keyboard focus to the title heading on mount. Used on route changes + * (e.g. opening the detail screen) so assistive tech lands on the new view's + * heading instead of being left on the now-unmounted trigger. + */ + autoFocusTitle?: boolean; +} + +export const ReviewHeader: FC = ({ + leading, + title, + subtitle, + actions, + secondRow, + autoFocusTitle = false, +}) => { + const titleRef = useRef(null); + useEffect(() => { + if (autoFocusTitle) { + titleRef.current?.focus(); + } + }, [autoFocusTitle]); + + return ( + + + {leading ? {leading} : null} + + + {title} + + {subtitle ? {subtitle} : null} + + {actions ? {actions} : null} + + {secondRow ? {secondRow} : null} + + ); +}; diff --git a/code/addons/review/src/components/StaleBanner.tsx b/code/addons/review/src/components/StaleBanner.tsx new file mode 100644 index 000000000000..bde3738998fc --- /dev/null +++ b/code/addons/review/src/components/StaleBanner.tsx @@ -0,0 +1,40 @@ +import React, { type FC } from 'react'; + +import { styled } from 'storybook/theming'; + +import { CheckIcon, WandIcon } from '@storybook/icons'; +import { CopyButton } from './CopyButton.tsx'; + +const Bar = styled.div(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + flexShrink: 0, + padding: '4px 16px', + background: theme.background.hoverable, + color: theme.color.defaultText, + borderBottom: `1px solid ${theme.appBorderColor}`, + fontSize: theme.typography.size.s2, + minHeight: 32, +})); + +/** + * Attention bar shown at the top of the review screens when the cached review + * has been marked stale (a source file changed after it was created). + */ +export const StaleBanner: FC = () => ( + + This review may be stale. Ask your agent to refresh it. + } + > + + + +); diff --git a/code/addons/review/src/constants.ts b/code/addons/review/src/constants.ts new file mode 100644 index 000000000000..0fbc5285734c --- /dev/null +++ b/code/addons/review/src/constants.ts @@ -0,0 +1,38 @@ +export const ADDON_ID = 'storybook/addon-review'; +export const PAGE_ID = `${ADDON_ID}/page`; +export const REVIEW_CHANGES_URL = '/review/'; + +// Dev-server route (declared in preset.ts) that proxies the deployed baseline +// Storybook. Shared contract between the server-side proxy and the client-side +// baseline iframes / index fetch — keep all consumers pointed at this constant. +export const BASELINE_PROXY_PATH = '/__review-baseline'; +// The baseline Storybook's index, used to detect stories absent from the +// baseline. Derived from the proxy path so the route stays single-sourced. +export const BASELINE_INDEX_URL = `${BASELINE_PROXY_PATH}/index.json`; + +// sessionStorage key recording whether the manager sidebar (hidden while the +// review page is open) should be restored when the user leaves. Survives the +// full-reload navigations between review screens. Value: 'restore' | 'keep'. +export const RESTORE_NAV_SESSION_KEY = `${ADDON_ID}/restore-nav`; + +// sessionStorage key remembering the detail screen's preview layout so it +// persists as the user moves between the detail and summary screens (and +// across the full-reload navigations between them). Value: '1up' | '2up'. +export const PREVIEW_MODE_SESSION_KEY = `${ADDON_ID}/preview-mode`; + +// `@storybook/addon-mcp` display-review tool call emits this event with the raw agent payload. +const PUSH_REVIEW = `${ADDON_ID}/push-review`; +// Display agent review in the UI +const DISPLAY_REVIEW = `${ADDON_ID}/display-review`; +// Requests for the cached state of the agent review +const REQUEST_REVIEW = `${ADDON_ID}/request-review`; +// Server signals that a source file changed after the cached review was created, +// so the open review page should surface a "may be stale" banner. +const REVIEW_STALE = `${ADDON_ID}/review-stale`; + +export const EVENTS = { + PUSH_REVIEW, + DISPLAY_REVIEW, + REQUEST_REVIEW, + REVIEW_STALE, +}; diff --git a/code/addons/review/src/index.ts b/code/addons/review/src/index.ts new file mode 100644 index 000000000000..e17cec6c1d47 --- /dev/null +++ b/code/addons/review/src/index.ts @@ -0,0 +1,2 @@ +export { ADDON_ID, PAGE_ID, REVIEW_CHANGES_URL, EVENTS } from './constants.ts'; +export type { ReviewState, ReviewCollection, CollectionKind } from './review-state.ts'; diff --git a/code/addons/review/src/manager.tsx b/code/addons/review/src/manager.tsx new file mode 100644 index 000000000000..4c40bebf4074 --- /dev/null +++ b/code/addons/review/src/manager.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { addons, types } from 'storybook/manager-api'; +import { Route } from 'storybook/internal/router'; + +import { + ADDON_ID, + PAGE_ID, + REVIEW_CHANGES_URL, + RESTORE_NAV_SESSION_KEY, + EVENTS, +} from './constants.ts'; +import { ReviewPage } from './ReviewPage.tsx'; +import { sessionStore } from './session-store.ts'; + +addons.register(ADDON_ID, (api) => { + // Safety net: the review page hides the sidebar and has no in-app exit, so + // if the user left it via a full reload (typed URL, bookmark) the + // component cleanup never ran. On any manager load that is NOT the review + // route, restore a sidebar we hid. SPA exits (browser back) are already + // handled by ReviewPage's effect cleanup. + const path = new URLSearchParams(window.location.search).get('path') ?? ''; + const restoreNav = sessionStore.read(RESTORE_NAV_SESSION_KEY); + if (!path.startsWith(REVIEW_CHANGES_URL) && restoreNav !== null) { + // Clear both 'restore' and 'keep' so a stale marker can't block fresh + // nav-state capture on the next review visit. + sessionStore.remove(RESTORE_NAV_SESSION_KEY); + if (restoreNav === 'restore') { + api.toggleNav(true); + } + } + + // When the agent pushes a review, pull any open tab to the page — but only + // if it is not already there. The review page replays cached state on load + // (REQUEST_REVIEW), which echoes back as DISPLAY_REVIEW; without + // this guard that echo would re-navigate to the bare review URL, dropping + // the detail subpath and bouncing a detail page to the summary. + api.getChannel()?.on(EVENTS.DISPLAY_REVIEW, () => { + const currentPath = new URLSearchParams(window.location.search).get('path') ?? ''; + if (!currentPath.startsWith(REVIEW_CHANGES_URL)) { + api.navigate(REVIEW_CHANGES_URL); + } + }); + + addons.add(PAGE_ID, { + type: types.experimental_PAGE, + url: REVIEW_CHANGES_URL, + title: 'Review changes', + render: () => ( + + + + ), + }); +}); diff --git a/code/addons/review/src/preset.test.ts b/code/addons/review/src/preset.test.ts new file mode 100644 index 000000000000..1d5eb8590b8c --- /dev/null +++ b/code/addons/review/src/preset.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Channel } from 'storybook/internal/channels'; +import type { Options } from 'storybook/internal/types'; + +import { EVENTS } from './constants.ts'; +import type { ReviewState } from './review-state.ts'; +import { __resetCache, experimental_serverChannel } from './preset.ts'; + +function createMockSubscribe() { + let captured: (() => void) | undefined; + const subscribeToModuleGraphChanges = vi.fn((onChange: () => void) => { + captured = onChange; + return () => { + captured = undefined; + }; + }); + return { + subscribeToModuleGraphChanges, + fireChange: () => captured?.(), + }; +} + +function createMockChannel() { + const listeners = new Map void>>(); + const emitted: Array<{ event: string; payload: unknown }> = []; + + const channel = { + on: vi.fn((event: string, listener: (...args: any[]) => void) => { + const arr = listeners.get(event) ?? []; + arr.push(listener); + listeners.set(event, arr); + }), + emit: vi.fn((event: string, payload?: unknown) => { + emitted.push({ event, payload }); + }), + fire: async (event: string, ...args: unknown[]) => { + const arr = listeners.get(event) ?? []; + for (const listener of arr) { + await listener(...args); + } + }, + } as unknown as Channel & { + fire: (event: string, ...args: unknown[]) => Promise; + }; + + return { channel, emitted }; +} + +const sampleReview: ReviewState = { + title: 'Recolour the primary button', + description: 'Button background changed from blue to green.', + collections: [ + { + title: 'Button', + rationale: 'The directly changed component.', + storyIds: ['button--primary'], + kind: 'atomic', + }, + ], +}; + +describe('addon-review experimental_serverChannel', () => { + const NOW = 1_700_000_000_000; + + beforeEach(() => { + __resetCache(); + vi.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('on PUSH_REVIEW, stamps createdAt, caches, and broadcasts DISPLAY_REVIEW', async () => { + const { channel, emitted } = createMockChannel(); + + await experimental_serverChannel(channel, {} as Options, {}); + await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + + expect(emitted).toEqual([ + { event: EVENTS.DISPLAY_REVIEW, payload: { ...sampleReview, createdAt: NOW } }, + ]); + }); + + it('drops an agent-supplied stale flag so a fresh push starts non-stale', async () => { + const { channel, emitted } = createMockChannel(); + const payloadWithStale: ReviewState = { ...sampleReview, stale: true }; + + await experimental_serverChannel(channel, {} as Options, {}); + await (channel as any).fire(EVENTS.PUSH_REVIEW, payloadWithStale); + + expect(emitted).toEqual([ + { event: EVENTS.DISPLAY_REVIEW, payload: { ...sampleReview, createdAt: NOW } }, + ]); + expect((emitted[0].payload as ReviewState).stale).toBeUndefined(); + }); + + it('on REQUEST_REVIEW with no cached state, emits nothing', async () => { + const { channel, emitted } = createMockChannel(); + + await experimental_serverChannel(channel, {} as Options, {}); + await (channel as any).fire(EVENTS.REQUEST_REVIEW); + + expect(emitted).toEqual([]); + }); + + it('on REQUEST_REVIEW after a PUSH_REVIEW, replays the cached payload', async () => { + const { channel, emitted } = createMockChannel(); + + await experimental_serverChannel(channel, {} as Options, {}); + await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + emitted.length = 0; + await (channel as any).fire(EVENTS.REQUEST_REVIEW); + + expect(emitted).toEqual([ + { event: EVENTS.DISPLAY_REVIEW, payload: { ...sampleReview, createdAt: NOW } }, + ]); + }); + + it('registers exactly one listener per cross-repo event', async () => { + const { channel } = createMockChannel(); + + await experimental_serverChannel(channel, {} as Options, { + subscribeToModuleGraphChanges: vi.fn(() => () => {}), + }); + + expect(channel.on).toHaveBeenCalledWith(EVENTS.PUSH_REVIEW, expect.any(Function)); + expect(channel.on).toHaveBeenCalledWith(EVENTS.REQUEST_REVIEW, expect.any(Function)); + expect(channel.on).toHaveBeenCalledTimes(2); + }); + + describe('staleness', () => { + const setup = async () => { + const { channel, emitted } = createMockChannel(); + const { subscribeToModuleGraphChanges, fireChange } = createMockSubscribe(); + await experimental_serverChannel(channel, {} as Options, { + subscribeToModuleGraphChanges, + }); + return { channel, emitted, fireChange }; + }; + + const staleOf = (emitted: Array<{ event: string; payload: unknown }>) => + emitted.filter((e) => e.event === EVENTS.REVIEW_STALE); + + it('marks the cached review stale and emits REVIEW_STALE after the grace window', async () => { + const { channel, emitted, fireChange } = await setup(); + await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + + // Past the grace window relative to createdAt (NOW). + vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000); + fireChange(); + + expect(staleOf(emitted)).toHaveLength(1); + + // Replay to a late tab carries the staleness on the cached state. + emitted.length = 0; + await (channel as any).fire(EVENTS.REQUEST_REVIEW); + expect(emitted).toEqual([ + { + event: EVENTS.DISPLAY_REVIEW, + payload: { + ...sampleReview, + createdAt: NOW, + stale: true, + }, + }, + ]); + }); + + it('ignores source changes within the grace window', async () => { + const { channel, emitted, fireChange } = await setup(); + await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + + // Date.now is still NOW (mocked in beforeEach) → within grace. + fireChange(); + + expect(staleOf(emitted)).toHaveLength(0); + emitted.length = 0; + await (channel as any).fire(EVENTS.REQUEST_REVIEW); + expect((emitted[0].payload as ReviewState).stale).toBeUndefined(); + }); + + it('ignores source changes when no review is cached', async () => { + const { emitted, fireChange } = await setup(); + + vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000); + fireChange(); + + expect(emitted).toEqual([]); + }); + + it('emits REVIEW_STALE only once across multiple changes', async () => { + const { channel, emitted, fireChange } = await setup(); + await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + + vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000); + fireChange(); + fireChange(); + fireChange(); + + expect(staleOf(emitted)).toHaveLength(1); + }); + + it('resets staleness when a new review is pushed', async () => { + const { channel, emitted, fireChange } = await setup(); + await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + + vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000); + fireChange(); + expect(staleOf(emitted)).toHaveLength(1); + + // A fresh push re-anchors createdAt and clears stale. + await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + emitted.length = 0; + await (channel as any).fire(EVENTS.REQUEST_REVIEW); + expect((emitted[0].payload as ReviewState).stale).toBeUndefined(); + }); + }); +}); diff --git a/code/addons/review/src/preset.ts b/code/addons/review/src/preset.ts new file mode 100644 index 000000000000..e98fca6b8b56 --- /dev/null +++ b/code/addons/review/src/preset.ts @@ -0,0 +1,235 @@ +import { existsSync, statSync } from 'node:fs'; +import { isAbsolute, relative, resolve } from 'node:path'; + +import { createProxyMiddleware } from 'http-proxy-middleware'; +import sirv from 'sirv'; +import type { Channel } from 'storybook/internal/channels'; +import { logger } from 'storybook/internal/node-logger'; +import type { Middleware, Options, ServerApp } from 'storybook/internal/types'; + +import type { moduleGraphServiceDef } from 'storybook/internal/core-server'; + +import { BASELINE_PROXY_PATH, EVENTS } from './constants.ts'; +import type { ReviewState } from './review-state.ts'; + +/** + * Window after a review's `createdAt` during which graph changes are ignored. + * Absorbs the agent's own edits (which precede the display-review call) whose + * file-system events may land a few milliseconds after the review is cached, + * preventing a freshly-pushed review from being marked stale immediately. + */ +const STALE_GRACE_MS = 1000; + +type SubscribeToModuleGraphChanges = (onChange: () => void) => () => void; + +/** + * Default subscription to the `core/module-graph` open service. The review goes + * stale when any file in the story module graph changes (the service's revision + * only advances for in-graph changes, so unrelated file edits never trip it). + * The service is imported lazily so merely loading this preset (e.g. in unit + * tests) does not pull in core-server; if the service is unavailable (e.g. a + * builder without module-graph support), staleness simply never triggers. + */ +const defaultSubscribeToModuleGraphChanges: SubscribeToModuleGraphChanges = (onChange) => { + let unsubscribe: () => void = () => {}; + let cancelled = false; + void import('storybook/internal/core-server') + .then(({ getService }) => { + if (cancelled) { + return; + } + const service = getService('core/module-graph'); + // Omit the input to watch the entire graph. The initial emission carries + // revision 0 (or the current revision at subscribe time); only subsequent + // advances represent a change after the review was cached. + unsubscribe = service.queries.getGraphRevision.subscribe(undefined, (revision) => { + if (revision > 0) { + onChange(); + } + }); + }) + .catch(() => { + // Module graph unavailable (e.g. builder without support); no staleness. + }); + return () => { + cancelled = true; + unsubscribe(); + }; +}; + +// Server-side cache for the agent-pushed review. Storybook's dev server is +// long-lived; this single slot survives across reconnecting browser tabs and +// is what REQUEST_REVIEW replays. It is intentionally not persisted to disk — +// a dev-server restart wipes the slate. +let cached: ReviewState | undefined; + +/** Test-only: reset the module-level cache between cases. */ +export function __resetCache(): void { + cached = undefined; +} + +function prepareReview(payload: ReviewState): ReviewState { + // Staleness is server-authoritative (set by the file-watch handler), so a + // fresh push must never inherit a stale flag from the agent payload. + const { stale: _untrustedStale, ...rest } = payload; + return { + ...rest, + // Server-side timestamp is authoritative for "Created x minutes ago". + createdAt: Date.now(), + }; +} + +export interface ServerChannelOptions { + /** Override the module-graph-change subscription. Used by tests. */ + subscribeToModuleGraphChanges?: SubscribeToModuleGraphChanges; +} + +/** + * Storybook's preset hook that hands us the long-lived dev-server channel. + * + * Responsibilities: + * - PUSH_REVIEW (from @storybook/addon-mcp): stamp the server createdAt, + * cache, broadcast as DISPLAY_REVIEW so any open tab updates. + * - REQUEST_REVIEW (from a tab that just mounted): re-broadcast the cached + * payload as DISPLAY_REVIEW so the late tab catches up. + */ +export const experimental_serverChannel = async ( + channel: Channel, + _options: Options, + serverOptions: ServerChannelOptions = {} +) => { + const subscribeToModuleGraphChanges = + serverOptions.subscribeToModuleGraphChanges ?? defaultSubscribeToModuleGraphChanges; + + channel.on(EVENTS.PUSH_REVIEW, (payload: ReviewState) => { + // A fresh review starts non-stale; its new createdAt re-anchors staleness. + cached = prepareReview(payload); + channel.emit(EVENTS.DISPLAY_REVIEW, cached); + }); + + channel.on(EVENTS.REQUEST_REVIEW, () => { + if (cached) { + channel.emit(EVENTS.DISPLAY_REVIEW, cached); + } + }); + + // Mark the cached review stale on the first module-graph change that lands + // after its createdAt (past the grace window). Staleness rides on the cached + // state so REQUEST_REVIEW replays it to tabs that open after the change. + subscribeToModuleGraphChanges(() => { + if (!cached || cached.stale || cached.createdAt === undefined) { + return; + } + if (Date.now() < cached.createdAt + STALE_GRACE_MS) { + return; + } + cached = { ...cached, stale: true }; + channel.emit(EVENTS.REVIEW_STALE); + }); + + return channel; +}; + +// The deployed baseline Storybook to compare against. A single env var that is +// either a project-relative static-build directory (served directly) or a remote +// origin URL (proxied). There is no default — without it, no baseline is served. +const BASELINE = process.env.STORYBOOK_REVIEW_BASELINE; + +/** + * Resolve a `STORYBOOK_REVIEW_BASELINE` value to a project-relative static dir. + * Returns the absolute path when the value is a relative path that stays inside + * the cwd; returns undefined for URL-like values, absolute paths, or paths that + * escape the cwd via `..` — those are not treated as a local static dir. + */ +const resolveBaselineStaticDir = (value: string): string | undefined => { + if (/^[a-zA-Z][\w+.-]*:\/\//.test(value) || isAbsolute(value)) { + return undefined; + } + const root = process.cwd(); + const resolved = resolve(root, value); + if (relative(root, resolved).startsWith('..')) { + return undefined; + } + return resolved; +}; + +const isValidBaselineOrigin = (value: string): boolean => { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; + +/** + * Storybook preset hook that serves the review baseline on the dev server. + * + * The review UI compares the current story against a baseline Storybook in a + * side-by-side iframe. That baseline must be reachable from the same origin as + * the dev server (otherwise the iframe is blocked). This hook mounts it at + * `/__review-baseline` when `STORYBOOK_REVIEW_BASELINE` is set: + * + * - A project-relative static-build directory is served directly via sirv. + * - An `http:`/`https:` URL is proxied so a deployed Storybook can be used + * without a local build. + * + * When the env var is unset or invalid, the hook is a no-op — review still + * works, but baseline comparison is unavailable. + */ +export const experimental_devServer = (app: ServerApp) => { + if (!BASELINE) { + return app; + } + + // A safe relative path is served directly as a local static build… + const staticDir = resolveBaselineStaticDir(BASELINE); + if (staticDir) { + if (!existsSync(staticDir) || !statSync(staticDir).isDirectory()) { + logger.warn( + `[addon-review] STORYBOOK_REVIEW_BASELINE "${BASELINE}" is not an existing directory; ignoring.` + ); + return app; + } + app.use( + BASELINE_PROXY_PATH, + sirv(staticDir, { dev: true, etag: true, extensions: [] }) as unknown as Middleware + ); + return app; + } + + // …otherwise a valid URL is proxied as a remote origin. + if (!isValidBaselineOrigin(BASELINE)) { + logger.warn( + `[addon-review] STORYBOOK_REVIEW_BASELINE "${BASELINE}" is neither a valid relative path nor a valid URL; ignoring.` + ); + return app; + } + + const proxyRequest = createProxyMiddleware({ + target: BASELINE, + changeOrigin: true, + // The baseline origin is a remote server that can be slow or unreachable. + // Bound the wait and respond deterministically so a dead connection fails + // fast instead of hanging the review UI's baseline iframe. + timeout: 30_000, + proxyTimeout: 30_000, + pathRewrite: (path) => + path.startsWith(BASELINE_PROXY_PATH) ? path.slice(BASELINE_PROXY_PATH.length) || '/' : path, + on: { + error: (_error, _req, res) => { + // `res` is a net.Socket on WebSocket upgrades; only HTTP responses + // carry a status code. + if ('writeHead' in res) { + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'text/plain' }); + } + res.end('Baseline preview is unavailable.'); + } + }, + }, + }) as unknown as Middleware; + + app.use(BASELINE_PROXY_PATH, proxyRequest); + return app; +}; diff --git a/code/addons/review/src/review-navigation.ts b/code/addons/review/src/review-navigation.ts new file mode 100644 index 000000000000..c44fb4ecb147 --- /dev/null +++ b/code/addons/review/src/review-navigation.ts @@ -0,0 +1,57 @@ +import { REVIEW_CHANGES_URL } from './constants.ts'; + +// A detail-screen target: a collection (indexed into state.collections) and, +// optionally, the specific story within it that is being reviewed. +export interface ReviewDetailLocation { + collectionIndex: number; + storyId?: string; +} + +const tryDecodeURIComponent = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +// Storybook's manager router keeps the active route in the `path` query param +// (see core/src/router) — `Route`/`api.navigate` read `?path=…`, not +// window.location.pathname. Hrefs therefore wrap the route in `?path=…`. +export const buildReviewChangesSummaryHref = () => `?path=${REVIEW_CHANGES_URL}`; + +export const buildReviewChangesDetailHref = (location: ReviewDetailLocation): string => { + const base = `${REVIEW_CHANGES_URL}${location.collectionIndex}`; + const target = location.storyId ? `${base}/${encodeURIComponent(location.storyId)}` : base; + return `?path=${target}`; +}; + +// Parse a detail-screen target out of the URL. Returns null for the summary. +export const parseReviewChangesDetailLocation = (search: string): ReviewDetailLocation | null => { + const params = new URLSearchParams(search); + const path = params.get('path') ?? ''; + + if (!path.startsWith(REVIEW_CHANGES_URL)) { + return null; + } + + const segments = path.slice(REVIEW_CHANGES_URL.length).split('/').filter(Boolean); + if (segments.length === 0) { + return null; + } + + const collectionIndex = Number(segments[0]); + if (!Number.isInteger(collectionIndex) || collectionIndex < 0) { + return null; + } + // Reject trailing junk (e.g. `0/story/extra`) so the route parses strictly. + if (segments.length > 2) { + return null; + } + + const storySegment = segments[1]; + return { + collectionIndex, + storyId: storySegment ? tryDecodeURIComponent(storySegment) : undefined, + }; +}; diff --git a/code/addons/review/src/review-state.ts b/code/addons/review/src/review-state.ts new file mode 100644 index 000000000000..6d8624951a1f --- /dev/null +++ b/code/addons/review/src/review-state.ts @@ -0,0 +1,47 @@ +/** + * The review payload an agent pushes via the `display-review` MCP tool. + * + * Flow: + * MCP `display-review` tool → emit PUSH_REVIEW on the Storybook channel + * → this addon's server preset stamps `createdAt` and caches it + * → emits DISPLAY_REVIEW to all open tabs (or replays on REQUEST_REVIEW). + * + * This mirrors the canonical valibot schema in `@storybook/addon-mcp` → + * `tools/display-review.ts`. This side only renders the data — it does + * not validate — so it needs the type, not the validator. Keep `title` / + * `description` / `collections` in sync with that schema. + */ + +export type CollectionKind = 'atomic' | 'consumer' | 'transitive' | 'catch-all'; + +export interface ReviewCollection { + title: string; + rationale: string; + storyIds: string[]; + kind?: CollectionKind; +} + +export interface ReviewState { + title: string; + description: string; + collections: ReviewCollection[]; + changedFiles?: string[]; + /** + * Server-side creation timestamp (unix ms) assigned when PUSH_REVIEW is + * received; used for live "Created x minutes ago" UI in the summary. + */ + createdAt?: number; + /** + * Set server-side once a watched source file changes after `createdAt`. + * Drives the "this review may be stale" banner. Persisted on the cached + * review so REQUEST_REVIEW replays it to late/refreshed tabs. + */ + stale?: boolean; + /** + * Whether a baseline is available to compare against. Enables the + * baseline/latest comparison controls on the detail screen. The baseline + * source itself is provided on a separate branch; until then this stays + * unset and the controls are hidden. + */ + hasBaseline?: boolean; +} diff --git a/code/addons/review/src/screens/DetailsScreen.stories.tsx b/code/addons/review/src/screens/DetailsScreen.stories.tsx new file mode 100644 index 000000000000..cf3f03732b42 --- /dev/null +++ b/code/addons/review/src/screens/DetailsScreen.stories.tsx @@ -0,0 +1,188 @@ +import { expect, fn, within } from 'storybook/test'; + +import { ManagerContext, type API, type State } from 'storybook/manager-api'; + +import { + buildReviewChangesDetailHref, + buildReviewChangesSummaryHref, +} from '../review-navigation.ts'; +import { PREVIEW_MODE_SESSION_KEY } from '../constants.ts'; +import { sessionStore } from '../session-store.ts'; +import preview from '../../../../.storybook/preview.tsx'; +import { DetailsScreen } from './DetailsScreen.tsx'; + +// DetailsScreen uses useAddonState (via the preview-mode toggle), which reads +// the manager API off ManagerContext. Provide a minimal in-memory mock so the +// stories render outside the real manager. +const addonStateStore: Record = {}; +const managerApi = { + on: fn(() => () => {}), + off: fn(), + emit: fn(), + getAddonState: fn((id: string) => addonStateStore[id]), + setAddonState: fn((id: string, value: unknown) => { + addonStateStore[id] = typeof value === 'function' ? value(addonStateStore[id]) : value; + return Promise.resolve(addonStateStore[id]); + }), +} as unknown as API; +const managerState = {} as State; + +const meta = preview.meta({ + component: DetailsScreen, + parameters: { + layout: 'fullscreen', + chromatic: { + ignoreSelectors: ['[data-testid="review-details-screen-preview"] iframe'], + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + beforeEach: () => { + for (const key of Object.keys(addonStateStore)) { + delete addonStateStore[key]; + } + sessionStore.remove(PREVIEW_MODE_SESSION_KEY); + }, + args: { + title: 'Toolbar & direct consumers', + componentTitle: 'Manager/Components/Toolbar', + storyName: 'Basic', + storyId: 'components-toolbar--basic', + storyIndex: 1, + totalStories: 3, + previewHref: 'iframe.html?id=components-toolbar--basic&viewMode=story', + storybookHref: '?path=/story/components-toolbar--basic', + backHref: buildReviewChangesSummaryHref(), + previousHref: buildReviewChangesDetailHref({ + collectionIndex: 0, + storyId: 'components-toolbar--compact', + }), + nextHref: buildReviewChangesDetailHref({ + collectionIndex: 0, + storyId: 'components-toolbar--dense', + }), + }, +}); + +export const Default = meta.story({ + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole('button', { name: '2/3' })).toBeInTheDocument(); + await expect( + await canvas.findByRole('heading', { name: 'Toolbar & direct consumers' }) + ).toBeInTheDocument(); + await expect(await canvas.findByText('Toolbar')).toBeInTheDocument(); + await expect(await canvas.findByText('Basic')).toBeInTheDocument(); + await expect( + await canvas.findByRole('link', { name: 'View in Storybook' }) + ).toBeInTheDocument(); + // No baseline by default: only the latest preview, no comparison controls. + await expect(await canvas.findByTitle('Latest components-toolbar--basic')).toBeInTheDocument(); + await expect(canvas.queryByTitle('Baseline components-toolbar--basic')).not.toBeInTheDocument(); + }, +}); + +export const WithBaseline = meta.story({ + args: { + hasBaseline: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Default is 1up with the latest pane active: only the "Latest" bar label is shown. + await expect(await canvas.findByText('Latest')).toBeInTheDocument(); + await expect(canvas.queryByText('Baseline')).not.toBeInTheDocument(); + await expect( + await canvas.findByRole('button', { name: 'Switch baseline and latest' }) + ).toBeInTheDocument(); + // Both previews are mounted; the baseline iframe is hidden in 1up latest mode. + await expect( + await canvas.findByTitle('Baseline components-toolbar--basic') + ).toBeInTheDocument(); + await expect(await canvas.findByTitle('Latest components-toolbar--basic')).toBeInTheDocument(); + await expect(canvas.queryByText('New')).not.toBeInTheDocument(); + await expect( + await canvas.findByRole('button', { name: 'Side-by-side view' }) + ).toBeInTheDocument(); + }, +}); + +export const WithBaselineSplit = meta.story({ + args: { + hasBaseline: true, + }, + beforeEach: () => { + sessionStore.write(PREVIEW_MODE_SESSION_KEY, '2up'); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('Baseline')).toBeInTheDocument(); + await expect(await canvas.findByText('Latest')).toBeInTheDocument(); + await expect( + await canvas.findByTitle('Baseline components-toolbar--basic') + ).toBeInTheDocument(); + await expect(await canvas.findByTitle('Latest components-toolbar--basic')).toBeInTheDocument(); + await expect( + canvas.queryByRole('button', { name: 'Switch baseline and latest' }) + ).not.toBeInTheDocument(); + }, +}); + +export const NewStory = meta.story({ + args: { + hasBaseline: true, + isNewlyAdded: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('New')).toBeInTheDocument(); + await expect(await canvas.findByTitle('Latest components-toolbar--basic')).toBeInTheDocument(); + // A new story has no baseline to compare against: no baseline preview and no + // side-by-side comparison controls. + await expect(canvas.queryByTitle('Baseline components-toolbar--basic')).not.toBeInTheDocument(); + await expect( + canvas.queryByRole('button', { name: 'Side-by-side view' }) + ).not.toBeInTheDocument(); + }, +}); + +export const Stale = meta.story({ + args: { isStale: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByText('This review may be stale. Ask your agent to refresh it.') + ).toBeInTheDocument(); + }, +}); + +export const WrapAroundNavigation = meta.story({ + args: { + storyId: 'components-toolbar--basic', + storyIndex: 0, + totalStories: 3, + previousHref: buildReviewChangesDetailHref({ + collectionIndex: 0, + storyId: 'components-toolbar--dense', + }), + nextHref: buildReviewChangesDetailHref({ + collectionIndex: 0, + storyId: 'components-toolbar--compact', + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const previousButton = await canvas.findByRole('link', { name: 'Previous story' }); + const nextButton = await canvas.findByRole('link', { name: 'Next story' }); + await expect(previousButton.getAttribute('href')).toContain( + '/review/0/components-toolbar--dense' + ); + await expect(nextButton.getAttribute('href')).toContain( + '/review/0/components-toolbar--compact' + ); + }, +}); diff --git a/code/addons/review/src/screens/DetailsScreen.tsx b/code/addons/review/src/screens/DetailsScreen.tsx new file mode 100644 index 000000000000..6330f098ae2c --- /dev/null +++ b/code/addons/review/src/screens/DetailsScreen.tsx @@ -0,0 +1,365 @@ +import React, { useEffect, useState, type FC } from 'react'; + +import { Badge, Button, IconButton } from 'storybook/internal/components'; +import { styled } from 'storybook/theming'; + +import { + ChevronSmallLeftIcon, + ChevronSmallRightIcon, + SideBySideIcon, + StopAltHollowIcon, + StorybookIcon, + TransferIcon, +} from '@storybook/icons'; + +import { ReviewHeader } from '../components/ReviewHeader.tsx'; +import { PREVIEW_MODE_SESSION_KEY } from '../constants.ts'; +import { sessionStore } from '../session-store.ts'; +import { useBaselineComparison } from './useBaselineComparison.ts'; + +import { StaleBanner } from '../components/StaleBanner.tsx'; + +const Page = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + height: '100dvh', + minHeight: 0, + overflow: 'hidden', + background: theme.background.content, + color: theme.color.defaultText, + fontFamily: theme.typography.fonts.base, +})); + +const SubtitleStrong = styled.span({ + fontWeight: 700, +}); + +const SubtitleSeparator = styled.span(({ theme }) => ({ + color: theme.textMutedColor, +})); + +const Counter = styled(Button)(({ theme }) => ({ + fontVariantNumeric: 'tabular-nums', + fontFamily: theme.typography.fonts.mono, + fontWeight: theme.typography.weight.regular, +})); + +const PreviewFrameWrap = styled.div<{ $singleUp: boolean }>(({ $singleUp }) => ({ + flex: 1, + minHeight: 0, + width: '100%', + display: 'flex', + position: 'relative', + ...($singleUp ? { overflow: 'hidden' } : {}), +})); + +const PreviewPane = styled.div<{ $singleUp: boolean; $active: boolean }>( + ({ $singleUp, $active }) => ({ + minWidth: 0, + minHeight: 0, + display: 'flex', + ...($singleUp + ? { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + zIndex: $active ? 1 : -1, + pointerEvents: $active ? 'auto' : 'none', + visibility: $active ? 'visible' : 'hidden', + } + : { + flex: 1, + position: 'relative', + }), + }) +); + +const PaneDivider = styled.div(({ theme }) => ({ + width: 1, + flexShrink: 0, + background: theme.color.border, +})); + +const PreviewDivider = styled(PaneDivider)<{ $singleUp: boolean }>(({ $singleUp }) => ({ + display: $singleUp ? 'none' : 'block', +})); + +const PreviewFrame = styled.iframe({ + flex: 1, + width: '100%', + height: '100%', + border: 0, + display: 'block', +}); + +// The baseline comparison bar. A two-up (side-by-side) and one-up (single) +// mode share a control cluster; the "switch" control only applies in one-up +// mode, where it flips which pane (baseline or latest) is shown. +const BaselineBar = styled.div({ + display: 'flex', + width: '100%', + alignItems: 'center', +}); + +const BarHalf = styled.div({ + display: 'flex', + flex: 1, + minWidth: 0, + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, +}); + +const BarLabel = styled.strong(({ theme }) => ({ + fontWeight: theme.typography.weight.bold, + fontSize: 14, + lineHeight: '20px', + whiteSpace: 'nowrap', +})); + +const BarControls = styled.div({ + display: 'flex', + alignItems: 'center', + gap: 6, + flexShrink: 0, +}); + +type CompareMode = 'split' | 'single'; +type ComparePane = 'baseline' | 'latest'; + +const DEFAULT_COMPARE_MODE: CompareMode = 'single'; + +// The persisted preview layout reuses the historical '1up'/'2up' values so the +// choice carries across sessions; map them to the local split/single model. +const readCompareMode = (): CompareMode => { + const stored = sessionStore.read(PREVIEW_MODE_SESSION_KEY); + return stored === '2up' ? 'split' : DEFAULT_COMPARE_MODE; +}; + +export interface DetailsScreenProps { + /** Fallback title shown when story metadata is unavailable. */ + title: string; + storyId: string; + storyIndex: number; + totalStories: number; + backHref: string; + previousHref: string; + nextHref: string; + /** Preview iframe src for the latest (current) story. */ + previewHref: string; + /** Manager href to open the story in the regular Storybook UI. */ + storybookHref: string; + componentTitle?: string; + storyName?: string; + /** When true, render the "this review may be stale" banner at the top. */ + isStale?: boolean; + /** Enables the baseline/latest comparison controls when a baseline exists. */ + hasBaseline?: boolean; + /** Whether this story is newly added relative to the baseline Storybook. */ + isNewlyAdded?: boolean; +} + +const componentName = (componentTitle: string): string => + componentTitle + .split('/') + .map((part) => part.trim()) + .filter(Boolean) + .pop() ?? componentTitle; + +const CompareControls: FC<{ + mode: CompareMode; + onModeChange: (mode: CompareMode) => void; + onSwitch: () => void; +}> = ({ mode, onModeChange, onSwitch }) => ( + + {mode === 'single' ? ( + + + + ) : null} + onModeChange('single')} + > + + + onModeChange('split')} + > + + + +); + +// The preview