diff --git a/apps/web/src/components/PluginDetailsModal.tsx b/apps/web/src/components/PluginDetailsModal.tsx index 306a75503..3a2a6f13c 100644 --- a/apps/web/src/components/PluginDetailsModal.tsx +++ b/apps/web/src/components/PluginDetailsModal.tsx @@ -19,6 +19,7 @@ // same callback wiring. import type { InstalledPluginRecord } from '@open-design/contracts'; +import { createPortal } from 'react-dom'; import { inferPluginPreview } from './plugins-home/preview'; import { PluginScenarioDetail } from './plugin-details/PluginScenarioDetail'; import { PluginExampleDetail } from './plugin-details/PluginExampleDetail'; @@ -39,9 +40,10 @@ export function PluginDetailsModal({ isApplying, }: Props) { const preview = inferPluginPreview(record); + let detail: JSX.Element; if (preview.kind === 'media') { - return ( + detail = ( ); - } - - if (preview.kind === 'html') { - return ( + } else if (preview.kind === 'html') { + detail = ( ); - } - - if (preview.kind === 'design') { - return ( + } else if (preview.kind === 'design') { + detail = ( ); + } else { + detail = ( + + ); } - return ( - - ); + if (typeof document === 'undefined') return detail; + return createPortal(detail, document.body); } diff --git a/apps/web/tests/components/PluginDetailsModal.layering.test.tsx b/apps/web/tests/components/PluginDetailsModal.layering.test.tsx new file mode 100644 index 000000000..4294f7f16 --- /dev/null +++ b/apps/web/tests/components/PluginDetailsModal.layering.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment jsdom + +import { cleanup, render } from '@testing-library/react'; +import type { InstalledPluginRecord } from '@open-design/contracts'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { PluginDetailsModal } from '../../src/components/PluginDetailsModal'; +import { I18nProvider } from '../../src/i18n'; + +function makePlugin( + id: string, + preview?: Record, +): InstalledPluginRecord { + return { + id, + title: id, + version: '0.1.0', + sourceKind: 'bundled', + source: '/tmp', + trust: 'bundled', + capabilitiesGranted: [], + manifest: { + name: id, + version: '0.1.0', + title: id, + od: { + kind: 'scenario', + ...(preview ? { preview } : {}), + useCase: { + query: 'Generate a preview.', + }, + }, + }, + fsPath: '/tmp', + installedAt: 0, + updatedAt: 0, + }; +} + +function renderInsideStackingContext(record: InstalledPluginRecord) { + const host = document.createElement('div'); + host.className = 'composer'; + document.body.appendChild(host); + + render( + +
+ {}} onUse={() => {}} /> +
+
, + { container: host }, + ); + + return host; +} + +describe('PluginDetailsModal layering', () => { + afterEach(() => { + cleanup(); + document.body.innerHTML = ''; + }); + + it('portals rich preview details to the document body so sticky chat and workspace headers cannot cover them', () => { + const host = renderInsideStackingContext( + makePlugin('video-plugin', { + type: 'video', + poster: 'https://cdn.example/poster.jpg', + video: 'https://cdn.example/clip.mp4', + }), + ); + + const backdrop = document.body.querySelector('.ds-modal-backdrop'); + expect(backdrop).toBeTruthy(); + expect(backdrop?.parentElement).toBe(document.body); + expect(host.querySelector('.ds-modal-backdrop')).toBeNull(); + }); + + it('portals fallback scenario details through the same top-level layer', () => { + const host = renderInsideStackingContext(makePlugin('scenario-plugin')); + + const backdrop = document.body.querySelector('.plugin-details-modal-backdrop'); + expect(backdrop).toBeTruthy(); + expect(backdrop?.parentElement).toBe(document.body); + expect(host.querySelector('.plugin-details-modal-backdrop')).toBeNull(); + }); +});