@aglyn/plugins-mui 1.0.0-beta.221 → 1.0.0-beta.222

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/collection-search-box.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 { mdiMagnify } from '@aglyn/shared-data-mdi'\nimport { AppLink, MdiIcon } from '@aglyn/shared-ui-jsx'\n// The icon picker's fuzzy matcher (use-mdi-icons-fuzzy), not a re-implementation\n// (AGL-1516): search here has to feel like search does everywhere else in the\n// product, and two matchers is how they drift. A TYPE here: the matcher itself\n// is fetched on a reader's first keystroke — see `loadCollectionFuse`.\nimport type { Fuse } from '@aglyn/shared-util-vendor/fuse'\nimport Box from '@mui/material/Box'\nimport Chip from '@mui/material/Chip'\nimport InputBase from '@mui/material/InputBase'\nimport MuiStack from '@mui/material/Stack'\nimport Typography from '@mui/material/Typography'\nimport { useMemo, useState } from 'react'\n\n/**\n * The search box the Collection Entries block and the standalone Collection\n * Search block both draw (AGL-1516/AGL-1525), and the fuzzy matcher behind\n * it. Its own module so the other collection elements carry neither the MUI\n * input nor the panel (AGL-3401); the matcher itself is fetched only when a\n * reader types.\n */\n\n/** The toolbar search field, from the frame (Figma 494:1220). */\nconst SEARCH_FIELD_WIDTH = 240\n\n/** Suggestion panel (Figma 496:1218). */\nconst SUGGESTION_PANEL_WIDTH = 420\n\n/**\n * Rows in the panel before it stops. The frame shows five; the sixth row is\n * always the \"View all results\" link, which is the honest overflow answer —\n * a scrolling suggestion panel is a worse results page.\n */\nconst SUGGESTION_LIMIT = 5\n\n/** Where \"View all results\" goes — the reserved site-search route (AGL-88). */\nconst searchResultsHref = (query: string) =>\n `/search?q=${encodeURIComponent(query)}`\n\n/**\n * The frame's compact filled field: magnify glyph + hint on the quiet\n * surface token, right-aligned so it sits where the toolbar row puts it.\n * Palette tokens only, so it follows the site theme in both modes.\n */\nconst searchFieldSx = {\n alignSelf: 'flex-end',\n display: 'flex',\n alignItems: 'center',\n gap: 1,\n px: 1.5,\n py: 0.75,\n borderRadius: 2,\n bgcolor: 'action.hover',\n color: 'text.secondary',\n width: SEARCH_FIELD_WIDTH,\n maxWidth: '100%',\n}\n\n/**\n * The positioned parent of the suggestion panel (AGL-1525). It takes over\n * `alignSelf` from the field so the toolbar row is unchanged — the panel\n * hangs off the field, it does not move it.\n */\nconst suggestionAnchorSx = {\n alignSelf: 'flex-end',\n position: 'relative',\n width: SEARCH_FIELD_WIDTH,\n maxWidth: '100%',\n}\n\n/**\n * The floating panel itself. Right-aligned to the field it drops from, and\n * wider than it, as the frame draws it — a suggestion row carries a title, a\n * chip, a date and a line of excerpt, none of which fit in 240px.\n *\n * Palette and shadow tokens only, so it follows the site theme in both\n * modes rather than pinning a light-mode card onto a dark page.\n */\nconst suggestionPanelSx = {\n position: 'absolute',\n top: 'calc(100% + 8px)',\n right: 0,\n zIndex: 10,\n width: SUGGESTION_PANEL_WIDTH,\n // Never wider than the viewport on a phone, where the anchor is narrow\n // and `right: 0` would otherwise hang the panel off the left edge.\n maxWidth: 'calc(100vw - 32px)',\n bgcolor: 'background.paper',\n color: 'text.primary',\n border: 1,\n borderColor: 'divider',\n borderRadius: 2,\n boxShadow: 6,\n overflow: 'hidden',\n textAlign: 'left',\n}\n\nconst suggestionRowLinkSx = {\n display: 'block',\n color: 'inherit',\n '&:hover': { bgcolor: 'action.hover' },\n}\n\nconst suggestionFooterSx = {\n display: 'block',\n px: 2,\n py: 1.25,\n borderTop: 1,\n borderColor: 'divider',\n bgcolor: 'action.hover',\n fontSize: 14,\n fontWeight: 500,\n}\n\n/** What a built index can be searched by — Fuse over the stamped rows. */\ntype EntryFuse = InstanceType<typeof Fuse<Aglyn.CollectionEntrySearchItem>>\n\n/** The matcher's constructor, once something has asked for it. */\nlet loadedFuse: typeof Fuse | undefined\nlet fuseLoad: Promise<typeof Fuse> | undefined\n\n/**\n * The fuzzy matcher, fetched the first time a reader types into a collection\n * search box (AGL-3401) — one fetch per visit, however many boxes ask.\n *\n * It was a static import, and `fuse.js` is 26 KB of the 68 KB this module\n * weighed: every page that placed ANY collection element downloaded and\n * parsed it, including the many whose entries block has no search at all and\n * the rest, where most visitors never type. The box itself still renders on\n * the server; only the matcher waits for a query.\n *\n * A failed fetch (offline, a deploy mid-visit) is forgotten rather than\n * cached, so the next keystroke tries again instead of the box going dead.\n */\nexport const loadCollectionFuse = (): Promise<typeof Fuse> =>\n (fuseLoad ??= import('./collection-search-fuse').then(\n (module) => (loadedFuse = module.Fuse),\n (error: unknown) => {\n fuseLoad = undefined\n throw error\n },\n ))\n\n/**\n * The Fuse index over `items`, and the call that asks for the matcher.\n *\n * `fuzzy` is null until the first `requestFuzzy()` has resolved — a box\n * nobody typed into never builds an index. A box mounted after another one\n * loaded the matcher starts with it, so a second search on the page answers\n * its first keystroke synchronously.\n */\nexport const useEntryFuse = (\n items: readonly Aglyn.CollectionEntrySearchItem[] | undefined,\n): { fuzzy: EntryFuse | null; requestFuzzy: () => void } => {\n const [FuseConstructor, setFuseConstructor] = useState<\n typeof Fuse | undefined\n >(() => loadedFuse)\n const fuzzy = useMemo(\n () =>\n FuseConstructor && items?.length\n ? // The ONE matcher config (AGL-1525), shared with the site-wide\n // results page the suggestion panel links to — a query the panel\n // forgave must not come back empty from \"View all results\".\n new FuseConstructor(items, { ...Aglyn.COLLECTION_SEARCH_FUSE_OPTIONS })\n : null,\n [FuseConstructor, items],\n )\n const requestFuzzy = () => {\n if (FuseConstructor) return\n loadCollectionFuse().then(\n (loaded) => setFuseConstructor(() => loaded),\n // The box keeps working as a form that submits to `/search`; the next\n // keystroke asks again.\n () => undefined,\n )\n }\n return { fuzzy, requestFuzzy }\n}\n\n/**\n * The frame's search input (Figma 494:1220) — the magnify glyph and a bare\n * `InputBase` on the quiet surface token.\n *\n * Presentational and stateless, so the entries block's in-place filter and\n * the standalone toolbar box (AGL-1516) are the SAME field rather than two\n * that merely look alike. `inert` is the editing-surface rendering: read-only\n * and handler-free, so a click or stray keystroke in the besigner never edits\n * state the canvas cannot use — how Category Pills go inert there too.\n */\nexport const SearchField = ({\n value,\n placeholder,\n inert,\n landmark,\n onQuery,\n onEscape,\n}: {\n value: string\n placeholder?: string\n inert?: boolean\n /**\n * Put `role=\"search\"` on the FIELD. False when a wrapping form carries it\n * instead — one search landmark per box, or a screen reader announces the\n * same field twice.\n */\n landmark?: boolean\n onQuery?: (next: string) => void\n onEscape?: () => void\n}) => (\n <Box {...(landmark ? { role: 'search' } : {})} sx={searchFieldSx}>\n <MdiIcon path={mdiMagnify.path} />\n <InputBase\n value={value}\n name=\"q\"\n placeholder={(placeholder ?? '').trim() || 'Search posts…'}\n inputProps={{ 'aria-label': 'Search entries' }}\n sx={{ flex: 1, fontSize: 14, color: 'text.primary' }}\n {...(inert\n ? { readOnly: true }\n : {\n onChange: (event) => onQuery?.(event.target.value),\n onKeyDown: (event) => {\n if (event.key === 'Escape') onEscape?.()\n },\n })}\n />\n </Box>\n)\n\n/**\n * The floating suggestion panel (AGL-1525, Figma 496:1218) — rows straight\n * off the server-stamped index, and a \"View all results\" link that is there\n * hits or no hits.\n *\n * `emptyText` is the caller's, not this component's, and that is the whole\n * point of it being a parameter: a page-windowed entries block and a\n * whole-collection toolbar box searched different sets, and a miss has to say\n * WHICH set came back empty. A shared \"No matches.\" would let one of them\n * claim a reach it never had.\n */\nconst SuggestionPanel = ({\n suggestions,\n trimmed,\n emptyText,\n}: {\n suggestions: readonly Aglyn.CollectionEntrySearchItem[]\n trimmed: string\n emptyText: string\n}) => (\n <Box sx={suggestionPanelSx}>\n {suggestions.length ? (\n suggestions.map((item, index) => {\n const row = (\n <MuiStack spacing={0.5} sx={{ px: 2, py: 1.25 }}>\n <MuiStack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Typography variant=\"subtitle2\" sx={{ color: 'text.primary' }}>\n {item.title}\n </Typography>\n {item.category ? (\n <Chip label={item.category} size=\"small\" />\n ) : null}\n {item.date ? (\n <Typography variant=\"caption\" sx={{ color: 'text.secondary' }}>\n {item.date}\n </Typography>\n ) : null}\n </MuiStack>\n {item.excerpt ? (\n <Typography\n variant=\"body2\"\n sx={{\n color: 'text.secondary',\n // One line of excerpt, as the frame draws it. A row that\n // grows with the prose turns the panel into a page.\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n whiteSpace: 'nowrap',\n }}\n >\n {item.excerpt}\n </Typography>\n ) : null}\n </MuiStack>\n )\n // A row without a `url` is a page cached before AGL-1525 stamped one,\n // or a slugless entry. It renders as TEXT rather than as a link to\n // nowhere: a suggestion that navigates to the wrong page is worse\n // than one the reader has to read.\n return item.url ? (\n <AppLink\n key={index}\n href={item.url}\n sx={suggestionRowLinkSx}\n underline=\"none\"\n >\n {row}\n </AppLink>\n ) : (\n <Box key={index}>{row}</Box>\n )\n })\n ) : (\n <Typography\n variant=\"body2\"\n sx={{ color: 'text.secondary', px: 2, py: 1.25 }}\n >\n {emptyText}\n </Typography>\n )}\n {/*\n Always present, hits or no hits (Figma 496:1218). A box that searches\n one page, or one bounded read of a collection, cannot answer \"then\n where is it?\" on its own — the site-wide results page is the only\n honest answer, and it matters most in exactly the case where the panel\n came back empty.\n */}\n <AppLink\n href={searchResultsHref(trimmed)}\n sx={suggestionFooterSx}\n underline=\"none\"\n >\n {`View all results for “${trimmed}” →`}\n </AppLink>\n </Box>\n)\n\n/**\n * Field plus dropdown, in a REAL form (AGL-1525) — so Enter reaches the\n * results page and the box still works with the panel's JS doing nothing at\n * all. The panel is an enhancement over a working search box, not the search\n * box.\n *\n * Owns the query, because nothing outside it needs one: `suggest` never\n * touches the cards underneath. That is what lets the standalone toolbar\n * block exist at all (AGL-1516) — a box with no clones to hide is a complete\n * feature, not a crippled one.\n */\nexport const SuggestSearchBox = ({\n fuzzy,\n requestFuzzy,\n items,\n placeholder,\n inert,\n emptyText,\n}: {\n /** Null until the matcher has loaded; see {@link useEntryFuse}. */\n fuzzy: EntryFuse | null\n /** Asks for the matcher; called on every query, a no-op once it is here. */\n requestFuzzy: () => void\n items?: readonly Aglyn.CollectionEntrySearchItem[]\n placeholder?: string\n inert?: boolean\n /** The honest miss for the set THIS box searched. */\n emptyText: (query: string) => string\n}) => {\n const [query, setQuery] = useState('')\n /** Escape dismissed THIS answer (AGL-1525); typing brings it back. */\n const [closed, setClosed] = useState(false)\n const trimmed = query.trim()\n const suggestions =\n !inert && trimmed && fuzzy && items\n ? fuzzy\n .search(trimmed)\n .slice(0, SUGGESTION_LIMIT)\n .map((result) => items[result.refIndex])\n .filter(Boolean)\n : []\n const field = (\n <SearchField\n value={query}\n {...(placeholder === undefined ? {} : { placeholder })}\n // No form wraps the inert field, so the landmark belongs on it.\n {...(inert ? { inert: true, landmark: true } : {})}\n onQuery={(next) => {\n requestFuzzy()\n setQuery(next)\n // A new query reopens a panel the reader dismissed — Escape closes\n // THIS answer, it does not turn the feature off for the visit.\n setClosed(false)\n }}\n onEscape={() => setClosed(true)}\n />\n )\n if (inert) return field\n return (\n <Box\n component=\"form\"\n role=\"search\"\n action=\"/search\"\n method=\"get\"\n sx={suggestionAnchorSx}\n >\n {field}\n {/*\n Not until the matcher is here: a panel drawn in the moment it takes\n to arrive would say \"No matches\" about a query nothing has searched.\n */}\n {trimmed && !closed && fuzzy ? (\n <SuggestionPanel\n suggestions={suggestions}\n trimmed={trimmed}\n emptyText={emptyText(trimmed)}\n />\n ) : null}\n </Box>\n )\n}\n"],"names":["Aglyn","mdiMagnify","AppLink","MdiIcon","Box","Chip","InputBase","MuiStack","Typography","useMemo","useState","SEARCH_FIELD_WIDTH","SUGGESTION_PANEL_WIDTH","SUGGESTION_LIMIT","searchResultsHref","query","encodeURIComponent","searchFieldSx","alignSelf","display","alignItems","gap","px","py","borderRadius","bgcolor","color","width","maxWidth","suggestionAnchorSx","position","suggestionPanelSx","top","right","zIndex","border","borderColor","boxShadow","overflow","textAlign","suggestionRowLinkSx","suggestionFooterSx","borderTop","fontSize","fontWeight","loadedFuse","fuseLoad","loadCollectionFuse","then","module","Fuse","error","undefined","useEntryFuse","items","FuseConstructor","setFuseConstructor","fuzzy","length","COLLECTION_SEARCH_FUSE_OPTIONS","requestFuzzy","loaded","SearchField","value","placeholder","inert","landmark","onQuery","onEscape","role","sx","path","name","trim","inputProps","flex","readOnly","onChange","event","target","onKeyDown","key","SuggestionPanel","suggestions","trimmed","emptyText","map","item","index","row","spacing","direction","variant","title","category","label","size","date","excerpt","textOverflow","whiteSpace","url","href","underline","SuggestSearchBox","setQuery","closed","setClosed","search","slice","result","refIndex","filter","Boolean","field","next","component","action","method"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/mui/src/lib/components/collection-search-box.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 { mdiMagnify } from '@aglyn/shared-data-mdi'\nimport { AppLink, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { useSoftGetSubmit } from '@aglyn/shared-ui-jsx/hooks/use-soft-get-submit'\n// The icon picker's fuzzy matcher (use-mdi-icons-fuzzy), not a re-implementation\n// (AGL-1516): search here has to feel like search does everywhere else in the\n// product, and two matchers is how they drift. A TYPE here: the matcher itself\n// is fetched on a reader's first keystroke — see `loadCollectionFuse`.\nimport type { Fuse } from '@aglyn/shared-util-vendor/fuse'\nimport Box from '@mui/material/Box'\nimport Chip from '@mui/material/Chip'\nimport InputBase from '@mui/material/InputBase'\nimport MuiStack from '@mui/material/Stack'\nimport Typography from '@mui/material/Typography'\nimport { type FormEvent, useMemo, useState } from 'react'\n\n/**\n * The search box the Collection Entries block and the standalone Collection\n * Search block both draw (AGL-1516/AGL-1525), and the fuzzy matcher behind\n * it. Its own module so the other collection elements carry neither the MUI\n * input nor the panel (AGL-3401); the matcher itself is fetched only when a\n * reader types.\n */\n\n/** The toolbar search field, from the frame (Figma 494:1220). */\nconst SEARCH_FIELD_WIDTH = 240\n\n/** Suggestion panel (Figma 496:1218). */\nconst SUGGESTION_PANEL_WIDTH = 420\n\n/**\n * Rows in the panel before it stops. The frame shows five; the sixth row is\n * always the \"View all results\" link, which is the honest overflow answer —\n * a scrolling suggestion panel is a worse results page.\n */\nconst SUGGESTION_LIMIT = 5\n\n/** Where \"View all results\" goes — the reserved site-search route (AGL-88). */\nconst searchResultsHref = (query: string) =>\n `/search?q=${encodeURIComponent(query)}`\n\n/**\n * The frame's compact filled field: magnify glyph + hint on the quiet\n * surface token, right-aligned so it sits where the toolbar row puts it.\n * Palette tokens only, so it follows the site theme in both modes.\n */\nconst searchFieldSx = {\n alignSelf: 'flex-end',\n display: 'flex',\n alignItems: 'center',\n gap: 1,\n px: 1.5,\n py: 0.75,\n borderRadius: 2,\n bgcolor: 'action.hover',\n color: 'text.secondary',\n width: SEARCH_FIELD_WIDTH,\n maxWidth: '100%',\n}\n\n/**\n * The positioned parent of the suggestion panel (AGL-1525). It takes over\n * `alignSelf` from the field so the toolbar row is unchanged — the panel\n * hangs off the field, it does not move it.\n */\nconst suggestionAnchorSx = {\n alignSelf: 'flex-end',\n position: 'relative',\n width: SEARCH_FIELD_WIDTH,\n maxWidth: '100%',\n}\n\n/**\n * The floating panel itself. Right-aligned to the field it drops from, and\n * wider than it, as the frame draws it — a suggestion row carries a title, a\n * chip, a date and a line of excerpt, none of which fit in 240px.\n *\n * Palette and shadow tokens only, so it follows the site theme in both\n * modes rather than pinning a light-mode card onto a dark page.\n */\nconst suggestionPanelSx = {\n position: 'absolute',\n top: 'calc(100% + 8px)',\n right: 0,\n zIndex: 10,\n width: SUGGESTION_PANEL_WIDTH,\n // Never wider than the viewport on a phone, where the anchor is narrow\n // and `right: 0` would otherwise hang the panel off the left edge.\n maxWidth: 'calc(100vw - 32px)',\n bgcolor: 'background.paper',\n color: 'text.primary',\n border: 1,\n borderColor: 'divider',\n borderRadius: 2,\n boxShadow: 6,\n overflow: 'hidden',\n textAlign: 'left',\n}\n\nconst suggestionRowLinkSx = {\n display: 'block',\n color: 'inherit',\n '&:hover': { bgcolor: 'action.hover' },\n}\n\nconst suggestionFooterSx = {\n display: 'block',\n px: 2,\n py: 1.25,\n borderTop: 1,\n borderColor: 'divider',\n bgcolor: 'action.hover',\n fontSize: 14,\n fontWeight: 500,\n}\n\n/** What a built index can be searched by — Fuse over the stamped rows. */\ntype EntryFuse = InstanceType<typeof Fuse<Aglyn.CollectionEntrySearchItem>>\n\n/** The matcher's constructor, once something has asked for it. */\nlet loadedFuse: typeof Fuse | undefined\nlet fuseLoad: Promise<typeof Fuse> | undefined\n\n/**\n * The fuzzy matcher, fetched the first time a reader types into a collection\n * search box (AGL-3401) — one fetch per visit, however many boxes ask.\n *\n * It was a static import, and `fuse.js` is 26 KB of the 68 KB this module\n * weighed: every page that placed ANY collection element downloaded and\n * parsed it, including the many whose entries block has no search at all and\n * the rest, where most visitors never type. The box itself still renders on\n * the server; only the matcher waits for a query.\n *\n * A failed fetch (offline, a deploy mid-visit) is forgotten rather than\n * cached, so the next keystroke tries again instead of the box going dead.\n */\nexport const loadCollectionFuse = (): Promise<typeof Fuse> =>\n (fuseLoad ??= import('./collection-search-fuse').then(\n (module) => (loadedFuse = module.Fuse),\n (error: unknown) => {\n fuseLoad = undefined\n throw error\n },\n ))\n\n/**\n * The Fuse index over `items`, and the call that asks for the matcher.\n *\n * `fuzzy` is null until the first `requestFuzzy()` has resolved — a box\n * nobody typed into never builds an index. A box mounted after another one\n * loaded the matcher starts with it, so a second search on the page answers\n * its first keystroke synchronously.\n */\nexport const useEntryFuse = (\n items: readonly Aglyn.CollectionEntrySearchItem[] | undefined,\n): { fuzzy: EntryFuse | null; requestFuzzy: () => void } => {\n const [FuseConstructor, setFuseConstructor] = useState<\n typeof Fuse | undefined\n >(() => loadedFuse)\n const fuzzy = useMemo(\n () =>\n FuseConstructor && items?.length\n ? // The ONE matcher config (AGL-1525), shared with the site-wide\n // results page the suggestion panel links to — a query the panel\n // forgave must not come back empty from \"View all results\".\n new FuseConstructor(items, { ...Aglyn.COLLECTION_SEARCH_FUSE_OPTIONS })\n : null,\n [FuseConstructor, items],\n )\n const requestFuzzy = () => {\n if (FuseConstructor) return\n loadCollectionFuse().then(\n (loaded) => setFuseConstructor(() => loaded),\n // The box keeps working as a form that submits to `/search`; the next\n // keystroke asks again.\n () => undefined,\n )\n }\n return { fuzzy, requestFuzzy }\n}\n\n/**\n * The frame's search input (Figma 494:1220) — the magnify glyph and a bare\n * `InputBase` on the quiet surface token.\n *\n * Presentational and stateless, so the entries block's in-place filter and\n * the standalone toolbar box (AGL-1516) are the SAME field rather than two\n * that merely look alike. `inert` is the editing-surface rendering: read-only\n * and handler-free, so a click or stray keystroke in the besigner never edits\n * state the canvas cannot use — how Category Pills go inert there too.\n */\nexport const SearchField = ({\n value,\n placeholder,\n inert,\n landmark,\n onQuery,\n onEscape,\n}: {\n value: string\n placeholder?: string\n inert?: boolean\n /**\n * Put `role=\"search\"` on the FIELD. False when a wrapping form carries it\n * instead — one search landmark per box, or a screen reader announces the\n * same field twice.\n */\n landmark?: boolean\n onQuery?: (next: string) => void\n onEscape?: () => void\n}) => (\n <Box {...(landmark ? { role: 'search' } : {})} sx={searchFieldSx}>\n <MdiIcon path={mdiMagnify.path} />\n <InputBase\n value={value}\n name=\"q\"\n placeholder={(placeholder ?? '').trim() || 'Search posts…'}\n inputProps={{ 'aria-label': 'Search entries' }}\n sx={{ flex: 1, fontSize: 14, color: 'text.primary' }}\n {...(inert\n ? { readOnly: true }\n : {\n onChange: (event) => onQuery?.(event.target.value),\n onKeyDown: (event) => {\n if (event.key === 'Escape') onEscape?.()\n },\n })}\n />\n </Box>\n)\n\n/**\n * The floating suggestion panel (AGL-1525, Figma 496:1218) — rows straight\n * off the server-stamped index, and a \"View all results\" link that is there\n * hits or no hits.\n *\n * `emptyText` is the caller's, not this component's, and that is the whole\n * point of it being a parameter: a page-windowed entries block and a\n * whole-collection toolbar box searched different sets, and a miss has to say\n * WHICH set came back empty. A shared \"No matches.\" would let one of them\n * claim a reach it never had.\n */\nconst SuggestionPanel = ({\n suggestions,\n trimmed,\n emptyText,\n}: {\n suggestions: readonly Aglyn.CollectionEntrySearchItem[]\n trimmed: string\n emptyText: string\n}) => (\n <Box sx={suggestionPanelSx}>\n {suggestions.length ? (\n suggestions.map((item, index) => {\n const row = (\n <MuiStack spacing={0.5} sx={{ px: 2, py: 1.25 }}>\n <MuiStack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Typography variant=\"subtitle2\" sx={{ color: 'text.primary' }}>\n {item.title}\n </Typography>\n {item.category ? (\n <Chip label={item.category} size=\"small\" />\n ) : null}\n {item.date ? (\n <Typography variant=\"caption\" sx={{ color: 'text.secondary' }}>\n {item.date}\n </Typography>\n ) : null}\n </MuiStack>\n {item.excerpt ? (\n <Typography\n variant=\"body2\"\n sx={{\n color: 'text.secondary',\n // One line of excerpt, as the frame draws it. A row that\n // grows with the prose turns the panel into a page.\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n whiteSpace: 'nowrap',\n }}\n >\n {item.excerpt}\n </Typography>\n ) : null}\n </MuiStack>\n )\n // A row without a `url` is a page cached before AGL-1525 stamped one,\n // or a slugless entry. It renders as TEXT rather than as a link to\n // nowhere: a suggestion that navigates to the wrong page is worse\n // than one the reader has to read.\n return item.url ? (\n <AppLink\n key={index}\n href={item.url}\n sx={suggestionRowLinkSx}\n underline=\"none\"\n >\n {row}\n </AppLink>\n ) : (\n <Box key={index}>{row}</Box>\n )\n })\n ) : (\n <Typography\n variant=\"body2\"\n sx={{ color: 'text.secondary', px: 2, py: 1.25 }}\n >\n {emptyText}\n </Typography>\n )}\n {/*\n Always present, hits or no hits (Figma 496:1218). A box that searches\n one page, or one bounded read of a collection, cannot answer \"then\n where is it?\" on its own — the site-wide results page is the only\n honest answer, and it matters most in exactly the case where the panel\n came back empty.\n */}\n <AppLink\n href={searchResultsHref(trimmed)}\n sx={suggestionFooterSx}\n underline=\"none\"\n >\n {`View all results for “${trimmed}” →`}\n </AppLink>\n </Box>\n)\n\n/**\n * Field plus dropdown, in a REAL form (AGL-1525) — so Enter reaches the\n * results page and the box still works with the panel's JS doing nothing at\n * all. The panel is an enhancement over a working search box, not the search\n * box.\n *\n * Owns the query, because nothing outside it needs one: `suggest` never\n * touches the cards underneath. That is what lets the standalone toolbar\n * block exist at all (AGL-1516) — a box with no clones to hide is a complete\n * feature, not a crippled one.\n */\nexport const SuggestSearchBox = ({\n fuzzy,\n requestFuzzy,\n items,\n placeholder,\n inert,\n emptyText,\n}: {\n /** Null until the matcher has loaded; see {@link useEntryFuse}. */\n fuzzy: EntryFuse | null\n /** Asks for the matcher; called on every query, a no-op once it is here. */\n requestFuzzy: () => void\n items?: readonly Aglyn.CollectionEntrySearchItem[]\n placeholder?: string\n inert?: boolean\n /** The honest miss for the set THIS box searched. */\n emptyText: (query: string) => string\n}) => {\n const [query, setQuery] = useState('')\n /** Escape dismissed THIS answer (AGL-1525); typing brings it back. */\n const [closed, setClosed] = useState(false)\n const softSubmit = useSoftGetSubmit()\n const trimmed = query.trim()\n const suggestions =\n !inert && trimmed && fuzzy && items\n ? fuzzy\n .search(trimmed)\n .slice(0, SUGGESTION_LIMIT)\n .map((result) => items[result.refIndex])\n .filter(Boolean)\n : []\n const field = (\n <SearchField\n value={query}\n {...(placeholder === undefined ? {} : { placeholder })}\n // No form wraps the inert field, so the landmark belongs on it.\n {...(inert ? { inert: true, landmark: true } : {})}\n onQuery={(next) => {\n requestFuzzy()\n setQuery(next)\n // A new query reopens a panel the reader dismissed — Escape closes\n // THIS answer, it does not turn the feature off for the visit.\n setClosed(false)\n }}\n onEscape={() => setClosed(true)}\n />\n )\n if (inert) return field\n return (\n <Box\n component=\"form\"\n role=\"search\"\n action=\"/search\"\n method=\"get\"\n onSubmit={(event: FormEvent<HTMLFormElement>) => {\n // The client router keeps this box mounted when it sits in the\n // site's chrome, so the panel is shut here rather than left open\n // over the results page it just opened.\n setClosed(true)\n softSubmit(event)\n }}\n sx={suggestionAnchorSx}\n >\n {field}\n {/*\n Not until the matcher is here: a panel drawn in the moment it takes\n to arrive would say \"No matches\" about a query nothing has searched.\n */}\n {trimmed && !closed && fuzzy ? (\n <SuggestionPanel\n suggestions={suggestions}\n trimmed={trimmed}\n emptyText={emptyText(trimmed)}\n />\n ) : null}\n </Box>\n )\n}\n"],"names":["Aglyn","mdiMagnify","AppLink","MdiIcon","useSoftGetSubmit","Box","Chip","InputBase","MuiStack","Typography","useMemo","useState","SEARCH_FIELD_WIDTH","SUGGESTION_PANEL_WIDTH","SUGGESTION_LIMIT","searchResultsHref","query","encodeURIComponent","searchFieldSx","alignSelf","display","alignItems","gap","px","py","borderRadius","bgcolor","color","width","maxWidth","suggestionAnchorSx","position","suggestionPanelSx","top","right","zIndex","border","borderColor","boxShadow","overflow","textAlign","suggestionRowLinkSx","suggestionFooterSx","borderTop","fontSize","fontWeight","loadedFuse","fuseLoad","loadCollectionFuse","then","module","Fuse","error","undefined","useEntryFuse","items","FuseConstructor","setFuseConstructor","fuzzy","length","COLLECTION_SEARCH_FUSE_OPTIONS","requestFuzzy","loaded","SearchField","value","placeholder","inert","landmark","onQuery","onEscape","role","sx","path","name","trim","inputProps","flex","readOnly","onChange","event","target","onKeyDown","key","SuggestionPanel","suggestions","trimmed","emptyText","map","item","index","row","spacing","direction","variant","title","category","label","size","date","excerpt","textOverflow","whiteSpace","url","href","underline","SuggestSearchBox","setQuery","closed","setClosed","softSubmit","search","slice","result","refIndex","filter","Boolean","field","next","component","action","method","onSubmit"],"mappings":"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"}
@@ -15,7 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import * as Aglyn from '@aglyn/aglyn';
18
- import type { SxProps } from '@mui/material/styles';
18
+ import { type SxProps } from '@mui/material/styles';
19
19
  import { type ReactNode } from 'react';
20
20
  export declare const ID: Aglyn.ComponentId;
21
21
  /**
@@ -49,10 +49,10 @@ export declare const ID: Aglyn.ComponentId;
49
49
  * screen image. In the besigner canvas nothing is layout-composed, so the
50
50
  * screen's first image is the only lead, exactly as before.
51
51
  *
52
- * Neither lead gets `fetchpriority="high"`, and the reasoning is at the
53
- * `fetchPriority` prop below. Short version: "first image of its origin" is
54
- * still not "the LCP element", and a ranking claim needs evidence this
55
- * function does not have.
52
+ * Only the SCREEN's lead can get `fetchpriority="high"` and a preload, and
53
+ * only when nothing says it renders small (AGL-3485); the reasoning is at the
54
+ * `fetchPriority` prop below. The layout's lead is the header logo on any
55
+ * site that has one, which is why "first image" alone was never evidence.
56
56
  *
57
57
  * Resolved from the tree rather than a render-order counter on purpose: the
58
58
  * renderer walks the tree in document order on the server AND on hydrate, but
@@ -95,6 +95,25 @@ export interface ImageProps {
95
95
  width?: string;
96
96
  /** CSS height (e.g. "240px"); defaults to auto. */
97
97
  height?: string;
98
+ /**
99
+ * The author's own `sizes` attribute, used exactly as typed (AGL-3485).
100
+ * Empty — the normal case — lets the element work it out: see
101
+ * `utils/image-sizes.ts`.
102
+ */
103
+ sizes?: string;
104
+ /**
105
+ * How wide the image rendered on the Besigner canvas, per breakpoint band,
106
+ * as a share of the page: written by the editor's save, never by an author
107
+ * (AGL-3485, `rendered-widths.ts`).
108
+ */
109
+ renderedWidths?: Aglyn.RenderedWidths;
110
+ /**
111
+ * The asset's current content hash, laid on by the composition from its
112
+ * media document like the pixel pair below (AGL-3485). It makes the URL the
113
+ * VERSIONED one, which every cache may keep for a year; a replace changes
114
+ * it, and so the URL.
115
+ */
116
+ mediaVersion?: string;
98
117
  /**
99
118
  * The asset's own pixel dimensions, copied off the media document when the
100
119
  * image was picked (AGL-2486), and read from the asset again when the page
@@ -20,9 +20,12 @@ import { jsx as _jsx } from "react/jsx-runtime";
20
20
  import { mdiImage } from "@aglyn/shared-data-mdi";
21
21
  import { AppLink } from "@aglyn/shared-ui-jsx";
22
22
  import Box from "@mui/material/Box";
23
- import { forwardRef } from "react";
23
+ import { useTheme } from "@mui/material/styles";
24
+ import useForkRef from "@mui/utils/useForkRef";
25
+ import { forwardRef, useEffect, useState } from "react";
24
26
  import { BUNDLE_ID } from "../constants/bundle-common.js";
25
27
  import { generatePresetId } from "../utils/generate-preset-id.js";
28
+ import { imageSizes, layoutBandWidths } from "../utils/image-sizes.js";
26
29
  // Component ids are persisted in screen documents; never rename.
27
30
  export const ID = 'image';
28
31
  /**
@@ -56,10 +59,10 @@ export const ID = 'image';
56
59
  * screen image. In the besigner canvas nothing is layout-composed, so the
57
60
  * screen's first image is the only lead, exactly as before.
58
61
  *
59
- * Neither lead gets `fetchpriority="high"`, and the reasoning is at the
60
- * `fetchPriority` prop below. Short version: "first image of its origin" is
61
- * still not "the LCP element", and a ranking claim needs evidence this
62
- * function does not have.
62
+ * Only the SCREEN's lead can get `fetchpriority="high"` and a preload, and
63
+ * only when nothing says it renders small (AGL-3485); the reasoning is at the
64
+ * `fetchPriority` prop below. The layout's lead is the header logo on any
65
+ * site that has one, which is why "first image" alone was never evidence.
63
66
  *
64
67
  * Resolved from the tree rather than a render-order counter on purpose: the
65
68
  * renderer walks the tree in document order on the server AND on hydrate, but
@@ -104,12 +107,71 @@ export const ID = 'image';
104
107
  screenLead
105
108
  ].filter((id)=>Boolean(id));
106
109
  }
110
+ /**
111
+ * The breakpoint band the theme says is in effect. Asked of the THEME, not of
112
+ * the window: the Besigner canvas pins its theme's breakpoint helpers to the
113
+ * previewed device (`createDevicePinnedTheme`), so on a pinned device this is
114
+ * the device's band, and in Fluid Responsive it is the window's — which is
115
+ * the band the canvas's own layout is answering to either way.
116
+ */ function themeBand(theme) {
117
+ if (typeof window === 'undefined' || !window.matchMedia) return 'xs';
118
+ for (const band of [
119
+ ...Aglyn.RENDERED_WIDTH_BANDS
120
+ ].reverse()){
121
+ if (band === 'xs') break;
122
+ const query = theme.breakpoints.up(band).replace(/^@media\s*/, '');
123
+ if (window.matchMedia(query).matches) return band;
124
+ }
125
+ return 'xs';
126
+ }
127
+ /**
128
+ * The width of the page an element is laid out in. On the canvas that is the
129
+ * shadow host the page renders into — narrower than the window, and the width
130
+ * a share of "the viewport" has to be taken of.
131
+ */ function pageWidthOf(element) {
132
+ const root = element.getRootNode();
133
+ const host = typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot ? root.host : document.documentElement;
134
+ return host.clientWidth;
135
+ }
136
+ /**
137
+ * Records how wide this image renders on the Besigner canvas, per band, for
138
+ * the save to write onto the node (AGL-3485, `rendered-widths.ts`). Inert
139
+ * everywhere but the canvas. A `ResizeObserver` on the image and on the page
140
+ * re-records as the author changes the layout, resizes the window or switches
141
+ * device — a device switch also rebuilds the pinned theme, which re-runs this.
142
+ */ function useRenderedWidthRecorder(nodeId, enabled) {
143
+ const theme = useTheme();
144
+ const [element, setElement] = useState(null);
145
+ useEffect(()=>{
146
+ if (!enabled || !element || !nodeId) return undefined;
147
+ if (typeof ResizeObserver === 'undefined') return undefined;
148
+ const measure = ()=>{
149
+ const percent = Aglyn.renderedWidthPercent(element.getBoundingClientRect().width, pageWidthOf(element));
150
+ if (percent !== undefined) {
151
+ Aglyn.recordRenderedWidth(nodeId, themeBand(theme), percent);
152
+ }
153
+ };
154
+ const observer = new ResizeObserver(measure);
155
+ observer.observe(element);
156
+ const root = element.getRootNode();
157
+ if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot) {
158
+ observer.observe(root.host);
159
+ }
160
+ return ()=>observer.disconnect();
161
+ }, [
162
+ enabled,
163
+ element,
164
+ nodeId,
165
+ theme
166
+ ]);
167
+ return setElement;
168
+ }
107
169
  /**
108
170
  * Image element (AGL-74): renders a plain img with fit/size/radius
109
171
  * controls; an empty src shows a labeled placeholder so the element stays
110
172
  * visible and selectable in the editor.
111
173
  */ const Image = /*#__PURE__*/ forwardRef((props, ref)=>{
112
- const { src: storedSrc, alt, decorative, title, loading, objectFit, width, height, intrinsicWidth, intrinsicHeight, radius, screenId, href: externalHref, // Never forward children to the <img> below — React throws on ANY
174
+ const { src: storedSrc, alt, decorative, title, loading, objectFit, width, height, sizes: authoredSizes, renderedWidths, mediaVersion, intrinsicWidth, intrinsicHeight, radius, screenId, href: externalHref, // Never forward children to the <img> below — React throws on ANY
113
175
  // children value reaching a void element, which 500'd whole pages
114
176
  // when a renderer passed empty JSX children through (AGL-579).
115
177
  children: _children, // Pull `sx` out of the spread: the literals below are composed AFTER
@@ -125,6 +187,9 @@ export const ID = 'image';
125
187
  "objectFit",
126
188
  "width",
127
189
  "height",
190
+ "sizes",
191
+ "renderedWidths",
192
+ "mediaVersion",
128
193
  "intrinsicWidth",
129
194
  "intrinsicHeight",
130
195
  "radius",
@@ -152,8 +217,15 @@ export const ID = 'image';
152
217
  * its leaf ids, so the id in the tree and the id in the context are the
153
218
  * same leaf spelled two ways (the markdown block resolves the same way).
154
219
  */ const nodeId = Aglyn.useNodeId();
155
- const isLeadImage = Boolean(nodeId) && leadImageNodeIds(Aglyn.canvas.rootNode).some((leadId)=>Aglyn.leafIdsMatch(leadId, nodeId));
220
+ const leadIds = nodeId ? leadImageNodeIds(Aglyn.canvas.rootNode) : [];
221
+ const isLeadImage = leadIds.some((leadId)=>Aglyn.leafIdsMatch(leadId, nodeId));
222
+ const screenLeadId = leadIds.find((id)=>!Aglyn.isLayoutComposedNodeId(id));
223
+ const isScreenLead = screenLeadId !== undefined && Aglyn.leafIdsMatch(screenLeadId, nodeId);
156
224
  const eager = loading === 'eager' || loading == null && isLeadImage;
225
+ // The canvas measures; nothing else does (AGL-3485).
226
+ const { editorInert } = Aglyn.useScreenLink(undefined);
227
+ const recordRef = useRenderedWidthRecorder(nodeId, Boolean(editorInert));
228
+ const imageRef = useForkRef(ref, recordRef);
157
229
  /**
158
230
  * Resolve the stored value to a URL (AGL-1215). A media reference becomes
159
231
  * a CDN URL here rather than in the document, so the route shape stays an
@@ -165,8 +237,11 @@ export const ID = 'image';
165
237
  * asking site in the org scope, which is what lets ONE reference in a
166
238
  * layout or reusable component resolve on each site that uses it.
167
239
  */ const { hostId } = Aglyn.useSite();
240
+ // With the asset's version when the composition read one (AGL-3485): the
241
+ // versioned URL is the one every cache may keep, and a replace moves it.
168
242
  const src = Aglyn.resolveMediaSrc(storedSrc, {
169
- hostId
243
+ hostId,
244
+ version: mediaVersion
170
245
  });
171
246
  const wrapLink = (element)=>linkHref && !suppressNavigation ? /*#__PURE__*/ _jsx(AppLink, {
172
247
  componentVariant: "naked",
@@ -185,7 +260,7 @@ export const ID = 'image';
185
260
  sx: nodeSx
186
261
  }));
187
262
  return /*#__PURE__*/ _jsx(Box, _extends({
188
- ref: ref
263
+ ref: imageRef
189
264
  }, rest, {
190
265
  sx: [
191
266
  {
@@ -209,9 +284,41 @@ export const ID = 'image';
209
284
  // CDN URLs (AGL-175) carry WebP variants selected by `?w=`; widths
210
285
  // without a variant fall back to the original server-side, so a static
211
286
  // srcSet is safe for any CDN-form URL. Asked of the RESOLVED url, so a
212
- // reference and a legacy stored path both keep their WebP variants.
213
- const isCdnUrl = Aglyn.isMediaCdnUrl(src);
214
- /** A CSS width that is a plain pixel value, and therefore a real `sizes`. */ const pinnedWidth = /^\d+(?:\.\d+)?px$/.test(String(width != null ? width : '').trim()) ? String(width).trim() : undefined;
287
+ // reference and a legacy stored path both keep their WebP variants. Any
288
+ // other url has no candidates, and so no `sizes` either.
289
+ //
290
+ // The asset's own width caps the list (AGL-3486): the widest candidate is
291
+ // the source, described truthfully, never a `1920w` for a 1000px photo that
292
+ // the CDN can only answer with the same 1000 pixels.
293
+ const srcSet = Aglyn.mediaCdnSrcSet(src, {
294
+ sourceWidth: typeof intrinsicWidth === 'number' && Number.isFinite(intrinsicWidth) && intrinsicWidth > 0 ? intrinsicWidth : undefined
295
+ });
296
+ /**
297
+ * How wide the image will render, for `sizes` (AGL-3485): the author's own
298
+ * value, else per breakpoint a pixel width or height the element states (in
299
+ * its attributes or its `sx`, responsive values included), what the
300
+ * Besigner measured, and what the layout around it states. See
301
+ * `utils/image-sizes.ts` for the order and for why none of it disturbs
302
+ * layout the way `sizes="auto"` did (AGL-2486).
303
+ *
304
+ * The layout is read off the canvas tree this image is rendered from, which
305
+ * the tenant fills on the server too, so the published HTML carries it.
306
+ */ const resolvedSizes = imageSizes({
307
+ sizes: authoredSizes,
308
+ width,
309
+ height,
310
+ sx: nodeSxProp,
311
+ intrinsicWidth,
312
+ intrinsicHeight,
313
+ renderedWidths,
314
+ layoutWidths: nodeId ? layoutBandWidths(Aglyn.canvas.getNode(nodeId)) : undefined
315
+ });
316
+ /**
317
+ * The page's hero: the SCREEN's lead image, loading eagerly, that nothing
318
+ * says renders small. It gets `fetchpriority="high"`, which its preload
319
+ * carries too (AGL-3485) — see `fetchPriority`. Only on a published page:
320
+ * an editing surface has no first paint to win.
321
+ */ const hero = isScreenLead && eager && !resolvedSizes.small && !suppressNavigation;
215
322
  /**
216
323
  * The intrinsic attribute pair, or nothing.
217
324
  *
@@ -226,8 +333,9 @@ export const ID = 'image';
226
333
  width: intrinsicWidth,
227
334
  height: intrinsicHeight
228
335
  } : undefined;
336
+ const sizes = srcSet ? resolvedSizes.sizes : undefined;
229
337
  return wrapLink(/*#__PURE__*/ _jsx(Box, _extends({
230
- ref: ref,
338
+ ref: imageRef,
231
339
  component: "img",
232
340
  src: src,
233
341
  // EVERY CANDIDATE IS A `?w=` URL, and the bare one is gone (2026-08-26).
@@ -240,7 +348,7 @@ export const ID = 'image';
240
348
  // can ask for the same list (AGL-3149). Building it here is the reason
241
349
  // they had none: a candidate list inside a component is a candidate list
242
350
  // no other component can have.
243
- srcSet: Aglyn.mediaCdnSrcSet(src),
351
+ srcSet: srcSet,
244
352
  // `sizes` is NOT only a delivery hint, and treating it as one broke every
245
353
  // fluid image (AGL-2486). With `w` descriptors the browser derives the
246
354
  // image's density-corrected INTRINSIC size from `sizes`, so `sizes` is
@@ -259,24 +367,26 @@ export const ID = 'image';
259
367
  // original in a 158px slot — rendered those images tiny and centred in
260
368
  // their box, on the canvas, in _preview and on published sites alike.
261
369
  //
262
- // A delivery win may not be paid for in layout, so this is back to
263
- // `100vw`: it overfetches, but it is the value every published document
264
- // was authored against. A pinned pixel width is still the better answer
265
- // where the author gave one, because it is a definite length and cannot
266
- // be circular. Getting image delivery right for fluid images needs the
267
- // media pipeline (a WebP variant at source width) or real intrinsic
268
- // `width`/`height` attributes from media metadata — neither of which
269
- // perturbs layout the way `sizes` does.
270
- sizes: isCdnUrl ? pinnedWidth != null ? pinnedWidth : '100vw' : undefined,
370
+ // A delivery win may not be paid for in layout, which is why every
371
+ // answer `imageSizes` gives is a real width of the slot (AGL-3485): the
372
+ // author's own value, a definite pixel length, a span of a parent of
373
+ // definite width, or the width the Besigner MEASURED this image at —
374
+ // a fixed point, since an image whose intrinsic width is its measured
375
+ // width lays out at its measured width. `100vw`, the value every
376
+ // published document was authored against, is now only the last resort
377
+ // for a band nothing describes.
378
+ sizes: sizes,
271
379
  // Unset alt keeps rendering `alt=""` exactly as it always has —
272
380
  // existing documents must not change output (AGL-1305). Decorative
273
381
  // ON forces `alt=""` over any alt text and suppresses the tooltip,
274
382
  // so the a11y intent is explicit rather than an accident of blank.
275
383
  alt: decorative ? '' : alt != null ? alt : '',
276
384
  title: decorative ? undefined : title || undefined
277
- }, eager ? {
385
+ }, eager ? _extends({
278
386
  loading: 'eager'
279
- } : Aglyn.DEFERRED_IMAGE_ATTRIBUTES, intrinsicAttributes, rest, {
387
+ }, hero ? {
388
+ fetchPriority: 'high'
389
+ } : {}) : Aglyn.DEFERRED_IMAGE_ATTRIBUTES, intrinsicAttributes, rest, {
280
390
  sx: [
281
391
  {
282
392
  display: 'block',
@@ -392,6 +502,16 @@ export const schema = {
392
502
  component: Aglyn.FieldComponentType.CSS_DIMENSION,
393
503
  label: 'Height'
394
504
  },
505
+ {
506
+ // AGL-3485. Empty is the answer for nearly everyone: the image works
507
+ // out how big it renders from where it sits on the page, measured as
508
+ // the page is built. This is the override for someone who knows the
509
+ // HTML attribute and wants to say it themselves.
510
+ name: 'sizes',
511
+ description: 'Leave empty — the image works out how big it shows on every ' + 'screen size from where you place it, and downloads a file that ' + 'size. Advanced: an HTML sizes value used exactly as typed, e.g. ' + '(min-width: 900px) 33vw, 100vw.',
512
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
513
+ label: 'Sizes'
514
+ },
395
515
  {
396
516
  name: 'loading',
397
517
  description: 'Lazy waits to load the image until a visitor scrolls near it; ' + 'pick Eager for the first image at the top of a page so it ' + 'shows immediately.',