@aglyn/plugins-mui 1.0.0-beta.236 → 1.0.0-beta.237

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/mui/src/lib/components/image.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { mdiImage } from '@aglyn/shared-data-mdi'\nimport { AppLink } from '@aglyn/shared-ui-jsx'\nimport Box from '@mui/material/Box'\nimport { type SxProps, type Theme, useTheme } from '@mui/material/styles'\nimport useForkRef from '@mui/utils/useForkRef'\nimport { forwardRef, type ReactNode, useEffect, useState } from 'react'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { generatePresetId } from '../utils/generate-preset-id'\nimport { imageSizes, layoutBandWidths } from '../utils/image-sizes'\n\n// Component ids are persisted in screen documents; never rename.\nexport const ID: Aglyn.ComponentId = 'image'\n\n/**\n * The node ids of the images that load eagerly: the first image the LAYOUT\n * chain renders and the first image the SCREEN renders, each in document\n * order (AGL-2486).\n *\n * Every image used to render `loading=\"lazy\"` — the hero included. That is\n * the worst possible default for the one image that is almost always the LCP\n * element: a lazy image is not fetched until layout has run and the browser\n * has decided it is near the viewport, and it is fetched at LOW priority when\n * it finally is. Lighthouse reports that as \"LCP request discovery\", and it is\n * also why an image four sections down could finish before the one the reader\n * is looking at: with everything lazy and everything low, nothing outranked\n * anything, so the order was whatever the network felt like.\n *\n * So the lead images get `loading=\"eager\"` and the rest get\n * `fetchpriority=\"low\"`, which is the browser-level knob that stops\n * below-the-fold images competing with the ones above them.\n *\n * ONE lead image was not enough. On any site whose header carries a logo, the\n * first image in document order is that logo, so the screen's own hero — the\n * element Lighthouse names as the LCP on aglyn.com's solution pages at\n * 375x812 — stayed `lazy` + `low`: undiscovered until layout, then fetched\n * behind every script on the page (measured 2026-09-08: 1.5 s of load delay\n * and 1.5 s of load time for a 13 KB image). Composition namespaces layout\n * nodes by origin ({@link Aglyn.isLayoutComposedNodeId}), so the walk keeps\n * one lead PER ORIGIN: the layout's first image (the logo) and the screen's\n * first image (the hero). A layout image further down — a mega-menu\n * illustration, the footer mark — is still deferred, as is every later\n * screen image. In the besigner canvas nothing is layout-composed, so the\n * screen's first image is the only lead, exactly as before.\n *\n * Only the SCREEN's lead can get `fetchpriority=\"high\"` and a preload, and\n * only when nothing says it renders small (AGL-3485); the reasoning is at the\n * `fetchPriority` prop below. The layout's lead is the header logo on any\n * site that has one, which is why \"first image\" alone was never evidence.\n *\n * Resolved from the tree rather than a render-order counter on purpose: the\n * renderer walks the tree in document order on the server AND on hydrate, but\n * a mutable counter would double-count under React's concurrent re-renders\n * and hand the priority to a different image on the client than the one the\n * HTML gave it. A pure function of the tree cannot disagree with itself — and\n * it is deliberately NOT memoized on the root object, because the canvas\n * mutates that tree in place while an author works.\n *\n * The walk STOPS once both leads are known — on a page with a header logo\n * and a hero that is O(nodes above the hero), a dozen or two on a normal\n * page — and at the first image on a screen with no layout.\n *\n * An image with no `src` renders a placeholder box and no `<img>` at all, so\n * it cannot be the LCP element and is skipped.\n */\nexport function leadImageNodeIds(\n root: Aglyn.NodeSchema | undefined,\n): readonly string[] {\n let layoutLead: string | undefined\n let screenLead: string | undefined\n const walk = (node: Aglyn.NodeSchema | undefined): boolean => {\n if (!node) return false\n if (node.componentId === ID) {\n const props = (node.resolvedProps ?? node.props ?? {}) as Record<\n string,\n unknown\n >\n if (String(props['src'] ?? '').trim()) {\n if (Aglyn.isLayoutComposedNodeId(node.$id)) {\n if (!layoutLead) layoutLead = node.$id\n } else if (!screenLead) {\n screenLead = node.$id\n }\n if (layoutLead && screenLead) return true\n }\n }\n for (const child of node.children ?? []) {\n if (walk(child)) return true\n }\n return false\n }\n walk(root)\n return [layoutLead, screenLead].filter((id): id is string => Boolean(id))\n}\n\n/**\n * The breakpoint band the theme says is in effect. Asked of the THEME, not of\n * the window: the Besigner canvas pins its theme's breakpoint helpers to the\n * previewed device (`createDevicePinnedTheme`), so on a pinned device this is\n * the device's band, and in Fluid Responsive it is the window's — which is\n * the band the canvas's own layout is answering to either way.\n */\nfunction themeBand(theme: Theme): Aglyn.RenderedWidthBand {\n if (typeof window === 'undefined' || !window.matchMedia) return 'xs'\n for (const band of [...Aglyn.RENDERED_WIDTH_BANDS].reverse()) {\n if (band === 'xs') break\n const query = theme.breakpoints.up(band).replace(/^@media\\s*/, '')\n if (window.matchMedia(query).matches) return band\n }\n return 'xs'\n}\n\n/**\n * The width of the page an element is laid out in. On the canvas that is the\n * shadow host the page renders into — narrower than the window, and the width\n * a share of \"the viewport\" has to be taken of.\n */\nfunction pageWidthOf(element: HTMLElement): number {\n const root = element.getRootNode()\n const host =\n typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot\n ? (root.host as HTMLElement)\n : document.documentElement\n return host.clientWidth\n}\n\n/**\n * Records how wide this image renders on the Besigner canvas, per band, for\n * the save to write onto the node (AGL-3485, `rendered-widths.ts`). Inert\n * everywhere but the canvas. A `ResizeObserver` on the image and on the page\n * re-records as the author changes the layout, resizes the window or switches\n * device — a device switch also rebuilds the pinned theme, which re-runs this.\n */\nfunction useRenderedWidthRecorder(\n nodeId: string,\n enabled: boolean,\n): (element: HTMLElement | null) => void {\n const theme = useTheme()\n const [element, setElement] = useState<HTMLElement | null>(null)\n useEffect(() => {\n if (!enabled || !element || !nodeId) return undefined\n if (typeof ResizeObserver === 'undefined') return undefined\n const measure = () => {\n const percent = Aglyn.renderedWidthPercent(\n element.getBoundingClientRect().width,\n pageWidthOf(element),\n )\n if (percent !== undefined) {\n Aglyn.recordRenderedWidth(nodeId, themeBand(theme), percent)\n }\n }\n const observer = new ResizeObserver(measure)\n observer.observe(element)\n const root = element.getRootNode()\n if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot) {\n observer.observe(root.host)\n }\n return () => observer.disconnect()\n }, [enabled, element, nodeId, theme])\n return setElement\n}\n\nexport interface ImageProps {\n /**\n * Where the image comes from (AGL-72). Either a **media reference** —\n * `media:{scope}/{mediaId}`, what \"Browse media\" now stores (AGL-1215) —\n * or any URL, which covers both the legacy values already in published\n * documents and an author-typed hotlink. `resolveMediaSrc` decides.\n */\n src?: string\n alt?: string\n /**\n * Explicit decorative choice (AGL-1305): ON forces `alt=\"\"` (and drops\n * the tooltip) no matter what the alt field says, so screen readers skip\n * the image. Distinct from simply leaving alt unset, which also renders\n * `alt=\"\"` today but records no intent.\n */\n decorative?: boolean\n /** Tooltip shown on hover — the native img `title` attribute. */\n title?: string\n /** Native loading hint; unset stays lazy, exactly as before AGL-1305. */\n loading?: 'lazy' | 'eager'\n objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'\n /** CSS width (e.g. \"100%\", \"320px\"); defaults to 100%. */\n width?: string\n /** CSS height (e.g. \"240px\"); defaults to auto. */\n height?: string\n /**\n * The author's own `sizes` attribute, used exactly as typed (AGL-3485).\n * Empty — the normal case — lets the element work it out: see\n * `utils/image-sizes.ts`.\n */\n sizes?: string\n /**\n * How wide the image rendered on the Besigner canvas, per breakpoint band,\n * as a share of the page: written by the editor's save, never by an author\n * (AGL-3485, `rendered-widths.ts`).\n */\n renderedWidths?: Aglyn.RenderedWidths\n /**\n * The asset's current content hash, laid on by the composition from its\n * media document like the pixel pair below (AGL-3485). It makes the URL the\n * VERSIONED one, which every cache may keep for a year; a replace changes\n * it, and so the URL.\n */\n mediaVersion?: string\n /**\n * The asset's own pixel dimensions, copied off the media document when the\n * image was picked (AGL-2486), and read from the asset again when the page\n * is composed, so a replace with a different shape reaches every page that\n * places it (AGL-2833).\n *\n * NOT author controls, which is why neither appears in the schema below:\n * they describe the file, and the CSS `width`/`height` above describe the\n * placement. The pair becomes the `<img>`'s intrinsic `width`/`height`\n * attributes, which is the only thing that lets the browser reserve the\n * right box before the bytes arrive — with `width: 100%; height: auto` the\n * element is otherwise zero-height until the image decodes, and every image\n * on the page shifts the layout as it lands.\n *\n * An attribute pair is a RATIO here, not a size: CSS wins for the used\n * dimensions either way, so a stale value costs nothing but a reservation\n * of the wrong shape. Both must be present and positive or neither is\n * emitted — one alone gives the browser no ratio and would be read as a\n * real dimension.\n *\n * Read from the node like any other prop, so this element never fetches a\n * media document: the composition has already laid the asset's current\n * pair over the pick's (`media-asset-facts.ts`), and the pick's copy is\n * what renders wherever that read cannot answer. `srcSet` still selects\n * which variant is downloaded; these only say what shape it will be.\n */\n intrinsicWidth?: number\n /** See `intrinsicWidth` — the two are only ever used as a pair. */\n intrinsicHeight?: number\n /** Border radius in px. */\n radius?: number\n /** Target screen id — resolved rename-safe like Screen Link (AGL-339). */\n screenId?: string\n /** External URL, used only when no `screenId` is set. */\n href?: string\n /**\n * Authored node styles, handed over by the renderer rather than typed into\n * an attribute — recomposed below so the author's Styles-panel values are\n * merged rather than replaced (AGL-1240).\n */\n sx?: SxProps\n /**\n * Accepted and dropped: an `<img>` is a void element and React throws on\n * ANY children value reaching one, which 500'd whole pages (AGL-579).\n * Declared because the implementation deliberately discards it (AGL-1323).\n */\n children?: ReactNode\n}\n\n/**\n * Image element (AGL-74): renders a plain img with fit/size/radius\n * controls; an empty src shows a labeled placeholder so the element stays\n * visible and selectable in the editor.\n */\nconst Image = forwardRef<HTMLElement, ImageProps>((props, ref) => {\n const {\n src: storedSrc,\n alt,\n decorative,\n title,\n loading,\n objectFit,\n width,\n height,\n sizes: authoredSizes,\n renderedWidths,\n mediaVersion,\n intrinsicWidth,\n intrinsicHeight,\n radius,\n screenId,\n href: externalHref,\n // Never forward children to the <img> below — React throws on ANY\n // children value reaching a void element, which 500'd whole pages\n // when a renderer passed empty JSX children through (AGL-579).\n children: _children,\n // Pull `sx` out of the spread: the literals below are composed AFTER\n // `{...rest}`, so leaving it there REPLACED every style the author set\n // from the Styles panel. The hero mockups' 16px radius and drop shadow\n // were being discarded on every published page (AGL-1240).\n sx: nodeSxProp,\n ...rest\n } = props\n // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).\n const nodeSx = Array.isArray(nodeSxProp)\n ? nodeSxProp\n : nodeSxProp\n ? [nodeSxProp]\n : []\n // Optional link mode (AGL-339): screen id first (rename-safe), external\n // URL as fallback; suppressed in the besigner canvas like Screen Link.\n // Shared with every other linking element since AGL-1335, so a `Link`\n // component prop bound here behaves as it does on a Button.\n const { href: linkHref, suppressNavigation } = Aglyn.useLinkTarget(\n screenId,\n externalHref,\n )\n /**\n * Eagerness (AGL-2486). An explicit author choice always wins — including\n * an explicit `lazy`, so someone who deliberately deferred the top image\n * keeps that. Only an UNSET `loading` is decided here, and only for the\n * lead images: the layout's first and the screen's first.\n *\n * `leafIdsMatch` rather than `===`: a reusable component instance suffixes\n * its leaf ids, so the id in the tree and the id in the context are the\n * same leaf spelled two ways (the markdown block resolves the same way).\n */\n const nodeId = Aglyn.useNodeId()\n const leadIds = nodeId ? leadImageNodeIds(Aglyn.canvas.rootNode) : []\n const isLeadImage = leadIds.some((leadId) =>\n Aglyn.leafIdsMatch(leadId, nodeId),\n )\n const screenLeadId = leadIds.find((id) => !Aglyn.isLayoutComposedNodeId(id))\n const isScreenLead =\n screenLeadId !== undefined && Aglyn.leafIdsMatch(screenLeadId, nodeId)\n const eager = loading === 'eager' || (loading == null && isLeadImage)\n // The canvas measures; nothing else does (AGL-3485).\n const { editorInert } = Aglyn.useScreenLink(undefined)\n const recordRef = useRenderedWidthRecorder(nodeId, Boolean(editorInert))\n const imageRef = useForkRef(ref, recordRef)\n /**\n * Resolve the stored value to a URL (AGL-1215). A media reference becomes\n * a CDN URL here rather than in the document, so the route shape stays an\n * app concern; every other value — a legacy firebasestorage URL, a legacy\n * `/api/media/cdn/…` path, an author-typed hotlink — passes through.\n *\n * `useSite().hostId` is the site being rendered: present on the tenant and\n * in Preview, absent in the besigner canvas. When it is there it names the\n * asking site in the org scope, which is what lets ONE reference in a\n * layout or reusable component resolve on each site that uses it.\n */\n const { hostId } = Aglyn.useSite()\n // With the asset's version when the composition read one (AGL-3485): the\n // versioned URL is the one every cache may keep, and a replace moves it.\n const src = Aglyn.resolveMediaSrc(storedSrc, {\n hostId,\n version: mediaVersion,\n })\n const wrapLink = (element: JSX.Element) =>\n linkHref && !suppressNavigation ? (\n <AppLink\n componentVariant=\"naked\"\n href={linkHref}\n style={{ display: 'block' }}\n >\n {element}\n </AppLink>\n ) : (\n element\n )\n if (!src) {\n // The labeled box is for the author, so only editing surfaces draw it; a\n // published page renders the bare element.\n if (!suppressNavigation) return <Box ref={ref} {...rest} sx={nodeSx} />\n return (\n <Box\n ref={imageRef}\n {...rest}\n sx={[\n {\n width: width || '100%',\n height: height || 120,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n border: '1px dashed',\n borderColor: 'divider',\n borderRadius: radius != null ? `${radius}px` : undefined,\n color: 'text.secondary',\n fontSize: 12,\n fontFamily: 'system-ui, sans-serif',\n },\n ...nodeSx,\n ]}\n >\n {'Image — choose a source'}\n </Box>\n )\n }\n // CDN URLs (AGL-175) carry WebP variants selected by `?w=`; widths\n // without a variant fall back to the original server-side, so a static\n // srcSet is safe for any CDN-form URL. Asked of the RESOLVED url, so a\n // reference and a legacy stored path both keep their WebP variants. Any\n // other url has no candidates, and so no `sizes` either.\n //\n // The asset's own width caps the list (AGL-3486): the widest candidate is\n // the source, described truthfully, never a `1920w` for a 1000px photo that\n // the CDN can only answer with the same 1000 pixels.\n const srcSet = Aglyn.mediaCdnSrcSet(src, {\n sourceWidth:\n typeof intrinsicWidth === 'number' &&\n Number.isFinite(intrinsicWidth) &&\n intrinsicWidth > 0\n ? intrinsicWidth\n : undefined,\n })\n /**\n * How wide the image will render, for `sizes` (AGL-3485): the author's own\n * value, else per breakpoint a pixel width or height the element states (in\n * its attributes or its `sx`, responsive values included), what the\n * Besigner measured, and what the layout around it states. See\n * `utils/image-sizes.ts` for the order and for why none of it disturbs\n * layout the way `sizes=\"auto\"` did (AGL-2486).\n *\n * The layout is read off the canvas tree this image is rendered from, which\n * the tenant fills on the server too, so the published HTML carries it.\n */\n const resolvedSizes = imageSizes({\n sizes: authoredSizes,\n width,\n height,\n sx: nodeSxProp,\n intrinsicWidth,\n intrinsicHeight,\n renderedWidths,\n layoutWidths: nodeId\n ? layoutBandWidths(Aglyn.canvas.getNode(nodeId) as never)\n : undefined,\n })\n /**\n * The page's hero: the SCREEN's lead image, loading eagerly, that nothing\n * says renders small. It gets `fetchpriority=\"high\"`, which its preload\n * carries too (AGL-3485) — see `fetchPriority`. Only on a published page:\n * an editing surface has no first paint to win.\n */\n const hero =\n isScreenLead && eager && !resolvedSizes.small && !suppressNavigation\n /**\n * The intrinsic attribute pair, or nothing.\n *\n * Both-or-neither: the browser derives an aspect-ratio only from the pair,\n * and a lone `width` is read as a real dimension instead — which would\n * reserve a box of the wrong shape rather than no box at all. Finite and\n * positive because a media document may carry `0` or a partial capture\n * (dimensions are best-effort at upload), and `width=\"0\"` collapses the\n * element.\n */\n const usable = (value: unknown): value is number =>\n typeof value === 'number' && Number.isFinite(value) && value > 0\n const intrinsicAttributes =\n usable(intrinsicWidth) && usable(intrinsicHeight)\n ? { width: intrinsicWidth, height: intrinsicHeight }\n : undefined\n const sizes = srcSet ? resolvedSizes.sizes : undefined\n return wrapLink(\n <Box\n ref={imageRef}\n component=\"img\"\n src={src}\n // EVERY CANDIDATE IS A `?w=` URL, and the bare one is gone (2026-08-26).\n // Why that is worth 335 KB against 4 KB, and why each candidate merges\n // its width into the url's existing query, are documented where the list\n // and the builder live — `MEDIA_CDN_VARIANT_WIDTHS` and `mediaCdnSrcSet`\n // in `media-ref.ts`.\n //\n // Called rather than restated so the Markdown and entry-body renderers\n // can ask for the same list (AGL-3149). Building it here is the reason\n // they had none: a candidate list inside a component is a candidate list\n // no other component can have.\n srcSet={srcSet}\n // `sizes` is NOT only a delivery hint, and treating it as one broke every\n // fluid image (AGL-2486). With `w` descriptors the browser derives the\n // image's density-corrected INTRINSIC size from `sizes`, so `sizes` is\n // what a CSS `width: 100%` resolves against whenever the containing block\n // is content-sized — shrink-to-fit, inline-block, a flex item sized on its\n // content. Measured in Chrome at a 1200px viewport with the author CSS\n // `width:100%;height:auto;display:block`:\n //\n // parent sizes=100vw sizes=auto\n // inline-block (shrink-to-fit) 1184px 300px\n // block / fixed-width flex 900px 900px\n //\n // 300px is the spec's default object size, used because resolving `auto`\n // against a content-sized parent is circular. So `sizes=\"auto\"` — which\n // genuinely does pick a better candidate, `?w=320` instead of a 357 KB\n // original in a 158px slot — rendered those images tiny and centred in\n // their box, on the canvas, in _preview and on published sites alike.\n //\n // A delivery win may not be paid for in layout, which is why every\n // answer `imageSizes` gives is a real width of the slot (AGL-3485): the\n // author's own value, a definite pixel length, a span of a parent of\n // definite width, or the width the Besigner MEASURED this image at —\n // a fixed point, since an image whose intrinsic width is its measured\n // width lays out at its measured width. `100vw`, the value every\n // published document was authored against, is now only the last resort\n // for a band nothing describes.\n sizes={sizes}\n // Unset alt keeps rendering `alt=\"\"` exactly as it always has —\n // existing documents must not change output (AGL-1305). Decorative\n // ON forces `alt=\"\"` over any alt text and suppresses the tooltip,\n // so the a11y intent is explicit rather than an accident of blank.\n alt={decorative ? '' : (alt ?? '')}\n title={decorative ? undefined : title || undefined}\n // The ordering signal, ONE-DIRECTIONAL on purpose (AGL-2486).\n //\n // `low` on every deferred image, so a footer image cannot be fetched\n // ahead of the section the reader is in. That half is safe in a way the\n // other half is not: deprioritising an image that is provably not being\n // looked at cannot starve whatever the LCP turns out to be.\n //\n // `high` is a claim that this image outranks everything else in flight,\n // including the stylesheet and the webfont a TEXT LCP is waiting on, and\n // it went wrong once (AGL-2486): spent on \"the first `<img>` in document\n // order\", which is the HEADER LOGO on any site whose header has one.\n // Measured on aglyn.com at 375x812, the logo carrying it was 145x44 =\n // 6,380 px² against an `<h1>` of 38,893 px² that Lighthouse named as the\n // LCP — the hint made the real LCP arrive later.\n //\n // So it goes only to the page's HERO (AGL-3485), on two pieces of\n // evidence that did not exist then:\n //\n // - it is the SCREEN's lead image. The layout's lead — the logo — never\n // qualifies, because composition says which origin each node has;\n // - nothing says it renders small. A pixel width of 240 or less, or under\n // 40% of the page in every band the Besigner measured or the layout\n // states, marks a logo or an icon, and those keep the browser's `auto`.\n //\n // The preload is React's own: a server render emits\n // `<link rel=\"preload\" as=\"image\">` in the head for every image that is\n // not lazy, carrying its `srcset`, its `sizes` and this `fetchpriority`\n // — so the hero's request starts with the head, for the same bytes the\n // `<img>` will ask for, and nothing here restates it.\n //\n // Every other lead image — the layout's, or a small one — keeps\n // `loading=\"eager\"` (the discovery fix that earned AGL-2486's win) and\n // the browser's own in-viewport ranking.\n // Decoding off the main thread for the deferred ones — they have no\n // paint deadline, and decoding them synchronously is main-thread time\n // spent on pixels nobody is looking at yet. The eager image keeps the\n // browser's default (`auto`) so it is free to decode in time to paint.\n //\n // The deferred set is `DEFERRED_IMAGE_ATTRIBUTES` rather than three\n // literals because every OTHER `<img>` a published page renders has to\n // land in the same rank to be ranked at all — a product grid, an event\n // list, a cart line. Those carried no hint whatsoever and so were\n // fetched EAGERLY, ahead of anything this component deferred. The set\n // is documented at its definition; the reasoning for each member is\n // the two paragraphs above and the two below.\n {...(eager\n ? {\n loading: 'eager' as const,\n ...(hero ? { fetchPriority: 'high' as const } : {}),\n }\n : Aglyn.DEFERRED_IMAGE_ATTRIBUTES)}\n // Ahead of `{...rest}` so an author who has typed a literal width or\n // height attribute onto the node still wins, and ahead of `sx` because\n // these are ATTRIBUTES: the CSS block below sets the used size, and\n // these only supply the ratio it is laid out against. `Box` in this\n // version applies `styleFunctionSx` alone — it has no system-props\n // layer — so both forward to the `<img>` rather than becoming CSS.\n {...intrinsicAttributes}\n {...rest}\n sx={[\n {\n display: 'block',\n width: width || '100%',\n height: height || 'auto',\n objectFit: objectFit || 'cover',\n borderRadius: radius != null ? `${radius}px` : undefined,\n },\n ...nodeSx,\n ]}\n />,\n )\n})\nImage.displayName = 'Image'\n\n/** Alt text and tooltip make no sense on an explicitly decorative image. */\nconst NOT_DECORATIVE = { when: 'decorative', is: true, notMatch: true }\n\nexport const schema: Aglyn.ComponentSchema<ImageProps> = {\n $id: ID,\n pluginId: BUNDLE_ID,\n displayName: 'Image',\n description:\n 'A picture from your media library or any URL, with fit, size and an optional link.',\n category: Aglyn.ComponentCategory.MEDIA,\n icon: {\n path: mdiImage.path,\n sx: { color: '#7b1fa2' },\n },\n flags: {\n // Static: may sit in a subtree that keeps its server HTML (AGL-3581).\n lazyHydration: Aglyn.FEATURE_FLAG.ENABLED,\n selfClosing: Aglyn.FEATURE_FLAG.ENABLED,\n },\n attributes: [\n {\n name: 'src',\n // \"Browse media\" is the path an author should take (AGL-1215) — it\n // stores a reference to the asset, which survives moves, replaces and\n // any future change to how media is delivered. Typing a URL stays\n // supported for hotlinking somebody else's image; nobody should ever\n // be pasting one of OUR paths in here.\n description:\n 'Pick from your media library with \"Browse media\", or paste the ' +\n 'URL of an image hosted somewhere else.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Image source',\n },\n {\n name: 'alt',\n // AGL-1896: \"Browse media\" now fills this in from the asset's own alt\n // text when it is empty, so the description says where the value came\n // from — otherwise a field that populates itself reads as a bug.\n description:\n 'Describes the image for screen readers and search engines. ' +\n 'Filled in from the media library when you pick a file that has ' +\n 'alt text; anything you type here wins for this placement only.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Alt text',\n // Hidden while Decorative is on — the renderer forces alt=\"\" then,\n // but the text is kept on the node so toggling back restores it.\n condition: NOT_DECORATIVE,\n },\n {\n name: 'decorative',\n description:\n 'Turn on when the image is purely decorative so screen readers ' +\n 'skip it — no alt text is needed then.',\n component: Aglyn.FieldComponentType.SWITCH,\n label: 'Decorative image',\n },\n {\n name: 'title',\n description:\n 'Optional tooltip shown when a visitor hovers over the image.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Tooltip',\n condition: NOT_DECORATIVE,\n },\n {\n name: 'objectFit',\n description: 'How the image fills its box.',\n component: Aglyn.FieldComponentType.SELECT,\n label: 'Fit',\n // Both selects on this element take real sentinels (AGL-1451):\n // `cover` and `lazy` are members of their own declared prop unions\n // and the values the render already falls back to. As `''` neither\n // could persist (AGL-1191) — an image switched to Contain or Eager\n // could not be switched back.\n options: [\n { value: 'cover', label: 'Cover (default)' },\n { value: 'contain', label: 'Contain' },\n { value: 'fill', label: 'Fill' },\n { value: 'none', label: 'None' },\n { value: 'scale-down', label: 'Scale down' },\n ],\n },\n {\n name: 'width',\n description:\n 'Width of the image — a number plus a unit, e.g. 100% or 320px.',\n component: Aglyn.FieldComponentType.CSS_DIMENSION,\n label: 'Width',\n },\n {\n name: 'height',\n description: 'Height of the image. Leave empty for auto.',\n component: Aglyn.FieldComponentType.CSS_DIMENSION,\n label: 'Height',\n },\n {\n // AGL-3485. Empty is the answer for nearly everyone: the image works\n // out how big it renders from where it sits on the page, measured as\n // the page is built. This is the override for someone who knows the\n // HTML attribute and wants to say it themselves.\n name: 'sizes',\n description:\n 'Leave empty — the image works out how big it shows on every ' +\n 'screen size from where you place it, and downloads a file that ' +\n 'size. Advanced: an HTML sizes value used exactly as typed, e.g. ' +\n '(min-width: 900px) 33vw, 100vw.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Sizes',\n },\n {\n name: 'loading',\n description:\n 'Lazy waits to load the image until a visitor scrolls near it; ' +\n 'pick Eager for the first image at the top of a page so it ' +\n 'shows immediately.',\n component: Aglyn.FieldComponentType.SELECT,\n label: 'Loading',\n options: [\n { value: 'lazy', label: 'Lazy (default)' },\n { value: 'eager', label: 'Eager' },\n ],\n },\n {\n name: 'screenId',\n description:\n 'Optional: navigate to this page when the image is clicked — ' +\n 'follows the published path like a Page Link.',\n component: Aglyn.FieldComponentType.SCREEN_SELECT,\n label: 'Link to page',\n },\n {\n name: 'href',\n description:\n 'Makes the image a link to somewhere off this site. Ignored while ' +\n 'Link to page names one. Leave both blank and the image is not ' +\n 'clickable at all.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'External URL',\n // A bare `#fragment` saves and goes nowhere (AGL-2867).\n resolveProps: Aglyn.bareFragmentLinkFieldProps,\n },\n ],\n}\n\nexport const presets: Aglyn.PresetSchema[] = [\n {\n $id: generatePresetId(ID),\n type: Aglyn.NodeType.PRESET,\n displayName: 'Image',\n pluginId: BUNDLE_ID,\n description: 'Image from your media library or any URL',\n category: Aglyn.ComponentCategory.MEDIA,\n icon: {\n path: mdiImage.path,\n sx: { color: '#7b1fa2' },\n },\n data: {\n $id: null,\n componentId: ID,\n pluginId: BUNDLE_ID,\n props: {},\n },\n },\n]\n\nexport default Image\n"],"names":["Aglyn","mdiImage","AppLink","Box","useTheme","useForkRef","forwardRef","useEffect","useState","BUNDLE_ID","generatePresetId","imageSizes","layoutBandWidths","ID","leadImageNodeIds","root","layoutLead","screenLead","walk","node","componentId","props","resolvedProps","String","trim","isLayoutComposedNodeId","$id","child","children","filter","id","Boolean","themeBand","theme","window","matchMedia","band","RENDERED_WIDTH_BANDS","reverse","query","breakpoints","up","replace","matches","pageWidthOf","element","getRootNode","host","ShadowRoot","document","documentElement","clientWidth","useRenderedWidthRecorder","nodeId","enabled","setElement","undefined","ResizeObserver","measure","percent","renderedWidthPercent","getBoundingClientRect","width","recordRenderedWidth","observer","observe","disconnect","Image","ref","src","storedSrc","alt","decorative","title","loading","objectFit","height","sizes","authoredSizes","renderedWidths","mediaVersion","intrinsicWidth","intrinsicHeight","radius","screenId","href","externalHref","_children","sx","nodeSxProp","rest","nodeSx","Array","isArray","linkHref","suppressNavigation","useLinkTarget","useNodeId","leadIds","canvas","rootNode","isLeadImage","some","leadId","leafIdsMatch","screenLeadId","find","isScreenLead","eager","editorInert","useScreenLink","recordRef","imageRef","hostId","useSite","resolveMediaSrc","version","wrapLink","componentVariant","style","display","alignItems","justifyContent","border","borderColor","borderRadius","color","fontSize","fontFamily","srcSet","mediaCdnSrcSet","sourceWidth","Number","isFinite","resolvedSizes","layoutWidths","getNode","hero","small","usable","value","intrinsicAttributes","component","fetchPriority","DEFERRED_IMAGE_ATTRIBUTES","displayName","NOT_DECORATIVE","when","is","notMatch","schema","pluginId","description","category","ComponentCategory","MEDIA","icon","path","flags","lazyHydration","FEATURE_FLAG","ENABLED","selfClosing","attributes","name","FieldComponentType","TEXT_FIELD","label","condition","SWITCH","SELECT","options","CSS_DIMENSION","SCREEN_SELECT","resolveProps","bareFragmentLinkFieldProps","presets","type","NodeType","PRESET","data"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/mui/src/lib/components/image.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { mdiImage } from '@aglyn/shared-data-mdi'\nimport { AppLink } from '@aglyn/shared-ui-jsx'\nimport {\n type LightboxAppearanceProps,\n splitLightboxAppearanceProps,\n} from '@aglyn/shared-ui-jsx/components/lightbox/lightbox-appearance'\nimport Box from '@mui/material/Box'\nimport { type SxProps, type Theme, useTheme } from '@mui/material/styles'\nimport useForkRef from '@mui/utils/useForkRef'\nimport {\n forwardRef,\n type KeyboardEvent,\n lazy,\n type MouseEvent,\n type ReactNode,\n Suspense,\n useCallback,\n useContext,\n useEffect,\n useState,\n} from 'react'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { generatePresetId } from '../utils/generate-preset-id'\nimport { imageSizes, layoutBandWidths } from '../utils/image-sizes'\nimport {\n lightboxAppearanceAttributes,\n WHEN_LIGHTBOX_ON,\n} from '../utils/lightbox-attributes'\nimport {\n imageGalleryOf,\n ImageLightboxGalleryContext,\n LIGHTBOX_ATTRIBUTE,\n LIGHTBOX_CAPTION_ATTRIBUTE,\n type LightboxGallery,\n} from './image-lightbox-items'\n\n/**\n * The picture lightbox, behind a lazy boundary (AGL-3717) for the reason the\n * Video's is (AGL-2744): it names MUI's Dialog, and this module is on every\n * page that shows a picture. It is fetched the first time a visitor points at\n * or focuses a picture that opens, and mounted when they press it.\n */\nconst ImageLightbox = lazy(() =>\n import('./image-lightbox').then((module) => ({\n default: module.ImageLightbox,\n })),\n)\n\n// Component ids are persisted in screen documents; never rename.\nexport const ID: Aglyn.ComponentId = 'image'\n\n/**\n * The node ids of the images that load eagerly: the first image the LAYOUT\n * chain renders and the first image the SCREEN renders, each in document\n * order (AGL-2486).\n *\n * Every image used to render `loading=\"lazy\"` — the hero included. That is\n * the worst possible default for the one image that is almost always the LCP\n * element: a lazy image is not fetched until layout has run and the browser\n * has decided it is near the viewport, and it is fetched at LOW priority when\n * it finally is. Lighthouse reports that as \"LCP request discovery\", and it is\n * also why an image four sections down could finish before the one the reader\n * is looking at: with everything lazy and everything low, nothing outranked\n * anything, so the order was whatever the network felt like.\n *\n * So the lead images get `loading=\"eager\"` and the rest get\n * `fetchpriority=\"low\"`, which is the browser-level knob that stops\n * below-the-fold images competing with the ones above them.\n *\n * ONE lead image was not enough. On any site whose header carries a logo, the\n * first image in document order is that logo, so the screen's own hero — the\n * element Lighthouse names as the LCP on aglyn.com's solution pages at\n * 375x812 — stayed `lazy` + `low`: undiscovered until layout, then fetched\n * behind every script on the page (measured 2026-09-08: 1.5 s of load delay\n * and 1.5 s of load time for a 13 KB image). Composition namespaces layout\n * nodes by origin ({@link Aglyn.isLayoutComposedNodeId}), so the walk keeps\n * one lead PER ORIGIN: the layout's first image (the logo) and the screen's\n * first image (the hero). A layout image further down — a mega-menu\n * illustration, the footer mark — is still deferred, as is every later\n * screen image. In the besigner canvas nothing is layout-composed, so the\n * screen's first image is the only lead, exactly as before.\n *\n * Only the SCREEN's lead can get `fetchpriority=\"high\"` and a preload, and\n * only when nothing says it renders small (AGL-3485); the reasoning is at the\n * `fetchPriority` prop below. The layout's lead is the header logo on any\n * site that has one, which is why \"first image\" alone was never evidence.\n *\n * Resolved from the tree rather than a render-order counter on purpose: the\n * renderer walks the tree in document order on the server AND on hydrate, but\n * a mutable counter would double-count under React's concurrent re-renders\n * and hand the priority to a different image on the client than the one the\n * HTML gave it. A pure function of the tree cannot disagree with itself — and\n * it is deliberately NOT memoized on the root object, because the canvas\n * mutates that tree in place while an author works.\n *\n * The walk STOPS once both leads are known — on a page with a header logo\n * and a hero that is O(nodes above the hero), a dozen or two on a normal\n * page — and at the first image on a screen with no layout.\n *\n * An image with no `src` renders a placeholder box and no `<img>` at all, so\n * it cannot be the LCP element and is skipped.\n */\nexport function leadImageNodeIds(\n root: Aglyn.NodeSchema | undefined,\n): readonly string[] {\n let layoutLead: string | undefined\n let screenLead: string | undefined\n const walk = (node: Aglyn.NodeSchema | undefined): boolean => {\n if (!node) return false\n if (node.componentId === ID) {\n const props = (node.resolvedProps ?? node.props ?? {}) as Record<\n string,\n unknown\n >\n if (String(props['src'] ?? '').trim()) {\n if (Aglyn.isLayoutComposedNodeId(node.$id)) {\n if (!layoutLead) layoutLead = node.$id\n } else if (!screenLead) {\n screenLead = node.$id\n }\n if (layoutLead && screenLead) return true\n }\n }\n for (const child of node.children ?? []) {\n if (walk(child)) return true\n }\n return false\n }\n walk(root)\n return [layoutLead, screenLead].filter((id): id is string => Boolean(id))\n}\n\n/**\n * The breakpoint band the theme says is in effect. Asked of the THEME, not of\n * the window: the Besigner canvas pins its theme's breakpoint helpers to the\n * previewed device (`createDevicePinnedTheme`), so on a pinned device this is\n * the device's band, and in Fluid Responsive it is the window's — which is\n * the band the canvas's own layout is answering to either way.\n */\nfunction themeBand(theme: Theme): Aglyn.RenderedWidthBand {\n if (typeof window === 'undefined' || !window.matchMedia) return 'xs'\n for (const band of [...Aglyn.RENDERED_WIDTH_BANDS].reverse()) {\n if (band === 'xs') break\n const query = theme.breakpoints.up(band).replace(/^@media\\s*/, '')\n if (window.matchMedia(query).matches) return band\n }\n return 'xs'\n}\n\n/**\n * The width of the page an element is laid out in. On the canvas that is the\n * shadow host the page renders into — narrower than the window, and the width\n * a share of \"the viewport\" has to be taken of.\n */\nfunction pageWidthOf(element: HTMLElement): number {\n const root = element.getRootNode()\n const host =\n typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot\n ? (root.host as HTMLElement)\n : document.documentElement\n return host.clientWidth\n}\n\n/**\n * Records how wide this image renders on the Besigner canvas, per band, for\n * the save to write onto the node (AGL-3485, `rendered-widths.ts`). Inert\n * everywhere but the canvas. A `ResizeObserver` on the image and on the page\n * re-records as the author changes the layout, resizes the window or switches\n * device — a device switch also rebuilds the pinned theme, which re-runs this.\n */\nfunction useRenderedWidthRecorder(\n nodeId: string,\n enabled: boolean,\n): (element: HTMLElement | null) => void {\n const theme = useTheme()\n const [element, setElement] = useState<HTMLElement | null>(null)\n useEffect(() => {\n if (!enabled || !element || !nodeId) return undefined\n if (typeof ResizeObserver === 'undefined') return undefined\n const measure = () => {\n const percent = Aglyn.renderedWidthPercent(\n element.getBoundingClientRect().width,\n pageWidthOf(element),\n )\n if (percent !== undefined) {\n Aglyn.recordRenderedWidth(nodeId, themeBand(theme), percent)\n }\n }\n const observer = new ResizeObserver(measure)\n observer.observe(element)\n const root = element.getRootNode()\n if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot) {\n observer.observe(root.host)\n }\n return () => observer.disconnect()\n }, [enabled, element, nodeId, theme])\n return setElement\n}\n\nexport interface ImageProps extends LightboxAppearanceProps {\n /**\n * Where the image comes from (AGL-72). Either a **media reference** —\n * `media:{scope}/{mediaId}`, what \"Browse media\" now stores (AGL-1215) —\n * or any URL, which covers both the legacy values already in published\n * documents and an author-typed hotlink. `resolveMediaSrc` decides.\n */\n src?: string\n alt?: string\n /**\n * Explicit decorative choice (AGL-1305): ON forces `alt=\"\"` (and drops\n * the tooltip) no matter what the alt field says, so screen readers skip\n * the image. Distinct from simply leaving alt unset, which also renders\n * `alt=\"\"` today but records no intent.\n */\n decorative?: boolean\n /** Tooltip shown on hover — the native img `title` attribute. */\n title?: string\n /** Native loading hint; unset stays lazy, exactly as before AGL-1305. */\n loading?: 'lazy' | 'eager'\n objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'\n /** CSS width (e.g. \"100%\", \"320px\"); defaults to 100%. */\n width?: string\n /** CSS height (e.g. \"240px\"); defaults to auto. */\n height?: string\n /**\n * The author's own `sizes` attribute, used exactly as typed (AGL-3485).\n * Empty — the normal case — lets the element work it out: see\n * `utils/image-sizes.ts`.\n */\n sizes?: string\n /**\n * How wide the image rendered on the Besigner canvas, per breakpoint band,\n * as a share of the page: written by the editor's save, never by an author\n * (AGL-3485, `rendered-widths.ts`).\n */\n renderedWidths?: Aglyn.RenderedWidths\n /**\n * The asset's current content hash, laid on by the composition from its\n * media document like the pixel pair below (AGL-3485). It makes the URL the\n * VERSIONED one, which every cache may keep for a year; a replace changes\n * it, and so the URL.\n */\n mediaVersion?: string\n /**\n * The asset's own pixel dimensions, copied off the media document when the\n * image was picked (AGL-2486), and read from the asset again when the page\n * is composed, so a replace with a different shape reaches every page that\n * places it (AGL-2833).\n *\n * NOT author controls, which is why neither appears in the schema below:\n * they describe the file, and the CSS `width`/`height` above describe the\n * placement. The pair becomes the `<img>`'s intrinsic `width`/`height`\n * attributes, which is the only thing that lets the browser reserve the\n * right box before the bytes arrive — with `width: 100%; height: auto` the\n * element is otherwise zero-height until the image decodes, and every image\n * on the page shifts the layout as it lands.\n *\n * An attribute pair is a RATIO here, not a size: CSS wins for the used\n * dimensions either way, so a stale value costs nothing but a reservation\n * of the wrong shape. Both must be present and positive or neither is\n * emitted — one alone gives the browser no ratio and would be read as a\n * real dimension.\n *\n * Read from the node like any other prop, so this element never fetches a\n * media document: the composition has already laid the asset's current\n * pair over the pick's (`media-asset-facts.ts`), and the pick's copy is\n * what renders wherever that read cannot answer. `srcSet` still selects\n * which variant is downloaded; these only say what shape it will be.\n */\n intrinsicWidth?: number\n /** See `intrinsicWidth` — the two are only ever used as a pair. */\n intrinsicHeight?: number\n /** Border radius in px. */\n radius?: number\n /**\n * Opens the picture full size in a lightbox when pressed (AGL-3717). A\n * linked image follows its link instead; an Image inside an Image List\n * whose lightbox is on opens the list's gallery whatever this says.\n */\n lightbox?: boolean | string\n /** A caption under the picture in the lightbox. */\n lightboxCaption?: string\n /**\n * A gallery name: every Image on the page with the same name opens as one\n * gallery, with previous and next. Empty opens this picture alone.\n */\n lightboxGallery?: string\n /** A strip of thumbnails under a gallery's picture. */\n lightboxThumbnails?: boolean | string\n /** Target screen id — resolved rename-safe like Screen Link (AGL-339). */\n screenId?: string\n /** External URL, used only when no `screenId` is set. */\n href?: string\n /**\n * Authored node styles, handed over by the renderer rather than typed into\n * an attribute — recomposed below so the author's Styles-panel values are\n * merged rather than replaced (AGL-1240).\n */\n sx?: SxProps\n /**\n * Accepted and dropped: an `<img>` is a void element and React throws on\n * ANY children value reaching one, which 500'd whole pages (AGL-579).\n * Declared because the implementation deliberately discards it (AGL-1323).\n */\n children?: ReactNode\n}\n\n/**\n * Image element (AGL-74): renders a plain img with fit/size/radius\n * controls; an empty src shows a labeled placeholder so the element stays\n * visible and selectable in the editor.\n */\nconst Image = forwardRef<HTMLElement, ImageProps>((allProps, ref) => {\n // The lightbox's look rides its own props, never the `<img>` (AGL-3717).\n const { appearance: lightboxAppearance, rest: props } =\n splitLightboxAppearanceProps(allProps)\n const {\n src: storedSrc,\n alt,\n decorative,\n title,\n loading,\n objectFit,\n width,\n height,\n sizes: authoredSizes,\n renderedWidths,\n mediaVersion,\n intrinsicWidth,\n intrinsicHeight,\n radius,\n screenId,\n href: externalHref,\n lightbox,\n lightboxCaption,\n lightboxGallery,\n lightboxThumbnails,\n // Never forward children to the <img> below — React throws on ANY\n // children value reaching a void element, which 500'd whole pages\n // when a renderer passed empty JSX children through (AGL-579).\n children: _children,\n // Pull `sx` out of the spread: the literals below are composed AFTER\n // `{...rest}`, so leaving it there REPLACED every style the author set\n // from the Styles panel. The hero mockups' 16px radius and drop shadow\n // were being discarded on every published page (AGL-1240).\n sx: nodeSxProp,\n ...rest\n } = props\n // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).\n const nodeSx = Array.isArray(nodeSxProp)\n ? nodeSxProp\n : nodeSxProp\n ? [nodeSxProp]\n : []\n // Optional link mode (AGL-339): screen id first (rename-safe), external\n // URL as fallback; suppressed in the besigner canvas like Screen Link.\n // Shared with every other linking element since AGL-1335, so a `Link`\n // component prop bound here behaves as it does on a Button.\n const { href: linkHref, suppressNavigation } = Aglyn.useLinkTarget(\n screenId,\n externalHref,\n )\n /**\n * Eagerness (AGL-2486). An explicit author choice always wins — including\n * an explicit `lazy`, so someone who deliberately deferred the top image\n * keeps that. Only an UNSET `loading` is decided here, and only for the\n * lead images: the layout's first and the screen's first.\n *\n * `leafIdsMatch` rather than `===`: a reusable component instance suffixes\n * its leaf ids, so the id in the tree and the id in the context are the\n * same leaf spelled two ways (the markdown block resolves the same way).\n */\n const nodeId = Aglyn.useNodeId()\n const leadIds = nodeId ? leadImageNodeIds(Aglyn.canvas.rootNode) : []\n const isLeadImage = leadIds.some((leadId) =>\n Aglyn.leafIdsMatch(leadId, nodeId),\n )\n const screenLeadId = leadIds.find((id) => !Aglyn.isLayoutComposedNodeId(id))\n const isScreenLead =\n screenLeadId !== undefined && Aglyn.leafIdsMatch(screenLeadId, nodeId)\n const eager = loading === 'eager' || (loading == null && isLeadImage)\n // The canvas measures; nothing else does (AGL-3485).\n const { editorInert } = Aglyn.useScreenLink(undefined)\n const recordRef = useRenderedWidthRecorder(nodeId, Boolean(editorInert))\n const imageRef = useForkRef(ref, recordRef)\n /**\n * Resolve the stored value to a URL (AGL-1215). A media reference becomes\n * a CDN URL here rather than in the document, so the route shape stays an\n * app concern; every other value — a legacy firebasestorage URL, a legacy\n * `/api/media/cdn/…` path, an author-typed hotlink — passes through.\n *\n * `useSite().hostId` is the site being rendered: present on the tenant and\n * in Preview, absent in the besigner canvas. When it is there it names the\n * asking site in the org scope, which is what lets ONE reference in a\n * layout or reusable component resolve on each site that uses it.\n */\n const { hostId } = Aglyn.useSite()\n // With the asset's version when the composition read one (AGL-3485): the\n // versioned URL is the one every cache may keep, and a replace moves it.\n const src = Aglyn.resolveMediaSrc(storedSrc, {\n hostId,\n version: mediaVersion,\n })\n /**\n * The lightbox (AGL-3717). A picture opens when its own switch is on, or\n * when it sits in an Image List whose lightbox is on — then the list's\n * gallery is what opens. A linked picture follows its link: one press\n * cannot both navigate and open a dialog.\n *\n * Nothing of the dialog is fetched until a visitor points at or focuses\n * the picture (`armed`), and nothing renders in it until they press.\n */\n const listGallery = useContext(ImageLightboxGalleryContext)\n const linked = Boolean(linkHref && !suppressNavigation)\n const opens =\n !linked &&\n (Boolean(listGallery) || Aglyn.readYesNoValue(lightbox) === true)\n const [lightboxOpen, setLightboxOpen] = useState(false)\n const [armed, setArmed] = useState(false)\n const [gallery, setGallery] = useState<LightboxGallery>({\n pictures: [],\n index: 0,\n })\n const arm = useCallback(() => {\n if (!listGallery) setArmed(true)\n }, [listGallery])\n const closeLightbox = useCallback(() => setLightboxOpen(false), [])\n const openFrom = (trigger: HTMLElement) => {\n // The canvas is inert (AGL-830): a press there selects the node.\n if (editorInert) return\n if (listGallery) {\n listGallery.open(trigger)\n return\n }\n const next = imageGalleryOf(trigger)\n if (!next.pictures.length) return\n setGallery(next)\n setArmed(true)\n setLightboxOpen(true)\n }\n const lightboxTriggerProps = opens\n ? {\n [LIGHTBOX_ATTRIBUTE]: listGallery ? '' : (lightboxGallery ?? '').trim(),\n ...(lightboxCaption?.trim()\n ? { [LIGHTBOX_CAPTION_ATTRIBUTE]: lightboxCaption.trim() }\n : {}),\n role: 'button',\n tabIndex: 0,\n 'aria-haspopup': 'dialog' as const,\n // A decorative picture has no alt, and a button needs a name.\n ...(decorative || !alt ? { 'aria-label': 'Open picture' } : {}),\n onPointerEnter: arm,\n onFocus: arm,\n onClick: (event: MouseEvent<HTMLElement>) => openFrom(event.currentTarget),\n onKeyDown: (event: KeyboardEvent<HTMLElement>) => {\n if (event.key !== 'Enter' && event.key !== ' ') return\n event.preventDefault()\n openFrom(event.currentTarget)\n },\n }\n : {}\n const withLightbox = (element: JSX.Element) =>\n opens && armed && !listGallery ? (\n <>\n {element}\n {/* `fallback={null}`: the picture is on screen underneath. */}\n <Suspense fallback={null}>\n <ImageLightbox\n open={lightboxOpen}\n onClose={closeLightbox}\n gallery={gallery}\n appearance={lightboxAppearance}\n thumbnails={Aglyn.readYesNoValue(lightboxThumbnails) === true}\n label={lightboxGallery?.trim() || undefined}\n />\n </Suspense>\n </>\n ) : (\n element\n )\n const wrapLink = (element: JSX.Element) =>\n linkHref && !suppressNavigation ? (\n <AppLink\n componentVariant=\"naked\"\n href={linkHref}\n style={{ display: 'block' }}\n >\n {element}\n </AppLink>\n ) : (\n element\n )\n if (!src) {\n // The labeled box is for the author, so only editing surfaces draw it; a\n // published page renders the bare element.\n if (!suppressNavigation) return <Box ref={ref} {...rest} sx={nodeSx} />\n return (\n <Box\n ref={imageRef}\n {...rest}\n sx={[\n {\n width: width || '100%',\n height: height || 120,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n border: '1px dashed',\n borderColor: 'divider',\n borderRadius: radius != null ? `${radius}px` : undefined,\n color: 'text.secondary',\n fontSize: 12,\n fontFamily: 'system-ui, sans-serif',\n },\n ...nodeSx,\n ]}\n >\n {'Image — choose a source'}\n </Box>\n )\n }\n // CDN URLs (AGL-175) carry WebP variants selected by `?w=`; widths\n // without a variant fall back to the original server-side, so a static\n // srcSet is safe for any CDN-form URL. Asked of the RESOLVED url, so a\n // reference and a legacy stored path both keep their WebP variants. Any\n // other url has no candidates, and so no `sizes` either.\n //\n // The asset's own width caps the list (AGL-3486): the widest candidate is\n // the source, described truthfully, never a `1920w` for a 1000px photo that\n // the CDN can only answer with the same 1000 pixels.\n const srcSet = Aglyn.mediaCdnSrcSet(src, {\n sourceWidth:\n typeof intrinsicWidth === 'number' &&\n Number.isFinite(intrinsicWidth) &&\n intrinsicWidth > 0\n ? intrinsicWidth\n : undefined,\n })\n /**\n * How wide the image will render, for `sizes` (AGL-3485): the author's own\n * value, else per breakpoint a pixel width or height the element states (in\n * its attributes or its `sx`, responsive values included), what the\n * Besigner measured, and what the layout around it states. See\n * `utils/image-sizes.ts` for the order and for why none of it disturbs\n * layout the way `sizes=\"auto\"` did (AGL-2486).\n *\n * The layout is read off the canvas tree this image is rendered from, which\n * the tenant fills on the server too, so the published HTML carries it.\n */\n const resolvedSizes = imageSizes({\n sizes: authoredSizes,\n width,\n height,\n sx: nodeSxProp,\n intrinsicWidth,\n intrinsicHeight,\n renderedWidths,\n layoutWidths: nodeId\n ? layoutBandWidths(Aglyn.canvas.getNode(nodeId) as never)\n : undefined,\n })\n /**\n * The page's hero: the SCREEN's lead image, loading eagerly, that nothing\n * says renders small. It gets `fetchpriority=\"high\"`, which its preload\n * carries too (AGL-3485) — see `fetchPriority`. Only on a published page:\n * an editing surface has no first paint to win.\n */\n const hero =\n isScreenLead && eager && !resolvedSizes.small && !suppressNavigation\n /**\n * The intrinsic attribute pair, or nothing.\n *\n * Both-or-neither: the browser derives an aspect-ratio only from the pair,\n * and a lone `width` is read as a real dimension instead — which would\n * reserve a box of the wrong shape rather than no box at all. Finite and\n * positive because a media document may carry `0` or a partial capture\n * (dimensions are best-effort at upload), and `width=\"0\"` collapses the\n * element.\n */\n const usable = (value: unknown): value is number =>\n typeof value === 'number' && Number.isFinite(value) && value > 0\n const intrinsicAttributes =\n usable(intrinsicWidth) && usable(intrinsicHeight)\n ? { width: intrinsicWidth, height: intrinsicHeight }\n : undefined\n const sizes = srcSet ? resolvedSizes.sizes : undefined\n return withLightbox(wrapLink(\n <Box\n ref={imageRef}\n component=\"img\"\n src={src}\n // EVERY CANDIDATE IS A `?w=` URL, and the bare one is gone (2026-08-26).\n // Why that is worth 335 KB against 4 KB, and why each candidate merges\n // its width into the url's existing query, are documented where the list\n // and the builder live — `MEDIA_CDN_VARIANT_WIDTHS` and `mediaCdnSrcSet`\n // in `media-ref.ts`.\n //\n // Called rather than restated so the Markdown and entry-body renderers\n // can ask for the same list (AGL-3149). Building it here is the reason\n // they had none: a candidate list inside a component is a candidate list\n // no other component can have.\n srcSet={srcSet}\n // `sizes` is NOT only a delivery hint, and treating it as one broke every\n // fluid image (AGL-2486). With `w` descriptors the browser derives the\n // image's density-corrected INTRINSIC size from `sizes`, so `sizes` is\n // what a CSS `width: 100%` resolves against whenever the containing block\n // is content-sized — shrink-to-fit, inline-block, a flex item sized on its\n // content. Measured in Chrome at a 1200px viewport with the author CSS\n // `width:100%;height:auto;display:block`:\n //\n // parent sizes=100vw sizes=auto\n // inline-block (shrink-to-fit) 1184px 300px\n // block / fixed-width flex 900px 900px\n //\n // 300px is the spec's default object size, used because resolving `auto`\n // against a content-sized parent is circular. So `sizes=\"auto\"` — which\n // genuinely does pick a better candidate, `?w=320` instead of a 357 KB\n // original in a 158px slot — rendered those images tiny and centred in\n // their box, on the canvas, in _preview and on published sites alike.\n //\n // A delivery win may not be paid for in layout, which is why every\n // answer `imageSizes` gives is a real width of the slot (AGL-3485): the\n // author's own value, a definite pixel length, a span of a parent of\n // definite width, or the width the Besigner MEASURED this image at —\n // a fixed point, since an image whose intrinsic width is its measured\n // width lays out at its measured width. `100vw`, the value every\n // published document was authored against, is now only the last resort\n // for a band nothing describes.\n sizes={sizes}\n // Unset alt keeps rendering `alt=\"\"` exactly as it always has —\n // existing documents must not change output (AGL-1305). Decorative\n // ON forces `alt=\"\"` over any alt text and suppresses the tooltip,\n // so the a11y intent is explicit rather than an accident of blank.\n alt={decorative ? '' : (alt ?? '')}\n title={decorative ? undefined : title || undefined}\n // The ordering signal, ONE-DIRECTIONAL on purpose (AGL-2486).\n //\n // `low` on every deferred image, so a footer image cannot be fetched\n // ahead of the section the reader is in. That half is safe in a way the\n // other half is not: deprioritising an image that is provably not being\n // looked at cannot starve whatever the LCP turns out to be.\n //\n // `high` is a claim that this image outranks everything else in flight,\n // including the stylesheet and the webfont a TEXT LCP is waiting on, and\n // it went wrong once (AGL-2486): spent on \"the first `<img>` in document\n // order\", which is the HEADER LOGO on any site whose header has one.\n // Measured on aglyn.com at 375x812, the logo carrying it was 145x44 =\n // 6,380 px² against an `<h1>` of 38,893 px² that Lighthouse named as the\n // LCP — the hint made the real LCP arrive later.\n //\n // So it goes only to the page's HERO (AGL-3485), on two pieces of\n // evidence that did not exist then:\n //\n // - it is the SCREEN's lead image. The layout's lead — the logo — never\n // qualifies, because composition says which origin each node has;\n // - nothing says it renders small. A pixel width of 240 or less, or under\n // 40% of the page in every band the Besigner measured or the layout\n // states, marks a logo or an icon, and those keep the browser's `auto`.\n //\n // The preload is React's own: a server render emits\n // `<link rel=\"preload\" as=\"image\">` in the head for every image that is\n // not lazy, carrying its `srcset`, its `sizes` and this `fetchpriority`\n // — so the hero's request starts with the head, for the same bytes the\n // `<img>` will ask for, and nothing here restates it.\n //\n // Every other lead image — the layout's, or a small one — keeps\n // `loading=\"eager\"` (the discovery fix that earned AGL-2486's win) and\n // the browser's own in-viewport ranking.\n // Decoding off the main thread for the deferred ones — they have no\n // paint deadline, and decoding them synchronously is main-thread time\n // spent on pixels nobody is looking at yet. The eager image keeps the\n // browser's default (`auto`) so it is free to decode in time to paint.\n //\n // The deferred set is `DEFERRED_IMAGE_ATTRIBUTES` rather than three\n // literals because every OTHER `<img>` a published page renders has to\n // land in the same rank to be ranked at all — a product grid, an event\n // list, a cart line. Those carried no hint whatsoever and so were\n // fetched EAGERLY, ahead of anything this component deferred. The set\n // is documented at its definition; the reasoning for each member is\n // the two paragraphs above and the two below.\n {...(eager\n ? {\n loading: 'eager' as const,\n ...(hero ? { fetchPriority: 'high' as const } : {}),\n }\n : Aglyn.DEFERRED_IMAGE_ATTRIBUTES)}\n // Ahead of `{...rest}` so an author who has typed a literal width or\n // height attribute onto the node still wins, and ahead of `sx` because\n // these are ATTRIBUTES: the CSS block below sets the used size, and\n // these only supply the ratio it is laid out against. `Box` in this\n // version applies `styleFunctionSx` alone — it has no system-props\n // layer — so both forward to the `<img>` rather than becoming CSS.\n {...intrinsicAttributes}\n {...rest}\n {...lightboxTriggerProps}\n sx={[\n {\n display: 'block',\n width: width || '100%',\n height: height || 'auto',\n objectFit: objectFit || 'cover',\n borderRadius: radius != null ? `${radius}px` : undefined,\n },\n ...(opens ? [{ cursor: 'zoom-in' }] : []),\n ...nodeSx,\n ]}\n />,\n ))\n})\nImage.displayName = 'Image'\n\n/** Alt text and tooltip make no sense on an explicitly decorative image. */\nconst NOT_DECORATIVE = { when: 'decorative', is: true, notMatch: true }\n\nexport const schema: Aglyn.ComponentSchema<ImageProps> = {\n $id: ID,\n pluginId: BUNDLE_ID,\n displayName: 'Image',\n description:\n 'A picture from your media library or any URL, with fit, size and an optional link.',\n category: Aglyn.ComponentCategory.MEDIA,\n icon: {\n path: mdiImage.path,\n sx: { color: '#7b1fa2' },\n },\n flags: {\n // Static: may sit in a subtree that keeps its server HTML (AGL-3581).\n lazyHydration: Aglyn.FEATURE_FLAG.ENABLED,\n selfClosing: Aglyn.FEATURE_FLAG.ENABLED,\n },\n attributes: [\n {\n name: 'src',\n // \"Browse media\" is the path an author should take (AGL-1215) — it\n // stores a reference to the asset, which survives moves, replaces and\n // any future change to how media is delivered. Typing a URL stays\n // supported for hotlinking somebody else's image; nobody should ever\n // be pasting one of OUR paths in here.\n description:\n 'Pick from your media library with \"Browse media\", or paste the ' +\n 'URL of an image hosted somewhere else.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Image source',\n },\n {\n name: 'alt',\n // AGL-1896: \"Browse media\" now fills this in from the asset's own alt\n // text when it is empty, so the description says where the value came\n // from — otherwise a field that populates itself reads as a bug.\n description:\n 'Describes the image for screen readers and search engines. ' +\n 'Filled in from the media library when you pick a file that has ' +\n 'alt text; anything you type here wins for this placement only.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Alt text',\n // Hidden while Decorative is on — the renderer forces alt=\"\" then,\n // but the text is kept on the node so toggling back restores it.\n condition: NOT_DECORATIVE,\n },\n {\n name: 'decorative',\n description:\n 'Turn on when the image is purely decorative so screen readers ' +\n 'skip it — no alt text is needed then.',\n component: Aglyn.FieldComponentType.SWITCH,\n label: 'Decorative image',\n },\n {\n name: 'title',\n description:\n 'Optional tooltip shown when a visitor hovers over the image.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Tooltip',\n condition: NOT_DECORATIVE,\n },\n {\n name: 'objectFit',\n description: 'How the image fills its box.',\n component: Aglyn.FieldComponentType.SELECT,\n label: 'Fit',\n // Both selects on this element take real sentinels (AGL-1451):\n // `cover` and `lazy` are members of their own declared prop unions\n // and the values the render already falls back to. As `''` neither\n // could persist (AGL-1191) — an image switched to Contain or Eager\n // could not be switched back.\n options: [\n { value: 'cover', label: 'Cover (default)' },\n { value: 'contain', label: 'Contain' },\n { value: 'fill', label: 'Fill' },\n { value: 'none', label: 'None' },\n { value: 'scale-down', label: 'Scale down' },\n ],\n },\n {\n name: 'width',\n description:\n 'Width of the image — a number plus a unit, e.g. 100% or 320px.',\n component: Aglyn.FieldComponentType.CSS_DIMENSION,\n label: 'Width',\n },\n {\n name: 'height',\n description: 'Height of the image. Leave empty for auto.',\n component: Aglyn.FieldComponentType.CSS_DIMENSION,\n label: 'Height',\n },\n {\n // AGL-3485. Empty is the answer for nearly everyone: the image works\n // out how big it renders from where it sits on the page, measured as\n // the page is built. This is the override for someone who knows the\n // HTML attribute and wants to say it themselves.\n name: 'sizes',\n description:\n 'Leave empty — the image works out how big it shows on every ' +\n 'screen size from where you place it, and downloads a file that ' +\n 'size. Advanced: an HTML sizes value used exactly as typed, e.g. ' +\n '(min-width: 900px) 33vw, 100vw.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Sizes',\n },\n {\n name: 'loading',\n description:\n 'Lazy waits to load the image until a visitor scrolls near it; ' +\n 'pick Eager for the first image at the top of a page so it ' +\n 'shows immediately.',\n component: Aglyn.FieldComponentType.SELECT,\n label: 'Loading',\n options: [\n { value: 'lazy', label: 'Lazy (default)' },\n { value: 'eager', label: 'Eager' },\n ],\n },\n {\n name: 'lightbox',\n description:\n 'Opens the picture full size in a lightbox when a visitor clicks ' +\n 'it. A linked image follows its link instead.',\n component: Aglyn.FieldComponentType.SWITCH,\n label: 'Open in a lightbox',\n },\n {\n name: 'lightboxCaption',\n description: 'A caption shown with the picture in the lightbox.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Lightbox caption',\n condition: WHEN_LIGHTBOX_ON,\n },\n {\n name: 'lightboxGallery',\n description:\n 'Give pictures the same gallery name and they open as one gallery, ' +\n 'with previous and next. Leave empty to open this picture alone.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Gallery name',\n condition: WHEN_LIGHTBOX_ON,\n },\n {\n name: 'lightboxThumbnails',\n description: 'Shows a strip of thumbnails under a gallery picture.',\n component: Aglyn.FieldComponentType.SWITCH,\n label: 'Gallery thumbnails',\n condition: WHEN_LIGHTBOX_ON,\n },\n ...lightboxAppearanceAttributes(WHEN_LIGHTBOX_ON),\n {\n name: 'screenId',\n description:\n 'Optional: navigate to this page when the image is clicked — ' +\n 'follows the published path like a Page Link.',\n component: Aglyn.FieldComponentType.SCREEN_SELECT,\n label: 'Link to page',\n },\n {\n name: 'href',\n description:\n 'Makes the image a link to somewhere off this site. Ignored while ' +\n 'Link to page names one. Leave both blank and the image is not ' +\n 'clickable at all.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'External URL',\n // A bare `#fragment` saves and goes nowhere (AGL-2867).\n resolveProps: Aglyn.bareFragmentLinkFieldProps,\n },\n ],\n}\n\nexport const presets: Aglyn.PresetSchema[] = [\n {\n $id: generatePresetId(ID),\n type: Aglyn.NodeType.PRESET,\n displayName: 'Image',\n pluginId: BUNDLE_ID,\n description: 'Image from your media library or any URL',\n category: Aglyn.ComponentCategory.MEDIA,\n icon: {\n path: mdiImage.path,\n sx: { color: '#7b1fa2' },\n },\n data: {\n $id: null,\n componentId: ID,\n pluginId: BUNDLE_ID,\n props: {},\n },\n },\n]\n\nexport default Image\n"],"names":["Aglyn","mdiImage","AppLink","splitLightboxAppearanceProps","Box","useTheme","useForkRef","forwardRef","lazy","Suspense","useCallback","useContext","useEffect","useState","BUNDLE_ID","generatePresetId","imageSizes","layoutBandWidths","lightboxAppearanceAttributes","WHEN_LIGHTBOX_ON","imageGalleryOf","ImageLightboxGalleryContext","LIGHTBOX_ATTRIBUTE","LIGHTBOX_CAPTION_ATTRIBUTE","ImageLightbox","then","module","default","ID","leadImageNodeIds","root","layoutLead","screenLead","walk","node","componentId","props","resolvedProps","String","trim","isLayoutComposedNodeId","$id","child","children","filter","id","Boolean","themeBand","theme","window","matchMedia","band","RENDERED_WIDTH_BANDS","reverse","query","breakpoints","up","replace","matches","pageWidthOf","element","getRootNode","host","ShadowRoot","document","documentElement","clientWidth","useRenderedWidthRecorder","nodeId","enabled","setElement","undefined","ResizeObserver","measure","percent","renderedWidthPercent","getBoundingClientRect","width","recordRenderedWidth","observer","observe","disconnect","Image","allProps","ref","appearance","lightboxAppearance","rest","src","storedSrc","alt","decorative","title","loading","objectFit","height","sizes","authoredSizes","renderedWidths","mediaVersion","intrinsicWidth","intrinsicHeight","radius","screenId","href","externalHref","lightbox","lightboxCaption","lightboxGallery","lightboxThumbnails","_children","sx","nodeSxProp","nodeSx","Array","isArray","linkHref","suppressNavigation","useLinkTarget","useNodeId","leadIds","canvas","rootNode","isLeadImage","some","leadId","leafIdsMatch","screenLeadId","find","isScreenLead","eager","editorInert","useScreenLink","recordRef","imageRef","hostId","useSite","resolveMediaSrc","version","listGallery","linked","opens","readYesNoValue","lightboxOpen","setLightboxOpen","armed","setArmed","gallery","setGallery","pictures","index","arm","closeLightbox","openFrom","trigger","open","next","length","lightboxTriggerProps","role","tabIndex","onPointerEnter","onFocus","onClick","event","currentTarget","onKeyDown","key","preventDefault","withLightbox","fallback","onClose","thumbnails","label","wrapLink","componentVariant","style","display","alignItems","justifyContent","border","borderColor","borderRadius","color","fontSize","fontFamily","srcSet","mediaCdnSrcSet","sourceWidth","Number","isFinite","resolvedSizes","layoutWidths","getNode","hero","small","usable","value","intrinsicAttributes","component","fetchPriority","DEFERRED_IMAGE_ATTRIBUTES","cursor","displayName","NOT_DECORATIVE","when","is","notMatch","schema","pluginId","description","category","ComponentCategory","MEDIA","icon","path","flags","lazyHydration","FEATURE_FLAG","ENABLED","selfClosing","attributes","name","FieldComponentType","TEXT_FIELD","condition","SWITCH","SELECT","options","CSS_DIMENSION","SCREEN_SELECT","resolveProps","bareFragmentLinkFieldProps","presets","type","NodeType","PRESET","data"],"mappings":"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"}
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import type { LightboxAppearance } from '@aglyn/shared-ui-jsx/components/lightbox/lightbox-appearance';
17
18
  import { type ReactNode } from 'react';
18
19
  import { type VideoPlaybackBeaconOptions } from './video-playback-beacon';
19
20
  export interface VideoLightboxProps {
@@ -42,6 +43,11 @@ export interface VideoLightboxProps {
42
43
  * that player and never plays `src` itself.
43
44
  */
44
45
  embedSrc?: string;
46
+ /**
47
+ * The author's lightbox settings (AGL-3717). Absent, the dialog is exactly
48
+ * the one AGL-2744 shipped: wide, black, the close control in the corner.
49
+ */
50
+ appearance?: LightboxAppearance;
45
51
  }
46
52
  /**
47
53
  * The dialog. Rendered only while `open`, and unmounted when it closes.
@@ -18,20 +18,23 @@
18
18
  *
19
19
  * ## Why this is its own module
20
20
  *
21
- * This file is the ONLY place in the tenant's component graph that names
22
- * `@mui/material/Dialog`, and it must stay that way. `libs/shared/ui/jsx`'s
23
- * barrel deliberately withholds `dialog-confirm`, `loading-modal` and
24
- * `navigation-drawer` because the MUI Dialog/Drawer/Popper stack was measured
25
- * sitting in the first-load chunks of `/pricing` — a page that opens no dialog
26
- * (AGL-1290). `plugin.ts` imports every element eagerly, so a `Dialog` named
27
- * at the top of `video.tsx` would put that whole stack back on every published
28
- * page of every customer site, including the ones with no video on them.
21
+ * The dialog itself is the shared lightbox shell (AGL-3717,
22
+ * `@aglyn/shared-ui-jsx/components/lightbox/lightbox-dialog`), the same one
23
+ * the Image, the Image List gallery and the Lightbox container open. What is
24
+ * the Video's own is what goes inside it: the film, its controls, the hosted
25
+ * player frame and the play beacon.
29
26
  *
30
- * Split out, it is a chunk `video.tsx` reaches through `lazy(() => import())`
31
- * and mounts only once a visitor has actually asked to watch something. A page
32
- * with no video pays nothing; a page with a video pays nothing; a visitor who
33
- * clicks pays once. The player's controls, `video-lightbox-controls.tsx`, are
34
- * imported from here and from nowhere else, so they ride in the same chunk.
27
+ * The shell names `@mui/material/Dialog`, which must never be reached from a
28
+ * first paint. `libs/shared/ui/jsx`'s barrel deliberately withholds
29
+ * `dialog-confirm`, `loading-modal` and `navigation-drawer` because the MUI
30
+ * Dialog/Drawer/Popper stack was measured sitting in the first-load chunks of
31
+ * `/pricing` — a page that opens no dialog (AGL-1290). `plugin.ts` imports
32
+ * every element eagerly, so this module is a chunk `video.tsx` reaches through
33
+ * `lazy(() => import())` and mounts only once a visitor has actually asked to
34
+ * watch something. A page with no video pays nothing; a page with a video
35
+ * pays nothing; a visitor who clicks pays once. The player's controls,
36
+ * `video-lightbox-controls.tsx`, are imported from here and from nowhere
37
+ * else, so they ride in the same chunk.
35
38
  *
36
39
  * It is still MUI's `Dialog` and still the site's own theme. The split is
37
40
  * about WHEN the dialog arrives, not about hand-rolling a modal — and the
@@ -51,12 +54,8 @@
51
54
  */ 'use client';
52
55
  import { _ as _extends } from "@swc/helpers/_/_extends";
53
56
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
54
- import { mdiClose } from "@aglyn/shared-data-mdi";
55
- import { MdiIcon } from "@aglyn/shared-ui-jsx";
57
+ import { LightboxCloseButton, LightboxDialog } from "@aglyn/shared-ui-jsx/components/lightbox/lightbox-dialog";
56
58
  import Box from "@mui/material/Box";
57
- import Dialog from "@mui/material/Dialog";
58
- import IconButton from "@mui/material/IconButton";
59
- import useMediaQuery from "@mui/material/useMediaQuery";
60
59
  import { useRef } from "react";
61
60
  import { controlButtonSx, handlePlayerShortcut, togglePlayback, VideoLightboxControls } from "./video-lightbox-controls.js";
62
61
  import { useVideoPlaybackBeacon } from "./video-playback-beacon.js";
@@ -72,27 +71,23 @@ import { VideoPlayerFrame } from "./video-player-frame.js";
72
71
  * here than it would elsewhere, because the whole element exists to keep a
73
72
  * multi-megabyte file off the wire until it is wanted.
74
73
  */ export function VideoLightbox(props) {
75
- const { open, onClose, title, src, poster, aspectRatio, loop, muted, captions, playback, embedSrc } = props;
74
+ const { open, onClose, title, src, poster, aspectRatio, loop, muted, captions, playback, embedSrc, appearance } = props;
76
75
  const playbackHandlers = useVideoPlaybackBeacon(_extends({}, playback, {
77
76
  viewingKey: open
78
77
  }));
79
78
  const videoRef = useRef(null);
80
79
  /** The frame that goes full screen, controls and all. */ const playerRef = useRef(null);
81
- /**
82
- * `prefers-reduced-motion` reaches the DIALOG, not the video.
83
- *
84
- * The overlay's fade-and-grow is incidental motion a visitor did not ask
85
- * for, so it is dropped to zero — MUI reads `transitionDuration` for both
86
- * the backdrop and the paper, so one value covers the whole open.
80
+ /*
81
+ * `prefers-reduced-motion` reaches the DIALOG, not the video: the shared
82
+ * shell drops its transition to nothing (AGL-3717 moved that rule there).
87
83
  *
88
- * The FILM is deliberately left alone, and that is the more interesting
89
- * half. Autoplay here is not autoplay: the visitor pressed a play button,
90
- * and a play button that opens a paused video is a defect, not an
91
- * accommodation. WCAG's auto-motion requirement is about motion that
92
- * starts without the user, and it is satisfied anyway — the player's
93
- * controls are on screen, so the video can be paused at any moment.
94
- */ const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
95
- /**
84
+ * The FILM is deliberately left alone. Autoplay here is not autoplay: the
85
+ * visitor pressed a play button, and a play button that opens a paused
86
+ * video is a defect, not an accommodation. WCAG's auto-motion requirement
87
+ * is about motion that starts without the user, and it is satisfied anyway
88
+ * — the player's controls are on screen, so the video can be paused at any
89
+ * moment.
90
+ */ /**
96
91
  * Pauses before closing, so the sound stops on the press rather than when
97
92
  * the exit transition ends and the `<video>` leaves the tree.
98
93
  */ const closeDialog = ()=>{
@@ -105,47 +100,37 @@ import { VideoPlayerFrame } from "./video-player-frame.js";
105
100
  * browser normally exits full screen on that key before the page hears it;
106
101
  * this covers one that passes the key on as well, so one press never does
107
102
  * both.
108
- */ const handleClose = (_event, reason)=>{
103
+ */ const interceptClose = (reason)=>{
109
104
  var _playerRef_current;
110
105
  const doc = (_playerRef_current = playerRef.current) == null ? void 0 : _playerRef_current.ownerDocument;
111
106
  if (reason === 'escapeKeyDown' && (doc == null ? void 0 : doc.fullscreenElement)) {
112
107
  void Promise.resolve(doc.exitFullscreen()).catch(()=>undefined);
113
- return;
108
+ return true;
114
109
  }
115
- closeDialog();
110
+ return false;
116
111
  };
117
- return /*#__PURE__*/ _jsx(Dialog, {
112
+ return /*#__PURE__*/ _jsx(LightboxDialog, {
118
113
  open: open,
119
- onClose: handleClose,
114
+ onClose: closeDialog,
115
+ interceptClose: interceptClose,
120
116
  onKeyDown: (event)=>handlePlayerShortcut(event, {
121
117
  video: videoRef.current,
122
118
  player: playerRef.current
123
119
  }),
124
120
  maxWidth: "lg",
125
121
  fullWidth: true,
126
- transitionDuration: reduceMotion ? 0 : undefined,
127
- slotProps: {
128
- paper: {
129
- /**
130
- * ⚠️ The label goes on the PAPER, not on `<Dialog>`.
131
- *
132
- * `role="dialog"` lives on the paper; props spread onto `Dialog`
133
- * reach the Modal root, which is `role="presentation"`. Written
134
- * there first and measured in a browser: the presentation root
135
- * carried the name and the dialog itself had none, which is an
136
- * unlabelled dialog announced to a screen reader — the exact
137
- * failure the label was added to prevent. No `DialogTitle`,
138
- * deliberately: the film is the content, and a heading above it
139
- * would only push it down.
140
- */ 'aria-label': title || FALLBACK_LABEL,
141
- // The paper is a frame around a video, not a sheet of content:
142
- // no padding, and a black ground so a letterboxed film has
143
- // something to sit on rather than a white margin.
144
- sx: {
145
- overflow: 'hidden',
146
- bgcolor: 'common.black'
147
- }
148
- }
122
+ // No `DialogTitle`, deliberately: the film is the content, and a
123
+ // heading above it would only push it down. The name rides the paper.
124
+ label: title || FALLBACK_LABEL,
125
+ // The close control lives inside the frame that goes full screen.
126
+ closeButton: false,
127
+ appearance: appearance,
128
+ // The paper is a frame around a video, not a sheet of content: no
129
+ // padding, and a black ground so a letterboxed film has something to
130
+ // sit on rather than a white margin.
131
+ paperSx: {
132
+ overflow: 'hidden',
133
+ bgcolor: 'common.black'
149
134
  },
150
135
  children: /*#__PURE__*/ _jsxs(Box, {
151
136
  ref: playerRef,
@@ -161,26 +146,20 @@ import { VideoPlayerFrame } from "./video-player-frame.js";
161
146
  bgcolor: 'common.black'
162
147
  },
163
148
  children: [
164
- /*#__PURE__*/ _jsx(IconButton, {
149
+ /*#__PURE__*/ _jsx(LightboxCloseButton, {
165
150
  onClick: closeDialog,
166
- "aria-label": "Close video",
167
- size: "small",
151
+ label: "Close video",
152
+ appearance: appearance,
168
153
  sx: [
169
154
  controlButtonSx,
170
- (theme)=>({
171
- position: 'absolute',
172
- top: theme.spacing(1),
173
- right: theme.spacing(1),
174
- zIndex: 1,
175
- // Over frames of unknown brightness, so the control carries its
176
- // own ground rather than trusting the film's.
155
+ // Over frames of unknown brightness, so the control carries its
156
+ // own ground rather than trusting the film's.
157
+ ...(appearance == null ? void 0 : appearance.closeStyle) ? [] : [
158
+ {
177
159
  bgcolor: 'common.black'
178
- })
179
- ],
180
- children: /*#__PURE__*/ _jsx(MdiIcon, {
181
- path: mdiClose.path,
182
- fontSize: "small"
183
- })
160
+ }
161
+ ]
162
+ ]
184
163
  }),
185
164
  embedSrc ? // A hosted player draws its own controls inside its frame, so the
186
165
  // dialog adds none, and its shortcuts find no film to act on.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/mui/src/lib/components/video-lightbox.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The Video element's lightbox (AGL-2744).\n *\n * ## Why this is its own module\n *\n * This file is the ONLY place in the tenant's component graph that names\n * `@mui/material/Dialog`, and it must stay that way. `libs/shared/ui/jsx`'s\n * barrel deliberately withholds `dialog-confirm`, `loading-modal` and\n * `navigation-drawer` because the MUI Dialog/Drawer/Popper stack was measured\n * sitting in the first-load chunks of `/pricing` — a page that opens no dialog\n * (AGL-1290). `plugin.ts` imports every element eagerly, so a `Dialog` named\n * at the top of `video.tsx` would put that whole stack back on every published\n * page of every customer site, including the ones with no video on them.\n *\n * Split out, it is a chunk `video.tsx` reaches through `lazy(() => import())`\n * and mounts only once a visitor has actually asked to watch something. A page\n * with no video pays nothing; a page with a video pays nothing; a visitor who\n * clicks pays once. The player's controls, `video-lightbox-controls.tsx`, are\n * imported from here and from nowhere else, so they ride in the same chunk.\n *\n * It is still MUI's `Dialog` and still the site's own theme. The split is\n * about WHEN the dialog arrives, not about hand-rolling a modal — and the\n * accessibility is most of why. `Dialog` traps focus for the life of the\n * overlay, restores it to the element that opened it on close, closes on\n * `Escape`, and marks the rest of the document inert for assistive\n * technology. Every one of those is a thing a bespoke overlay gets wrong.\n *\n * ## Why the film has no `controls` attribute here\n *\n * The browser's own controls swallow `Escape` (AGL-2802): from inside them no\n * key event reaches the page, so the dialog could not be dismissed from the\n * stops a keyboard visitor is on while watching. The player draws its own\n * controls instead; `video-lightbox-controls.tsx` explains the measurement and\n * carries the key map. The inline player keeps the browser's controls, because\n * nothing on a page is listening for `Escape` there.\n */\n'use client'\n\nimport { mdiClose } from '@aglyn/shared-data-mdi'\nimport { MdiIcon } from '@aglyn/shared-ui-jsx'\nimport Box from '@mui/material/Box'\nimport Dialog, { type DialogProps } from '@mui/material/Dialog'\nimport IconButton from '@mui/material/IconButton'\nimport useMediaQuery from '@mui/material/useMediaQuery'\nimport { type ReactNode, useRef } from 'react'\nimport {\n controlButtonSx,\n handlePlayerShortcut,\n togglePlayback,\n VideoLightboxControls,\n} from './video-lightbox-controls'\nimport {\n useVideoPlaybackBeacon,\n type VideoPlaybackBeaconOptions,\n} from './video-playback-beacon'\nimport { VideoPlayerFrame } from './video-player-frame'\n\nexport interface VideoLightboxProps {\n open: boolean\n onClose: () => void\n /** Names the dialog for assistive technology; also the video's own title. */\n title?: string\n /** Resolved video URL — already through `resolveMediaSrc`. */\n src: string\n /** Resolved poster URL, shown for the instant before the first frame. */\n poster?: string\n /** `width / height` from the asset, when the DAM measured it. */\n aspectRatio?: string\n loop?: boolean\n muted?: boolean\n /** The `<track>` the inline element would have rendered, if any. */\n captions?: ReactNode\n /**\n * What a play in this dialog is counted against (AGL-2781). Each open is\n * its own viewing, so the element passes the facts and the dialog keys the\n * viewing to `open`.\n */\n playback?: Omit<VideoPlaybackBeaconOptions, 'viewingKey'>\n /**\n * A hosted player's frame address (AGL-2826). When set, the dialog frames\n * that player and never plays `src` itself.\n */\n embedSrc?: string\n}\n\n/** What a dialog is called when the author gave the video no title. */\nconst FALLBACK_LABEL = 'Video'\n\n/**\n * The dialog. Rendered only while `open`, and unmounted when it closes.\n *\n * Unmounting is not tidiness — it is the only thing that reliably STOPS the\n * download. A `<video>` left in the tree paused still holds whatever it has\n * buffered and may keep filling that buffer; taking the element out of the\n * document is what tells the browser the request is over. That matters more\n * here than it would elsewhere, because the whole element exists to keep a\n * multi-megabyte file off the wire until it is wanted.\n */\nexport function VideoLightbox(props: VideoLightboxProps) {\n const {\n open,\n onClose,\n title,\n src,\n poster,\n aspectRatio,\n loop,\n muted,\n captions,\n playback,\n embedSrc,\n } = props\n const playbackHandlers = useVideoPlaybackBeacon({\n ...playback,\n viewingKey: open,\n })\n const videoRef = useRef<HTMLVideoElement>(null)\n /** The frame that goes full screen, controls and all. */\n const playerRef = useRef<HTMLDivElement>(null)\n /**\n * `prefers-reduced-motion` reaches the DIALOG, not the video.\n *\n * The overlay's fade-and-grow is incidental motion a visitor did not ask\n * for, so it is dropped to zero — MUI reads `transitionDuration` for both\n * the backdrop and the paper, so one value covers the whole open.\n *\n * The FILM is deliberately left alone, and that is the more interesting\n * half. Autoplay here is not autoplay: the visitor pressed a play button,\n * and a play button that opens a paused video is a defect, not an\n * accommodation. WCAG's auto-motion requirement is about motion that\n * starts without the user, and it is satisfied anyway — the player's\n * controls are on screen, so the video can be paused at any moment.\n */\n const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')\n /**\n * Pauses before closing, so the sound stops on the press rather than when\n * the exit transition ends and the `<video>` leaves the tree.\n */\n const closeDialog = () => {\n videoRef.current?.pause()\n onClose()\n }\n /**\n * `Escape` in full screen leaves full screen and keeps the dialog open. A\n * browser normally exits full screen on that key before the page hears it;\n * this covers one that passes the key on as well, so one press never does\n * both.\n */\n const handleClose: DialogProps['onClose'] = (_event, reason) => {\n const doc = playerRef.current?.ownerDocument\n if (reason === 'escapeKeyDown' && doc?.fullscreenElement) {\n void Promise.resolve(doc.exitFullscreen()).catch(() => undefined)\n return\n }\n closeDialog()\n }\n return (\n <Dialog\n open={open}\n onClose={handleClose}\n onKeyDown={(event) =>\n handlePlayerShortcut(event, {\n video: videoRef.current,\n player: playerRef.current,\n })\n }\n maxWidth=\"lg\"\n fullWidth\n transitionDuration={reduceMotion ? 0 : undefined}\n slotProps={{\n paper: {\n /**\n * ⚠️ The label goes on the PAPER, not on `<Dialog>`.\n *\n * `role=\"dialog\"` lives on the paper; props spread onto `Dialog`\n * reach the Modal root, which is `role=\"presentation\"`. Written\n * there first and measured in a browser: the presentation root\n * carried the name and the dialog itself had none, which is an\n * unlabelled dialog announced to a screen reader — the exact\n * failure the label was added to prevent. No `DialogTitle`,\n * deliberately: the film is the content, and a heading above it\n * would only push it down.\n */\n 'aria-label': title || FALLBACK_LABEL,\n // The paper is a frame around a video, not a sheet of content:\n // no padding, and a black ground so a letterboxed film has\n // something to sit on rather than a white margin.\n sx: { overflow: 'hidden', bgcolor: 'common.black' },\n },\n }}\n >\n <Box\n ref={playerRef}\n sx={{\n position: 'relative',\n display: 'flex',\n flexDirection: 'column',\n // The paper stops growing at the viewport, less its margins. The\n // film is what gives way, letterboxed, so the controls under it\n // are never the part cut off on a short or landscape screen.\n flex: '1 1 auto',\n minHeight: 0,\n bgcolor: 'common.black',\n }}\n >\n <IconButton\n onClick={closeDialog}\n aria-label=\"Close video\"\n size=\"small\"\n sx={[\n controlButtonSx,\n (theme) => ({\n position: 'absolute',\n top: theme.spacing(1),\n right: theme.spacing(1),\n zIndex: 1,\n // Over frames of unknown brightness, so the control carries its\n // own ground rather than trusting the film's.\n bgcolor: 'common.black',\n }),\n ]}\n >\n <MdiIcon path={mdiClose.path} fontSize=\"small\" />\n </IconButton>\n {embedSrc ? (\n // A hosted player draws its own controls inside its frame, so the\n // dialog adds none, and its shortcuts find no film to act on.\n // Closing unmounts the frame with the rest of the dialog, which\n // stops the player exactly as it stops the `<video>`, and the frame\n // gives way on a short screen for the reason the film does.\n <VideoPlayerFrame\n src={embedSrc}\n title={title}\n aspectRatio={aspectRatio}\n style={{ flexGrow: 1, flexShrink: 1, minHeight: 0 }}\n />\n ) : (\n <>\n <Box\n component=\"video\"\n ref={videoRef}\n src={src}\n poster={poster || undefined}\n title={title || undefined}\n autoPlay\n playsInline\n loop={Boolean(loop)}\n muted={Boolean(muted)}\n // The one place in this element where fetching eagerly is right:\n // the visitor has asked for the film and is looking at the player.\n preload=\"auto\"\n // A click on the picture plays and pauses, as it does on the\n // browser's own player.\n onClick={() => {\n if (videoRef.current) togglePlayback(videoRef.current)\n }}\n onPlay={playbackHandlers.onPlay}\n onTimeUpdate={playbackHandlers.onTimeUpdate}\n onEnded={playbackHandlers.onEnded}\n sx={{\n display: 'block',\n width: '100%',\n height: 'auto',\n flex: '1 1 auto',\n minHeight: 0,\n objectFit: 'contain',\n aspectRatio,\n }}\n >\n {captions}\n </Box>\n <VideoLightboxControls\n videoRef={videoRef}\n playerRef={playerRef}\n captions={Boolean(captions)}\n />\n </>\n )}\n </Box>\n </Dialog>\n )\n}\nVideoLightbox.displayName = 'VideoLightbox'\n\nexport default VideoLightbox\n"],"names":["mdiClose","MdiIcon","Box","Dialog","IconButton","useMediaQuery","useRef","controlButtonSx","handlePlayerShortcut","togglePlayback","VideoLightboxControls","useVideoPlaybackBeacon","VideoPlayerFrame","FALLBACK_LABEL","VideoLightbox","props","open","onClose","title","src","poster","aspectRatio","loop","muted","captions","playback","embedSrc","playbackHandlers","viewingKey","videoRef","playerRef","reduceMotion","closeDialog","current","pause","handleClose","_event","reason","doc","ownerDocument","fullscreenElement","Promise","resolve","exitFullscreen","catch","undefined","onKeyDown","event","video","player","maxWidth","fullWidth","transitionDuration","slotProps","paper","sx","overflow","bgcolor","ref","position","display","flexDirection","flex","minHeight","onClick","aria-label","size","theme","top","spacing","right","zIndex","path","fontSize","style","flexGrow","flexShrink","component","autoPlay","playsInline","Boolean","preload","onPlay","onTimeUpdate","onEnded","width","height","objectFit","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/mui/src/lib/components/video-lightbox.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The Video element's lightbox (AGL-2744).\n *\n * ## Why this is its own module\n *\n * The dialog itself is the shared lightbox shell (AGL-3717,\n * `@aglyn/shared-ui-jsx/components/lightbox/lightbox-dialog`), the same one\n * the Image, the Image List gallery and the Lightbox container open. What is\n * the Video's own is what goes inside it: the film, its controls, the hosted\n * player frame and the play beacon.\n *\n * The shell names `@mui/material/Dialog`, which must never be reached from a\n * first paint. `libs/shared/ui/jsx`'s barrel deliberately withholds\n * `dialog-confirm`, `loading-modal` and `navigation-drawer` because the MUI\n * Dialog/Drawer/Popper stack was measured sitting in the first-load chunks of\n * `/pricing` — a page that opens no dialog (AGL-1290). `plugin.ts` imports\n * every element eagerly, so this module is a chunk `video.tsx` reaches through\n * `lazy(() => import())` and mounts only once a visitor has actually asked to\n * watch something. A page with no video pays nothing; a page with a video\n * pays nothing; a visitor who clicks pays once. The player's controls,\n * `video-lightbox-controls.tsx`, are imported from here and from nowhere\n * else, so they ride in the same chunk.\n *\n * It is still MUI's `Dialog` and still the site's own theme. The split is\n * about WHEN the dialog arrives, not about hand-rolling a modal — and the\n * accessibility is most of why. `Dialog` traps focus for the life of the\n * overlay, restores it to the element that opened it on close, closes on\n * `Escape`, and marks the rest of the document inert for assistive\n * technology. Every one of those is a thing a bespoke overlay gets wrong.\n *\n * ## Why the film has no `controls` attribute here\n *\n * The browser's own controls swallow `Escape` (AGL-2802): from inside them no\n * key event reaches the page, so the dialog could not be dismissed from the\n * stops a keyboard visitor is on while watching. The player draws its own\n * controls instead; `video-lightbox-controls.tsx` explains the measurement and\n * carries the key map. The inline player keeps the browser's controls, because\n * nothing on a page is listening for `Escape` there.\n */\n'use client'\n\nimport type { LightboxAppearance } from '@aglyn/shared-ui-jsx/components/lightbox/lightbox-appearance'\nimport {\n LightboxCloseButton,\n LightboxDialog,\n} from '@aglyn/shared-ui-jsx/components/lightbox/lightbox-dialog'\nimport Box from '@mui/material/Box'\nimport { type ReactNode, useRef } from 'react'\nimport {\n controlButtonSx,\n handlePlayerShortcut,\n togglePlayback,\n VideoLightboxControls,\n} from './video-lightbox-controls'\nimport {\n useVideoPlaybackBeacon,\n type VideoPlaybackBeaconOptions,\n} from './video-playback-beacon'\nimport { VideoPlayerFrame } from './video-player-frame'\n\nexport interface VideoLightboxProps {\n open: boolean\n onClose: () => void\n /** Names the dialog for assistive technology; also the video's own title. */\n title?: string\n /** Resolved video URL — already through `resolveMediaSrc`. */\n src: string\n /** Resolved poster URL, shown for the instant before the first frame. */\n poster?: string\n /** `width / height` from the asset, when the DAM measured it. */\n aspectRatio?: string\n loop?: boolean\n muted?: boolean\n /** The `<track>` the inline element would have rendered, if any. */\n captions?: ReactNode\n /**\n * What a play in this dialog is counted against (AGL-2781). Each open is\n * its own viewing, so the element passes the facts and the dialog keys the\n * viewing to `open`.\n */\n playback?: Omit<VideoPlaybackBeaconOptions, 'viewingKey'>\n /**\n * A hosted player's frame address (AGL-2826). When set, the dialog frames\n * that player and never plays `src` itself.\n */\n embedSrc?: string\n /**\n * The author's lightbox settings (AGL-3717). Absent, the dialog is exactly\n * the one AGL-2744 shipped: wide, black, the close control in the corner.\n */\n appearance?: LightboxAppearance\n}\n\n/** What a dialog is called when the author gave the video no title. */\nconst FALLBACK_LABEL = 'Video'\n\n/**\n * The dialog. Rendered only while `open`, and unmounted when it closes.\n *\n * Unmounting is not tidiness — it is the only thing that reliably STOPS the\n * download. A `<video>` left in the tree paused still holds whatever it has\n * buffered and may keep filling that buffer; taking the element out of the\n * document is what tells the browser the request is over. That matters more\n * here than it would elsewhere, because the whole element exists to keep a\n * multi-megabyte file off the wire until it is wanted.\n */\nexport function VideoLightbox(props: VideoLightboxProps) {\n const {\n open,\n onClose,\n title,\n src,\n poster,\n aspectRatio,\n loop,\n muted,\n captions,\n playback,\n embedSrc,\n appearance,\n } = props\n const playbackHandlers = useVideoPlaybackBeacon({\n ...playback,\n viewingKey: open,\n })\n const videoRef = useRef<HTMLVideoElement>(null)\n /** The frame that goes full screen, controls and all. */\n const playerRef = useRef<HTMLDivElement>(null)\n /*\n * `prefers-reduced-motion` reaches the DIALOG, not the video: the shared\n * shell drops its transition to nothing (AGL-3717 moved that rule there).\n *\n * The FILM is deliberately left alone. Autoplay here is not autoplay: the\n * visitor pressed a play button, and a play button that opens a paused\n * video is a defect, not an accommodation. WCAG's auto-motion requirement\n * is about motion that starts without the user, and it is satisfied anyway\n * — the player's controls are on screen, so the video can be paused at any\n * moment.\n */\n /**\n * Pauses before closing, so the sound stops on the press rather than when\n * the exit transition ends and the `<video>` leaves the tree.\n */\n const closeDialog = () => {\n videoRef.current?.pause()\n onClose()\n }\n /**\n * `Escape` in full screen leaves full screen and keeps the dialog open. A\n * browser normally exits full screen on that key before the page hears it;\n * this covers one that passes the key on as well, so one press never does\n * both.\n */\n const interceptClose = (reason: string) => {\n const doc = playerRef.current?.ownerDocument\n if (reason === 'escapeKeyDown' && doc?.fullscreenElement) {\n void Promise.resolve(doc.exitFullscreen()).catch(() => undefined)\n return true\n }\n return false\n }\n return (\n <LightboxDialog\n open={open}\n onClose={closeDialog}\n interceptClose={interceptClose}\n onKeyDown={(event) =>\n handlePlayerShortcut(event, {\n video: videoRef.current,\n player: playerRef.current,\n })\n }\n maxWidth=\"lg\"\n fullWidth\n // No `DialogTitle`, deliberately: the film is the content, and a\n // heading above it would only push it down. The name rides the paper.\n label={title || FALLBACK_LABEL}\n // The close control lives inside the frame that goes full screen.\n closeButton={false}\n appearance={appearance}\n // The paper is a frame around a video, not a sheet of content: no\n // padding, and a black ground so a letterboxed film has something to\n // sit on rather than a white margin.\n paperSx={{ overflow: 'hidden', bgcolor: 'common.black' }}\n >\n <Box\n ref={playerRef}\n sx={{\n position: 'relative',\n display: 'flex',\n flexDirection: 'column',\n // The paper stops growing at the viewport, less its margins. The\n // film is what gives way, letterboxed, so the controls under it\n // are never the part cut off on a short or landscape screen.\n flex: '1 1 auto',\n minHeight: 0,\n bgcolor: 'common.black',\n }}\n >\n <LightboxCloseButton\n onClick={closeDialog}\n label=\"Close video\"\n appearance={appearance}\n sx={[\n controlButtonSx,\n // Over frames of unknown brightness, so the control carries its\n // own ground rather than trusting the film's.\n ...(appearance?.closeStyle ? [] : [{ bgcolor: 'common.black' }]),\n ]}\n />\n {embedSrc ? (\n // A hosted player draws its own controls inside its frame, so the\n // dialog adds none, and its shortcuts find no film to act on.\n // Closing unmounts the frame with the rest of the dialog, which\n // stops the player exactly as it stops the `<video>`, and the frame\n // gives way on a short screen for the reason the film does.\n <VideoPlayerFrame\n src={embedSrc}\n title={title}\n aspectRatio={aspectRatio}\n style={{ flexGrow: 1, flexShrink: 1, minHeight: 0 }}\n />\n ) : (\n <>\n <Box\n component=\"video\"\n ref={videoRef}\n src={src}\n poster={poster || undefined}\n title={title || undefined}\n autoPlay\n playsInline\n loop={Boolean(loop)}\n muted={Boolean(muted)}\n // The one place in this element where fetching eagerly is right:\n // the visitor has asked for the film and is looking at the player.\n preload=\"auto\"\n // A click on the picture plays and pauses, as it does on the\n // browser's own player.\n onClick={() => {\n if (videoRef.current) togglePlayback(videoRef.current)\n }}\n onPlay={playbackHandlers.onPlay}\n onTimeUpdate={playbackHandlers.onTimeUpdate}\n onEnded={playbackHandlers.onEnded}\n sx={{\n display: 'block',\n width: '100%',\n height: 'auto',\n flex: '1 1 auto',\n minHeight: 0,\n objectFit: 'contain',\n aspectRatio,\n }}\n >\n {captions}\n </Box>\n <VideoLightboxControls\n videoRef={videoRef}\n playerRef={playerRef}\n captions={Boolean(captions)}\n />\n </>\n )}\n </Box>\n </LightboxDialog>\n )\n}\nVideoLightbox.displayName = 'VideoLightbox'\n\nexport default VideoLightbox\n"],"names":["LightboxCloseButton","LightboxDialog","Box","useRef","controlButtonSx","handlePlayerShortcut","togglePlayback","VideoLightboxControls","useVideoPlaybackBeacon","VideoPlayerFrame","FALLBACK_LABEL","VideoLightbox","props","open","onClose","title","src","poster","aspectRatio","loop","muted","captions","playback","embedSrc","appearance","playbackHandlers","viewingKey","videoRef","playerRef","closeDialog","current","pause","interceptClose","reason","doc","ownerDocument","fullscreenElement","Promise","resolve","exitFullscreen","catch","undefined","onKeyDown","event","video","player","maxWidth","fullWidth","label","closeButton","paperSx","overflow","bgcolor","ref","sx","position","display","flexDirection","flex","minHeight","onClick","closeStyle","style","flexGrow","flexShrink","component","autoPlay","playsInline","Boolean","preload","onPlay","onTimeUpdate","onEnded","width","height","objectFit","displayName"],"mappings":"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"}
@@ -15,6 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import * as Aglyn from '@aglyn/aglyn';
18
+ import { type LightboxAppearanceProps } from '@aglyn/shared-ui-jsx/components/lightbox/lightbox-appearance';
18
19
  import type { SxProps } from '@mui/material/styles';
19
20
  import { type ReactNode } from 'react';
20
21
  export declare const ID: Aglyn.ComponentId;
@@ -44,7 +45,7 @@ export declare function resolveVideoPreload(options: {
44
45
  poster?: unknown;
45
46
  autoPlay?: unknown;
46
47
  }): VideoPreload;
47
- export interface VideoProps {
48
+ export interface VideoProps extends LightboxAppearanceProps {
48
49
  /**
49
50
  * Where the video comes from. Either a **media reference** —
50
51
  * `media:{scope}/{mediaId}`, what "Browse media" stores (AGL-1215) — or any