feat(desktop): lazy embed renderers + fenced diagrams/alerts

Per-kind renderers, each a lazy split chunk: plain-iframe video/maps (wheel
chains to the transcript; maps gate scroll behind ⌘), the in-document
blockquote-script path for X/Instagram, the dark Spotify player, and the
YouTube iframe. Adds Mermaid and DOMPurify-sanitised SVG fences and GFM alert
callouts, all sized to 33dvh and theme-matched to avoid white color-scheme
artifacts. Main-process stamps a Referer on YouTube embed requests.
This commit is contained in:
Brooklyn Nicholson 2026-06-26 03:22:08 -05:00
parent 81ac562bf0
commit 0c190083cd
13 changed files with 757 additions and 0 deletions

View file

@ -0,0 +1,48 @@
'use strict'
const { session } = require('electron')
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
const EMBED_REFERER = 'https://www.youtube.com/'
const YOUTUBE_REFERER_HOST_RE =
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
function installEmbedRefererForSession(embedSession) {
if (!embedSession) {
return
}
embedSession.webRequest.onBeforeSendHeaders((details, callback) => {
let host = ''
try {
host = new URL(details.url).hostname
} catch {
host = ''
}
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
callback({ requestHeaders: details.requestHeaders })
return
}
const headers = { ...details.requestHeaders }
if (!headers.Referer && !headers.referer) {
headers.Referer = EMBED_REFERER
}
callback({ requestHeaders: headers })
})
}
/** Stamp Referer on YouTube requests in the embed webview partition only. */
function installEmbedReferer() {
try {
installEmbedRefererForSession(session.fromPartition(EMBED_SESSION_PARTITION))
} catch {
// Non-fatal: embeds still render; YouTube may show referer errors.
}
}
module.exports = { installEmbedReferer }

View file

@ -24,6 +24,7 @@ const https = require('node:https')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { installEmbedReferer } = require('./embed-referer.cjs')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const {
@ -7447,6 +7448,7 @@ app.whenReady().then(() => {
}
installMediaPermissions()
registerMediaProtocol()
installEmbedReferer()
registerDeepLinkProtocol()
ensureWslWindowsFonts()
configureSpellChecker()

View file

@ -0,0 +1,39 @@
import { createElement } from 'react'
import { describe, expect, it } from 'vitest'
import { extractAlert } from './alert'
describe('extractAlert', () => {
it('detects each GFM alert kind from the leading marker', () => {
for (const [marker, type] of [
['[!NOTE]', 'note'],
['[!TIP]', 'tip'],
['[!IMPORTANT]', 'important'],
['[!WARNING]', 'warning'],
['[!CAUTION]', 'caution']
] as const) {
const node = createElement('p', null, `${marker}\nBody text`)
const result = extractAlert(node)
expect(result?.type).toBe(type)
}
})
it('is case-insensitive on the marker', () => {
expect(extractAlert(createElement('p', null, '[!note] hi'))?.type).toBe('note')
})
it('returns null for a plain blockquote', () => {
expect(extractAlert(createElement('p', null, 'just a quote'))).toBeNull()
expect(extractAlert('no marker here')).toBeNull()
})
it('strips the marker token from the body', () => {
const result = extractAlert(createElement('p', null, '[!WARNING]\nDanger ahead'))
expect(result).not.toBeNull()
// The marker must not survive into the rendered body.
expect(JSON.stringify(result?.body)).not.toContain('[!WARNING]')
expect(JSON.stringify(result?.body)).toContain('Danger ahead')
})
})

View file

@ -0,0 +1,125 @@
import { cloneElement, isValidElement, type ReactNode } from 'react'
import { AlertCircle, AlertTriangle, type IconComponent, Info, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
export type AlertType = 'caution' | 'important' | 'note' | 'tip' | 'warning'
interface AlertStyle {
accent: string
icon: IconComponent
label: string
}
// GitHub's five alert kinds, mapped to our icon set + a tinted accent.
const ALERT_STYLES: Record<AlertType, AlertStyle> = {
caution: { accent: 'text-rose-600 dark:text-rose-400', icon: AlertTriangle, label: 'Caution' },
important: { accent: 'text-violet-600 dark:text-violet-400', icon: AlertCircle, label: 'Important' },
note: { accent: 'text-blue-600 dark:text-blue-400', icon: Info, label: 'Note' },
tip: { accent: 'text-emerald-600 dark:text-emerald-400', icon: Zap, label: 'Tip' },
warning: { accent: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle, label: 'Warning' }
}
const MARKER_RE = /^\s*\[!(note|tip|important|warning|caution)\]\s*\n?/i
function firstText(node: ReactNode): string {
if (typeof node === 'string') {
return node
}
if (typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
for (const child of node) {
const text = firstText(child)
if (text.trim()) {
return text
}
}
return ''
}
if (isValidElement(node)) {
return firstText((node.props as { children?: ReactNode }).children)
}
return ''
}
// Remove the leading `[!TYPE]` token from the first text node that carries it,
// leaving the rest of the blockquote body intact. One-shot via the `state` flag.
function stripMarker(node: ReactNode, state: { done: boolean }): ReactNode {
if (state.done) {
return node
}
if (typeof node === 'string') {
const replaced = node.replace(MARKER_RE, '')
if (replaced !== node) {
state.done = true
return replaced
}
return node
}
if (Array.isArray(node)) {
return node.map((child, index) => <Fragmentless key={index} node={stripMarker(child, state)} />)
}
if (isValidElement(node)) {
const children = (node.props as { children?: ReactNode }).children
if (children == null) {
return node
}
return cloneElement(node, undefined, stripMarker(children, state))
}
return node
}
// Tiny helper so the array branch can return keyed nodes without wrapping
// strings in extra elements (React renders the raw node).
function Fragmentless({ node }: { node: ReactNode }) {
return <>{node}</>
}
/**
* Detect a GitHub-style alert blockquote (`> [!NOTE]`). Returns the alert kind
* and the body with the marker stripped, or null for a plain blockquote.
*/
export function extractAlert(children: ReactNode): { body: ReactNode; type: AlertType } | null {
const match = firstText(children).match(MARKER_RE)
if (!match) {
return null
}
return { body: stripMarker(children, { done: false }), type: match[1].toLowerCase() as AlertType }
}
export function MarkdownAlert({ children, type }: { children: ReactNode; type: AlertType }) {
const style = ALERT_STYLES[type]
const Icon = style.icon
return (
<div
className="my-2 rounded-lg border border-border bg-muted/25 px-3 py-2 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
data-slot="aui_markdown-alert"
>
<div className={cn('mb-1 flex items-center gap-1.5 text-[0.8125rem] font-semibold', style.accent)}>
<Icon className="size-4 shrink-0" />
{style.label}
</div>
{children}
</div>
)
}

View file

@ -0,0 +1,55 @@
'use client'
import { type CSSProperties } from 'react'
import type { FrameEmbed } from './providers/types'
import { ScrollGate } from './scroll-gate'
import { useIsDark } from './use-is-dark'
const ALLOW = 'autoplay; encrypted-media; picture-in-picture; clipboard-write; fullscreen'
// Plain iframes (not webviews): a non-scrollable cross-origin iframe lets the
// wheel chain to the transcript instead of capturing it. Maps are the one
// exception — they're interactive, so a ScrollGate blocks them until ⌘ is held.
export default function FrameEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
const isDark = useIsDark()
const isMap = descriptor.provider === 'googlemaps' || descriptor.provider === 'openstreetmap'
// color-scheme makes the iframe's default (unpainted) backdrop follow the
// theme instead of flashing white at the corners / during load.
const colorScheme = isDark ? 'dark' : 'light'
const style: CSSProperties = descriptor.aspectRatio
? { aspectRatio: descriptor.aspectRatio, colorScheme }
: { colorScheme, height: descriptor.height }
if (isMap) {
return (
<div className="relative w-full overflow-hidden" style={style}>
<iframe
allow={ALLOW}
className="absolute inset-0 size-full border-0 bg-transparent"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
src={descriptor.embedUrl}
style={{ colorScheme }}
title={`${descriptor.label} embed`}
/>
<ScrollGate />
</div>
)
}
return (
<iframe
allow={ALLOW}
allowFullScreen
className="block w-full border-0 bg-transparent"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
scrolling="no"
src={descriptor.embedUrl}
style={style}
title={`${descriptor.label} embed`}
/>
)
}

View file

@ -0,0 +1,97 @@
'use client'
import mermaid from 'mermaid'
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import type { RichFenceProps } from './types'
import { useIsDark } from './use-is-dark'
let lastTheme: 'dark' | 'default' | null = null
// Re-initialise only on first use / theme flip. `securityLevel: 'strict'` makes
// mermaid sanitise label HTML and drop click handlers, so the rendered SVG is
// safe to inject.
function ensureInit(dark: boolean) {
const theme = dark ? 'dark' : 'default'
if (theme === lastTheme) {
return
}
mermaid.initialize({ fontFamily: 'inherit', securityLevel: 'strict', startOnLoad: false, theme })
lastTheme = theme
}
function SourcePreview({ code, muted }: { code: string; muted?: boolean }) {
return (
<pre
className={cn(
'overflow-auto p-3 font-mono text-[0.7rem] leading-relaxed whitespace-pre-wrap wrap-anywhere',
muted ? 'text-muted-foreground/70' : 'text-foreground/90'
)}
>
{code}
</pre>
)
}
// Lazy chunk (pulls in mermaid). Renders ```mermaid fences as diagrams; shows
// the source while the message streams (partial syntax throws) and falls back
// to source on parse failure.
export default function MermaidRenderer({ code, streaming }: RichFenceProps) {
const isDark = useIsDark()
const [svg, setSvg] = useState('')
const [failed, setFailed] = useState(false)
useEffect(() => {
if (streaming) {
return
}
let cancelled = false
setFailed(false)
void (async () => {
try {
ensureInit(isDark)
const id = `mmd-${Math.random().toString(36).slice(2)}`
const result = await mermaid.render(id, code)
if (!cancelled) {
setSvg(result.svg)
}
} catch {
if (!cancelled) {
setFailed(true)
setSvg('')
}
}
})()
return () => {
cancelled = true
}
}, [code, isDark, streaming])
if (streaming) {
return <SourcePreview code={code} muted />
}
if (failed) {
return <SourcePreview code={code} />
}
if (!svg) {
return <SourcePreview code={code} muted />
}
return (
<div
className="overflow-auto p-3 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
)
}

View file

@ -0,0 +1,39 @@
'use client'
import { type ComponentType, lazy, type LazyExoticComponent, type ReactNode, Suspense } from 'react'
import { RichBoundary } from './rich-boundary'
import type { RichFenceProps } from './types'
// Root renderer for fenced code blocks: a language → lazy-renderer table. Each
// renderer is its own split chunk (mermaid pulls in the mermaid lib, svg pulls
// in DOMPurify), loaded only when a block of that language actually appears.
const LAZY_FENCE: Record<string, LazyExoticComponent<ComponentType<RichFenceProps>>> = {
mermaid: lazy(() => import('./mermaid-embed')),
svg: lazy(() => import('./svg-embed'))
}
export const RICH_FENCE_LANGUAGES: ReadonlySet<string> = new Set(Object.keys(LAZY_FENCE))
interface RichCodeBlockProps extends RichFenceProps {
/** Rendered for unhandled languages, while the chunk loads, and on failure
* (typically the normal syntax-highlighted code block). */
fallback: ReactNode
language?: string
}
export function RichCodeBlock({ code, fallback, language, streaming }: RichCodeBlockProps) {
const Renderer = language ? LAZY_FENCE[language.toLowerCase()] : undefined
if (!Renderer) {
return <>{fallback}</>
}
return (
<RichBoundary fallback={fallback} resetKey={code}>
<Suspense fallback={fallback}>
<Renderer code={code} streaming={streaming} />
</Suspense>
</RichBoundary>
)
}

View file

@ -0,0 +1,33 @@
'use client'
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
/** Block wheel until ⌘/Ctrl so map embeds don't hijack transcript scroll. */
export function ScrollGate() {
const [active, setActive] = useState(false)
useEffect(() => {
const sync = (event: KeyboardEvent) => setActive(event.metaKey || event.ctrlKey)
const clear = () => setActive(false)
window.addEventListener('keydown', sync)
window.addEventListener('keyup', sync)
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('keydown', sync)
window.removeEventListener('keyup', sync)
window.removeEventListener('blur', clear)
}
}, [])
return (
<div className={cn('group/gate absolute inset-0', active ? 'pointer-events-none' : 'pointer-events-auto')}>
<span className="pointer-events-none absolute bottom-2 left-2 rounded-md bg-black/55 px-1.5 py-0.5 text-[0.625rem] font-medium text-white opacity-0 transition-opacity group-hover/embed:opacity-100">
Hold to zoom
</span>
</div>
)
}

View file

@ -0,0 +1,131 @@
'use client'
import { useEffect, useRef } from 'react'
import { escapeHtml } from './escape-html'
import type { EmbedDescriptor } from './providers/types'
import { useIsDark } from './use-is-dark'
// The provider embed scripts need a REAL origin to run (they touch
// cookies/storage/postMessage), so — exactly like react-social-media-embed — we
// render the official blockquote in this document and let the script swap it for
// a correctly-sized iframe. A sandboxed srcDoc iframe gives a null origin and
// the scripts silently bail (white / 2px). The container is height:auto, so it
// grows to whatever the provider renders. No measuring, no forced height.
type EmbedWindow = Window &
typeof globalThis & {
instgrm?: { Embeds?: { process?: () => void } }
twttr?: { widgets?: { load?: (el?: HTMLElement) => void } }
}
const SCRIPT: Record<string, { id: string; src: string }> = {
instagram: { id: 'hermes-ig-embed', src: 'https://www.instagram.com/embed.js' },
tiktok: { id: 'hermes-tt-embed', src: 'https://www.tiktok.com/embed.js' },
twitter: { id: 'hermes-tw-embed', src: 'https://platform.twitter.com/widgets.js' }
}
const PROCESS_DELAYS_MS = [0, 300, 800, 1600, 3000]
function markup(descriptor: EmbedDescriptor, theme: 'dark' | 'light'): string {
const url = escapeHtml(descriptor.sourceUrl)
switch (descriptor.provider) {
case 'instagram':
return `<blockquote class="instagram-media" data-instgrm-permalink="${url}" data-instgrm-version="14" style="margin:0;width:100%;min-width:0;max-width:100%"></blockquote>`
case 'tiktok': {
const id = escapeHtml(descriptor.id.replace(/^tiktok:/, ''))
return `<blockquote class="tiktok-embed" cite="${url}" data-video-id="${id}" style="margin:0;max-width:100%"><section></section></blockquote>`
}
case 'twitter':
// data-chrome="transparent" drops the card background so the themed page
// shows through instead of a white box.
return `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}" data-chrome="transparent"><a href="${url}"></a></blockquote>`
default:
return ''
}
}
function loadScript(provider: string): Promise<void> {
const { id, src } = SCRIPT[provider]
// TikTok exposes no re-process API; its script rescans the document each time
// it runs, so we re-inject it. The others are loaded once and reused.
if (provider === 'tiktok') {
document.getElementById(id)?.remove()
} else if (document.getElementById(id)) {
return Promise.resolve()
}
return new Promise(resolve => {
const script = document.createElement('script')
script.async = true
script.id = id
script.onload = () => resolve()
script.onerror = () => resolve()
script.src = src
document.body.appendChild(script)
})
}
function processEmbed(provider: string, container: HTMLElement): void {
const win = window as EmbedWindow
if (provider === 'instagram') {
win.instgrm?.Embeds?.process?.()
} else if (provider === 'twitter') {
win.twttr?.widgets?.load?.(container)
}
// TikTok auto-scans on (re)injection — no manual process call.
}
export default function SocialEmbedRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
const isDark = useIsDark()
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const container = ref.current
if (!container) {
return
}
let cancelled = false
const timers: number[] = []
container.innerHTML = markup(descriptor, isDark ? 'dark' : 'light')
void loadScript(descriptor.provider).then(() => {
// The script renders asynchronously; nudge a few times so the embed
// settles whether the script was cached or freshly fetched.
for (const delay of PROCESS_DELAYS_MS) {
timers.push(window.setTimeout(() => !cancelled && processEmbed(descriptor.provider, container), delay))
}
})
return () => {
cancelled = true
for (const timer of timers) {
clearTimeout(timer)
}
container.innerHTML = ''
}
}, [descriptor, isDark])
// The white corner/box on tweets is a color-scheme MISMATCH: when the iframe's
// resolved scheme differs from ours, the browser paints an opaque (white)
// Canvas behind it. Twitter's embed resolves to `light`, so we force the iframe
// to `light` to match — no mismatch, no Canvas — and data-chrome=transparent
// then lets the dark page show through. (Confirmed: mkdocs-material #6889.)
return (
<div
className="w-full [&_.instagram-media]:!min-w-0 [&_iframe]:!m-0 [&_iframe]:!max-w-full [&_iframe]:[color-scheme:light]"
ref={ref}
/>
)
}

View file

@ -0,0 +1,45 @@
'use client'
import { type CSSProperties, useMemo } from 'react'
import type { FrameEmbed } from './providers/types'
import { useIsDark } from './use-is-dark'
const ALLOW = 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture'
// Spotify paints a white backdrop behind its card; theme=0 gives the dark
// player and the card wrapper's overflow-hidden clips the corners.
function spotifySrc(embedUrl: string, isDark: boolean): string {
const url = new URL(embedUrl)
url.searchParams.set('utm_source', 'generator')
if (isDark) {
url.searchParams.set('theme', '0')
}
return url.toString()
}
export default function SpotifyEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
const isDark = useIsDark()
const src = useMemo(() => spotifySrc(descriptor.embedUrl, isDark), [descriptor.embedUrl, isDark])
// Match the iframe's own (light) scheme — a `dark` mismatch makes the browser
// paint an opaque white Canvas behind it. theme=0 still gives the dark player.
const style: CSSProperties = {
colorScheme: 'light',
height: descriptor.height
}
return (
<iframe
allow={ALLOW}
className="block w-full border-0 bg-transparent"
loading="lazy"
src={src}
style={style}
title="Spotify embed"
/>
)
}

View file

@ -0,0 +1,32 @@
'use client'
import DOMPurify from 'dompurify'
import { useMemo } from 'react'
import type { RichFenceProps } from './types'
// Lazy chunk (pulls in DOMPurify). Renders a ```svg fence as an image after
// hard-sanitising it: the svg profile strips scripts, event handlers, and
// foreignObject, so untrusted model output can't execute.
export default function SvgRenderer({ code }: RichFenceProps) {
const clean = useMemo(
() =>
DOMPurify.sanitize(code, {
USE_PROFILES: { svg: true, svgFilters: true }
}),
[code]
)
if (!clean.trim()) {
return null
}
// Left-aligned, capped on both axes so a large intrinsic SVG scales down
// (preserving ratio) instead of filling the column or centering.
return (
<div
className="my-2 [&_svg]:block [&_svg]:h-auto [&_svg]:w-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: clean }}
/>
)
}

View file

@ -0,0 +1,64 @@
'use client'
import { type CSSProperties, lazy, Suspense } from 'react'
import { EMBED_MAX_H } from './embed-size'
import { EmbedFail } from './fail'
import type { EmbedDescriptor } from './providers/types'
import { RichBoundary } from './rich-boundary'
const FrameEmbedRenderer = lazy(() => import('./frame-embed'))
const SocialEmbedRenderer = lazy(() => import('./social-embed'))
const SpotifyEmbedRenderer = lazy(() => import('./spotify-embed'))
const YouTubeEmbedRenderer = lazy(() => import('./youtube-embed'))
function intrinsicHeight(descriptor: EmbedDescriptor): number {
if (descriptor.aspectRatio) {
return Math.round((descriptor.maxWidth ?? 640) / descriptor.aspectRatio)
}
return descriptor.height ?? 320
}
function LazyRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
// X and Instagram load their official blockquote script in-document. The tweet
// check also narrows the union to FrameEmbed for the iframe renderers below.
if (descriptor.renderer === 'tweet' || descriptor.provider === 'instagram') {
return <SocialEmbedRenderer descriptor={descriptor} />
}
if (descriptor.provider === 'youtube') {
return <YouTubeEmbedRenderer descriptor={descriptor} />
}
if (descriptor.provider === 'spotify') {
return <SpotifyEmbedRenderer descriptor={descriptor} />
}
return <FrameEmbedRenderer descriptor={descriptor} />
}
export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) {
const aspect = descriptor.aspectRatio
// Ratio embeds cap WIDTH off the ratio so height tops out at the cap while
// scaling. Non-ratio embeds own their own height (scale-to-fit / measured /
// fixed) — no maxHeight here or it would clip what the renderer just sized.
const style: CSSProperties = {
containIntrinsicSize: `auto ${intrinsicHeight(descriptor)}px`,
contentVisibility: 'auto',
...(aspect
? { width: `min(${descriptor.maxWidth ?? 640}px, 100%, calc(${EMBED_MAX_H} * ${aspect}))` }
: { width: descriptor.maxWidth ? `min(${descriptor.maxWidth}px, 100%)` : '100%' })
}
return (
<span className="group/embed my-2 block overflow-hidden rounded-lg" data-slot="aui_embed-card" style={style}>
<RichBoundary fallback={<EmbedFail label={descriptor.label} />} resetKey={descriptor.id}>
<Suspense fallback={null}>
<LazyRenderer descriptor={descriptor} />
</Suspense>
</RichBoundary>
</span>
)
}

View file

@ -0,0 +1,47 @@
'use client'
import { useMemo } from 'react'
import type { FrameEmbed } from './providers/types'
import { useIsDark } from './use-is-dark'
const YOUTUBE_ALLOW =
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen'
function youtubeSrc(embedUrl: string): string {
const url = new URL(embedUrl)
// Only pass origin when it is an HTTP(S) origin; custom schemes (app://,
// file://) can make the player reject otherwise embeddable videos.
if (
typeof window !== 'undefined' &&
(window.location.protocol === 'http:' || window.location.protocol === 'https:') &&
window.location.origin &&
window.location.origin !== 'null'
) {
url.searchParams.set('origin', window.location.origin)
}
return url.toString()
}
// Keep this as a plain iframe and let YouTube render its native player/error UI.
export default function YouTubeEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
const isDark = useIsDark()
const src = useMemo(() => youtubeSrc(descriptor.embedUrl), [descriptor.embedUrl])
// Width is capped to the ratio by UrlEmbed, so aspect-video sizes height ≤ cap.
return (
<iframe
allow={YOUTUBE_ALLOW}
allowFullScreen
className="block aspect-video w-full border-0 bg-transparent"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
scrolling="no"
src={src}
style={{ colorScheme: isDark ? 'dark' : 'light' }}
title="YouTube embed"
/>
)
}