diff --git a/express/code/blocks/collapsible-rows/collapsible-rows.css b/express/code/blocks/collapsible-rows/collapsible-rows.css index 3eb182c36..6dc6e6bae 100644 --- a/express/code/blocks/collapsible-rows/collapsible-rows.css +++ b/express/code/blocks/collapsible-rows/collapsible-rows.css @@ -71,6 +71,84 @@ main .section .collapsible-row-sub-header p { main .section .collapsible-row-sub-header p:last-of-type { margin-bottom: 0; } + +/* Copy quote / Create a design actions — per Figma node 0-19592: a + right-aligned pair below the quote+author text, 8px gap, pill buttons. */ +.collapsible-row-actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-100); + margin-top: var(--spacing-300); +} + +.collapsible-row-action { + display: inline-flex; + align-items: center; + gap: var(--spacing-80); + box-sizing: border-box; + height: 32px; + padding: 7px 16px 7px 14px; + border: none; + border-radius: 24px; + font-family: var(--body-font-family); + font-size: 14px; + font-weight: var(--heading-font-weight-medium, 700); + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.collapsible-row-action img { + width: 16px; + height: 16px; +} + +.collapsible-row-action--copy { + background: #e9e9e9; + color: #292929; +} + +.collapsible-row-action--copy:hover { + background: #dcdcdc; +} + +.collapsible-row-action--design { + background: #3b63fb; + color: #fff; +} + +.collapsible-row-action--design:hover { + background: #2d4fd6; +} + +.collapsible-row-action--design img { + filter: brightness(0) invert(1); +} + +/* Icon-only on mobile — "Copy quote" needs the label at tablet/desktop + widths where the pill has room, but on mobile the icon alone is enough + and the text just crowds the "Create a design" pill beside it. The + accessible name still comes from the (now sr-only) label text, so this + is a visual change only — see buildQuoteActions in collapsible-rows.js. */ +@media (max-width: 767px) { + .collapsible-row-action--copy span { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .collapsible-row-action--copy { + padding: 7px; + } +} + .collapsible-rows .collapsible-row-toggle-btn { display: block; width: fit-content; diff --git a/express/code/blocks/collapsible-rows/collapsible-rows.js b/express/code/blocks/collapsible-rows/collapsible-rows.js index 9faea5509..112c58dd8 100644 --- a/express/code/blocks/collapsible-rows/collapsible-rows.js +++ b/express/code/blocks/collapsible-rows/collapsible-rows.js @@ -1,10 +1,59 @@ -import { getLibs } from '../../scripts/utils.js'; +import { getLibs, getIconElementDeprecated } from '../../scripts/utils.js'; import { isExpressTypographyClass, isMiloTypographyClass } from '../../scripts/typography-utils.js'; +import showCopyToast from '../../scripts/utils/copy-toast.js'; let createTag; let getConfig; let replaceKey; +/** + * Name of the custom event a mini-editor block on the same page listens + * for (see mini-editor.js) to jump its editor to this exact quote/author + * and scroll it into view — the two blocks are otherwise unrelated, so a + * DOM event keeps them decoupled instead of importing one into the other. + */ +const USE_QUOTE_EVENT = 'mini-editor:use-quote'; + +/** + * Builds the "Copy quote" / "Create a design" action pair added below each + * quote row's text, per Figma node 0-19592. Copy uses the same "quote — + * author" format and shared bottom toast as mini-editor's own copy + * actions, so every copy affordance on the page behaves identically. + * "Create a design" only makes sense when a mini-editor block is present + * on the page to receive the USE_QUOTE_EVENT it dispatches, so the whole + * action pair is skipped otherwise. + */ +function buildQuoteActions(quote, author, hasMiniEditor) { + if (!hasMiniEditor) return null; + + const actions = createTag('div', { class: 'collapsible-row-actions' }); + + const copyBtn = createTag('button', { type: 'button', class: 'collapsible-row-action collapsible-row-action--copy' }, [ + getIconElementDeprecated('copy-quote'), + createTag('span', {}, ['Copy quote']), + ]); + copyBtn.addEventListener('click', async () => { + const text = author ? `${quote} — ${author}` : quote; + try { + await navigator.clipboard.writeText(text); + showCopyToast('Quote copied to clipboard'); + } catch { + // Clipboard write failed (e.g. permissions) — no toast, nothing else to do. + } + }); + + const designBtn = createTag('button', { type: 'button', class: 'collapsible-row-action collapsible-row-action--design' }, [ + getIconElementDeprecated('create-design'), + createTag('span', {}, ['Create a design']), + ]); + designBtn.addEventListener('click', () => { + document.dispatchEvent(new CustomEvent(USE_QUOTE_EVENT, { detail: { quote, author } })); + }); + + actions.append(copyBtn, designBtn); + return actions; +} + function shouldReuseSingleElement(tempContainer) { const childElements = Array.from(tempContainer.children); if (childElements.length !== 1) return false; @@ -42,7 +91,7 @@ function createContentElement(html, baseClass, options = {}) { return element; } -function buildTableLayout(block, typographyClasses = {}) { +function buildTableLayout(block, typographyClasses = {}, hasMiniEditor = false) { const parentDiv = block.closest('.section'); parentDiv?.classList.add('collapsible-rows-grey-bg', 'collapsible-section-padding'); @@ -109,6 +158,12 @@ function buildTableLayout(block, typographyClasses = {}) { subHeaderEl.classList.add(...typographyClasses.body); } subHeaderAccordion.append(subHeaderEl); + const quoteActions = buildQuoteActions( + headerEl.textContent.trim(), + subHeaderEl.textContent.trim(), + hasMiniEditor, + ); + if (quoteActions) subHeaderAccordion.append(quoteActions); headerEl.addEventListener('click', () => { headerAccordion.classList.toggle('rounded-corners'); @@ -121,7 +176,13 @@ function buildTableLayout(block, typographyClasses = {}) { }); } -function buildOriginalLayout(block, typographyClasses = {}, viewMoreText = 'View more', viewLessText = 'View less') { +function buildOriginalLayout( + block, + typographyClasses = {}, + viewMoreText = 'View more', + viewLessText = 'View less', + hasMiniEditor = false, +) { const collapsibleRows = []; const rows = Array.from(block.children); @@ -169,6 +230,12 @@ function buildOriginalLayout(block, typographyClasses = {}, viewMoreText = 'View subHeaderEl.classList.add(...typographyClasses.body); } accordion.append(subHeaderEl); + const quoteActions = buildQuoteActions( + headerEl.textContent.trim(), + subHeaderEl.textContent.trim(), + hasMiniEditor, + ); + if (quoteActions) accordion.append(quoteActions); }); const toggleButton = createTag('a', { class: 'collapsible-row-toggle-btn button' }); @@ -242,14 +309,19 @@ export default async function decorate(block) { const typographyClasses = extractTypographyClasses(block); const isExpandableVariant = block.classList.contains('expandable'); + // "Create a design" only does something when a mini-editor block exists + // on the page to receive its event — checked against the raw authored + // DOM (not decorated state), since block decoration order across blocks + // on a page isn't guaranteed. + const hasMiniEditor = !!document.querySelector('.mini-editor'); if (isExpandableVariant) { - buildTableLayout(block, typographyClasses); + buildTableLayout(block, typographyClasses, hasMiniEditor); } else { const [viewMoreText, viewLessText] = await Promise.all([ replaceKey('view-more', getConfig()), replaceKey('view-less', getConfig()), ]); - buildOriginalLayout(block, typographyClasses, viewMoreText || 'View more', viewLessText || 'View less'); + buildOriginalLayout(block, typographyClasses, viewMoreText || 'View more', viewLessText || 'View less', hasMiniEditor); } } diff --git a/express/code/blocks/mini-editor/mini-editor-background-loader.js b/express/code/blocks/mini-editor/mini-editor-background-loader.js new file mode 100644 index 000000000..d14f9eb7e --- /dev/null +++ b/express/code/blocks/mini-editor/mini-editor-background-loader.js @@ -0,0 +1,55 @@ +/** + * Mini Editor background loader + * + * Single entry point (`getCardBackgrounds`) that returns the background-card + * collection the mini-editor renders, by fetching live templates from the + * template service for the block's authored `collectionId`. + * + * Every card has the same shape: `{ id, bg }`, where `id` is a template urn + * (used later for todo/CTA actions that need to reference the exact source + * asset) and `bg` is the image URL to paint. + */ + +import { + fetchResults, + isValidTemplate, + getImageThumbnailSrc, +} from '../../scripts/template-utils.js'; + +function buildRecipe(props) { + const params = new URLSearchParams(); + params.set('limit', String(props.limit)); + if (props.collectionId) params.set('collectionId', props.collectionId); + if (props.topics) params.set('topics', props.topics); + return params.toString(); +} + +/** + * Returns the mini-editor's background-card collection as `[{ id, bg }, ...]`, + * fetched live from the template service. + * + * @param {Object} props + * @param {string} [props.collectionId] — template collection to fetch from. + * @param {number} props.limit — max cards to return. + * @param {string} [props.topics] — template-fetch topics filter. + * @returns {Promise>} + */ +export default async function getCardBackgrounds(props) { + const recipe = buildRecipe(props); + const res = await fetchResults(recipe); + if (!res?.items?.length) return []; + + return res.items + .filter((item) => isValidTemplate(item)) + .slice(0, props.limit) + .map((item) => { + const page = item.pages?.[0]; + /* eslint-disable no-underscore-dangle */ + const renditionHref = item._links?.['http://ns.adobe.com/adobecloud/rel/rendition']?.href; + const componentHref = item._links?.['http://ns.adobe.com/adobecloud/rel/component']?.href; + /* eslint-enable no-underscore-dangle */ + const bg = getImageThumbnailSrc(renditionHref, componentHref, page); + return { id: item.id, bg }; + }) + .filter((card) => !!card.bg); +} diff --git a/express/code/blocks/mini-editor/mini-editor-fonts-loader.js b/express/code/blocks/mini-editor/mini-editor-fonts-loader.js new file mode 100644 index 000000000..47a7b5f5a --- /dev/null +++ b/express/code/blocks/mini-editor/mini-editor-fonts-loader.js @@ -0,0 +1,117 @@ +/** + * Mini Editor fonts loader + * + * Single entry point (`getFontOptions`) that returns the font-choice list the + * mini-editor's font control renders. It owns the decision of WHERE those + * options come from, so callers never branch on it: + * + * - Load the Adobe Fonts (Typekit) kit, then read whatever families it + * actually exposes and turn them into options (the live source). + * - If the kit fails to load or exposes nothing, fall back to the bundled + * FALLBACK_FONT_OPTIONS so the UI always has choices to show. + * + * Every option has the same shape the widget consumes: + * `{ label, font, italic?, weight? }`. + */ + +// Fallback used only if the Typekit kit fails to load or exposes no fonts +// (network failure, ad blocker, or API shape change) — see getFontOptions. +const FALLBACK_FONT_OPTIONS = [ + { label: 'Sans', font: '"Cal Sans", "Inter", sans-serif' }, + { label: 'Serif', font: '"Source Serif 4", Georgia, serif', italic: true }, + { label: 'Script', font: '"Dancing Script", cursive', italic: true }, + { label: 'Bold', font: '"Poppins", sans-serif', weight: '700' }, + { label: 'Serious', font: 'Georgia, serif' }, +]; + +// Adobe Fonts (Typekit) kit id — same lazy-load approach as font-generator.js: +// load the JS embed kit (works cross-domain) instead of the CSS endpoint +// (which 412s off non-allow-listed domains), and resolve on active/inactive +// so callers await real font readiness, not just script load. +const ADOBE_FONTS_KIT_ID = 'iqd6egj'; + +function loadWebFonts() { + return new Promise((resolve) => { + const runTypekit = () => { + try { + window.Typekit.load({ + kitId: ADOBE_FONTS_KIT_ID, + scriptTimeout: 3000, + async: true, + active: resolve, + inactive: resolve, + }); + } catch { + resolve(); + } + }; + if (window.Typekit) { + runTypekit(); + return; + } + const script = document.createElement('script'); + script.src = `https://use.typekit.net/${ADOBE_FONTS_KIT_ID}.js`; + script.async = true; + script.addEventListener('load', runTypekit, { once: true }); + script.addEventListener('error', resolve, { once: true }); + document.head.append(script); + }); +} + +// Turns a Typekit family slug ("gothic-a1", "source-han-sans-japanese") into +// a human label ("Gothic A1", "Source Han Sans Japanese") for the font pill/ +// buttons — Typekit's font list exposes no display name, only this slug. +function familySlugToLabel(family) { + return family + .split('-') + .map((part) => (part.length <= 2 ? part.toUpperCase() : part[0].toUpperCase() + part.slice(1))) + .join(' '); +} + +/** + * Reads the fonts the loaded Typekit kit actually exposes (window.Typekit + * .fonts.fonts — each entry's `family` is both its slug and the exact CSS + * font-family name Typekit registered, confirmed via document.fonts) and + * turns them into option entries, instead of a hand-authored list that can + * silently drift from whatever the kit (ADOBE_FONTS_KIT_ID) actually + * contains. Variants of the same family (weight/style) collapse into one + * option that offers italic/bold if any variant of that family has it. + * Falls back to FALLBACK_FONT_OPTIONS if the kit failed to load or exposes + * nothing, so the UI still has font choices to show. + */ +function buildFontOptions() { + const entries = window.Typekit?.fonts?.fonts; + if (!Array.isArray(entries) || !entries.length) return FALLBACK_FONT_OPTIONS; + + const byFamily = new Map(); + entries.forEach(({ family, weight, style }) => { + if (!family) return; + const existing = byFamily.get(family) || { italic: false, bold: false }; + if (style === 'italic') existing.italic = true; + if (weight === '700' || weight === 'bold') existing.bold = true; + byFamily.set(family, existing); + }); + if (!byFamily.size) return FALLBACK_FONT_OPTIONS; + + return Array.from(byFamily, ([family, { italic, bold }]) => { + const option = { label: familySlugToLabel(family), font: `"${family}", var(--body-font-family, sans-serif)` }; + if (italic) option.italic = true; + if (bold) option.weight = '700'; + return option; + }); +} + +/** + * Returns the mini-editor's font-choice list as `[{ label, font, italic?, + * weight? }, ...]`. Loads the Adobe Fonts kit first so the options reflect + * whatever families it actually registered, then builds them — the caller + * does not need to load the kit or know which source (live/fallback) was + * used. + * + * @returns {Promise>} + */ +export default async function getFontOptions() { + await loadWebFonts(); + return buildFontOptions(); +} diff --git a/express/code/blocks/mini-editor/mini-editor.css b/express/code/blocks/mini-editor/mini-editor.css new file mode 100644 index 000000000..9c49c5de7 --- /dev/null +++ b/express/code/blocks/mini-editor/mini-editor.css @@ -0,0 +1,173 @@ +/* --me-card-bg / --me-quote-font* defaults live in mini-editor-widget.css + (shared with the "Create a design" modal's card root) — this rule only + adds the arc-sizing tokens and block-level layout that only apply here, + where decorations are enabled. */ +.mini-editor { + /* Arc carousel sizing (tablet/mobile) — defined here, not on .me-arc + itself, so .mini-editor-widget (a sibling of .me-arc, not a + descendant) can also read --me-arc-card-w to keep its own max-width + in lockstep with the carousel's card size at every breakpoint. See + .me-carousel-mode .mini-editor-widget in mini-editor-widget.css and the + mobile media query below for the per-breakpoint overrides. */ + --me-arc-card-w: 542px; + --me-arc-card-h: 350px; + /* Extra height below the centre card's own box: the side cards pivot + around --me-arc-origin-y (a point on the shared carousel circle, far + below the card — see .me-arc-card's transform-origin), so their + outer-bottom corner swings ~76px below the unrotated baseline at rest. + The container needs that much extra room beneath the card to avoid + clipping it. */ + --me-arc-extra-h: 80px; + /* Distance from the card's own vertical centre down to the shared + carousel circle's centre — every role (prev/centre/next, plus the + further-out stage/exit positions used mid-transition) rotates about + this same point via a single rotate() (see .me-arc-card's + transform-origin and the --me-arc-card--* rules below), so the bottom + edge of every card stays on that one circle at every point along a + transition — not just at the resting prev/centre/next positions. + Independent translateX/translateY/rotate interpolation (the previous + approach) couldn't guarantee that: each component eases on its own + timeline, so the combined path bows off the circle mid-transition. + Chosen so the prev/next resting spots land ~574px either side of + centre, matching the pre-existing peek amount: 574 / sin(8deg) ≈ + 4124px. */ + --me-arc-origin-y: 4124px; + /* Visible gap between the carousel card and the font/colour controls + below — --spacing-200 (12px) on tablet/desktop, matching the gap + between the desktop widget's own card and its controls; overridden + to --spacing-100 (8px) on mobile per Figma's "Edit widget Stacked" + frame (node 0-18697), which uses a tighter gap at that size. */ + --me-arc-gap: var(--spacing-300); + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: var(--spacing-700) var(--spacing-400); + gap: var(--spacing-500); +} + +/* sp-theme (see mini-editor.js) is a real element sitting between .mini-editor + and its header/stage content, so the flex layout that used to live + directly on .mini-editor's children needs to be replicated here — sp-theme + has no default block/flex display of its own (per the Spectrum docs at + scripts/widgets/spectrum/ADDING-A-COMPONENT.md). Padding/box-sizing stay + on .mini-editor itself; only the child layout moves. */ +.mini-editor > sp-theme { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + gap: var(--spacing-500); +} + +.mini-editor-header { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-300); + max-width: 668px; + text-align: center; + position: relative; + z-index: 1; +} + +.mini-editor-logo { + display: flex; + align-items: center; + justify-content: center; +} + +.mini-editor-logo img { + height: 20px; + width: auto; +} + +.mini-editor-header h1, +.mini-editor-header h2 { + margin: 0; + font-size: var(--heading-font-size-l); + font-weight: var(--heading-font-weight-extra); + line-height: 32px; + letter-spacing: -1px; + color: var(--color-content-neutral); +} + +/* margin: 0 needs !important to beat main .section .content p / main .section p + in styles.css (3 classes + tag vs. our 1 class + tag) — without it those + rules' margin: var(--spacing-500) 0 wins and blows out the header spacing. */ +.mini-editor-header p { + margin: 0 !important; + max-width: 437px; + font-size: var(--body-font-size-m); + color: var(--Alias-content-typography-Body); +} + +/* Matches the "Design with your quote" CTA on the discover page hero + (.ax-marquee-dynamic-hero .button-container a.button) so both entry + points to the editor use the same button treatment. Selector needs to + out-specificity main a.button.accent:any-link in styles.css. */ +.mini-editor-header p a.button.accent:any-link { + background-color: var(--color-background-accent-default); + padding-top: var(--spacing-200); + padding-bottom: var(--spacing-200); + border-radius: 24px; + font-size: var(--body-font-size-m); + font-style: normal; + font-weight: var(--subheading-font-weight); + line-height: 125%; +} + +@media (width <= 767px) { + .mini-editor { + padding: var(--spacing-500) var(--spacing-300); + /* Smaller card than tablet — per Figma mobile frame 0-18684, only a + small edge of the neighbouring templates should be visible. Since + the container is now 100vw everywhere (see .me-arc in + mini-editor-widget.css), how much of each side card actually shows + follows naturally from the smaller mobile viewport, not a separately + tuned peek amount. --me-arc-origin-y recalculated for this card size + the same way as the base rule (354 / sin(8deg) ≈ 2544px, so the + prev/next resting spots still land ~354px either side of centre); + --me-arc-extra-h likewise recalculated for 327x270. Defined on + .mini-editor (not .me-arc) so .mini-editor-widget can also read + --me-arc-card-w — see .me-carousel-mode .mini-editor-widget. */ + --me-arc-card-w: 327px; + --me-arc-card-h: 270px; + --me-arc-origin-y: 2544px; + --me-arc-extra-h: 50px; + --me-arc-gap: var(--spacing-100); + } + + /* Mobile header is left-aligned (not centred) per Figma frame 0-18684, + with a larger logo lockup and a darker, looser-line-height heading. */ + .mini-editor-header { + align-items: flex-start; + text-align: left; + gap: var(--spacing-300); + } + + .mini-editor-logo { + justify-content: flex-start; + } + + .mini-editor-logo img { + height: 29px; + } + + .mini-editor-header h1, + .mini-editor-header h2 { + line-height: 1.04; + color: var(--Alias-content-typography-Heading); + } + + .mini-editor-header p { + max-width: none; + } + + /* CTA is hidden on mobile — "Button and free messaging" (node 0-18691) + is marked hidden in the Hero Mobile frame. */ + .mini-editor-header p:has(a.button) { + display: none; + } +} diff --git a/express/code/blocks/mini-editor/mini-editor.js b/express/code/blocks/mini-editor/mini-editor.js new file mode 100644 index 000000000..76801d9f5 --- /dev/null +++ b/express/code/blocks/mini-editor/mini-editor.js @@ -0,0 +1,317 @@ +import { getLibs, getIconElementDeprecated } from '../../scripts/utils.js'; +import { + trapFocus, + handleEscapeClose, + disableBackgroundScroll, + restoreBackgroundScroll, +} from '../../scripts/color-shared/spectrum/utils/a11y.js'; +import showCopyToast from '../../scripts/utils/copy-toast.js'; +import MiniEditorCardExporter from '../../scripts/utils/mini-editor-card-export.js'; +import { showExpressToast } from '../../scripts/color-shared/spectrum/components/express-toast.js'; +import createMiniEditorWidget from '../../scripts/widgets/mini-editor-widget/mini-editor-widget.js'; +import createMiniEditorModal from '../../scripts/widgets/mini-editor-modal/mini-editor-modal.js'; +import getCardBackgrounds from './mini-editor-background-loader.js'; +import getFontOptions from './mini-editor-fonts-loader.js'; + +let createTag; +let loadStyle; +let getConfig; +let replaceKey; + +const TEMPLATE_LIMIT = 8; +const DECO_CARD_COUNT = 8; + +// Module-level (not a DOM query) so two mini-editor blocks decorating +// concurrently on the same page can't both pass an empty check before either +// has appended its modal — only the first init() call builds one. +let modalPromise = null; + +/** + * Copies the quote and, when present, its author (as "quote — author") so + * pasted text always carries attribution instead of the quote alone. Shows + * the shared bottom toast on success, per Figma node 0-19315 — every copy + * action on the page uses this same toast, not just the mini-editor's own. + */ +async function copyQuoteToClipboard(quote, author) { + const text = author ? `${quote} — ${author}` : quote; + try { + await navigator.clipboard.writeText(text); + showCopyToast('Quote copied to clipboard'); + return true; + } catch { + return false; + } +} + +async function downloadCard(block, editor) { + const downloadButton = block.querySelector('.me-action--download'); + if (downloadButton?.disabled) return; + + try { + if (downloadButton) { + downloadButton.disabled = true; + downloadButton.setAttribute('aria-busy', 'true'); + await new Promise(requestAnimationFrame); + } + const model = editor?.getContentModel(); + if (!model) throw new Error('Mini-editor content model is unavailable'); + await MiniEditorCardExporter.download(model); + } catch (error) { + window.lana?.log(`Mini-editor download failed: ${error?.message || error}`, { + tags: 'mini-editor,download', + severity: 'error', + }); + const message = await replaceKey('screenshot-download-failed', getConfig()); + await showExpressToast({ message, variant: 'negative' }); + } finally { + if (downloadButton) { + downloadButton.disabled = false; + downloadButton.removeAttribute('aria-busy'); + } + } +} + +/** + * Reads quote + author pairs authored in this same page's collapsible-rows + * block(s). Works whether collapsible-rows has already decorated + * (`.collapsible-row-header` / `.collapsible-row-sub-header`) or not yet + * (raw authored two-column `
` rows), since decoration order across + * blocks on a page isn't guaranteed. Author is optional per row. + */ +function getPageQuotes() { + const main = document.querySelector('main'); + if (!main) return []; + + const decoratedRowSelector = [ + '.collapsible-rows .collapsible-row-wrapper', + '.collapsible-rows .collapsible-row-accordion', + ].join(', '); + const decoratedRows = main.querySelectorAll(decoratedRowSelector); + if (decoratedRows.length) { + return Array.from(decoratedRows, (row) => { + const quote = row.querySelector('.collapsible-row-header')?.textContent.trim() || ''; + const authorEl = row.querySelector('.collapsible-row-sub-header'); + const author = authorEl?.textContent.trim() || ''; + return { quote, author }; + }).filter((q) => !!q.quote); + } + + // The .expandable (table-layout) variant reserves its first two raw rows + // for a background image and a section title (see collapsible-rows.js + // buildTableLayout's rows.shift() calls) — those aren't quotes, and this + // raw fallback has no way to tell them apart from real quote rows before + // collapsible-rows decorates. Skip it there; the decorated-row path above + // already handles that variant correctly once it has decorated. + const rawRows = main.querySelectorAll('.collapsible-rows:not(.expandable) > div'); + return Array.from(rawRows, (row) => { + const cols = row.querySelectorAll(':scope > div'); + return { + quote: cols[0]?.textContent.trim() || '', + author: cols[1]?.textContent.trim() || '', + }; + }).filter((q) => !!q.quote); +} + +function constructProps(block) { + const props = { + collectionId: '', + limit: TEMPLATE_LIMIT, + topics: '', + }; + + Array.from(block.children).forEach((row) => { + const cols = row.querySelectorAll(':scope > div'); + const key = cols[0]?.textContent.trim().toLowerCase(); + const knownKeys = ['collection id', 'limit', 'topics']; + if (cols.length >= 2 && knownKeys.includes(key)) { + const value = cols[1].textContent.trim(); + if (!value) return; + if (key === 'collection id') { + props.collectionId = value.replaceAll('\\:', ':'); + } else if (key === 'limit') { + const n = parseInt(value, 10); + if (!Number.isNaN(n) && n > 0) props.limit = n; + } else if (key === 'topics') { + props.topics = value; + } + return; + } + // Content row: heading/subcopy/CTA authored in the first column, + // regardless of whether DA left a trailing empty second column. + const [firstCol] = cols; + if (!props.contentRow && firstCol?.textContent.trim()) { + props.contentRow = firstCol; + } + }); + + return props; +} + +// Above this, a decorative card (.me-deco-quote) would need to truncate — +// see truncateQuote in mini-editor-widget.js. Quote SELECTION (which quote +// goes on which card) prefers staying under this so a deco card only ever +// truncates when there's truly no untruncated quote left to give it. +const DECO_QUOTE_CHAR_LIMIT = 216; + +/** + * Pairs each fetched background card with a quote so every card/quote + * combination is stable and reusable across the main widget, the desktop + * decorative cards, and the tablet/mobile carousel — all three read from + * this same list. Font is deliberately not part of this pairing: every + * decorative card uses one fixed style (see .me-deco-quote), only the + * editor's own widget has a font choice — see buildFontControl. + * + * cardSet[0] (the main widget's own card, no character limit — see + * DECO_QUOTE_CHAR_LIMIT's own truncation in the widget) always gets the + * first authored quote, same as before. For the decorative cards + * (cardSet[1..]), quotes at or under DECO_QUOTE_CHAR_LIMIT are cycled + * through first — every short quote gets used at least once before any + * long quote is reused — since a card only needs to fall back to a long, + * truncated quote once every short one has already been given a card. + */ +function buildCardSet(cards, quotes) { + const decoSlotCount = Math.max(0, cards.length - 1); + const [firstQuote] = quotes; + const shortQuotes = quotes.filter((q) => q.quote.length <= DECO_QUOTE_CHAR_LIMIT); + const longQuotes = quotes.filter((q) => q.quote.length > DECO_QUOTE_CHAR_LIMIT); + + // Round-robins `pool` to exactly `count` entries — used to fill deco + // slots with short quotes first, reusing each one only after every other + // short quote already has a slot, then the same for long quotes. + const takeRoundRobin = (pool, count) => Array.from( + { length: Math.min(count, pool.length ? count : 0) }, + (_, i) => pool[i % pool.length], + ); + + const decoQuotes = shortQuotes.length >= decoSlotCount + ? takeRoundRobin(shortQuotes, decoSlotCount) + : [...takeRoundRobin(shortQuotes, shortQuotes.length), + ...takeRoundRobin(longQuotes, decoSlotCount - shortQuotes.length)]; + + return cards.map((card, i) => { + const { quote, author } = i === 0 ? firstQuote : decoQuotes[i - 1]; + return { card, quote, author }; + }); +} + +/** + * Milo's decorateButtons only matches `em a` / `strong a` / `p > a strong` + * (see libs/utils/decorate.js), so a plain `

` authored here — with no + * bold/italic wrapper — isn't picked up. Rather than depend on authors + * remembering to wrap the CTA in ``, style it directly as a button. + */ +function decorateCta(header) { + const cta = header.querySelector('a'); + cta?.classList.add('button'); + cta?.classList.add('accent'); +} + +function buildLogo() { + return createTag('div', { class: 'mini-editor-logo', 'aria-hidden': 'true' }, [ + getIconElementDeprecated('adobe-express-logo'), + ]); +} + +function buildContentHeader(props) { + const header = createTag('div', { class: 'mini-editor-header' }); + header.append(buildLogo()); + if (props.contentRow) { + header.append(...props.contentRow.childNodes); + } + return header; +} + +export default async function init(block) { + ({ createTag, loadStyle, getConfig } = await import(`${getLibs()}/utils/utils.js`)); + ({ replaceKey } = await import(`${getLibs()}/features/placeholders.js`)); + loadStyle(`${getConfig().codeRoot}/scripts/widgets/mini-editor-widget/mini-editor-widget.css`); + loadStyle(`${getConfig().codeRoot}/scripts/widgets/mini-editor-modal/mini-editor-modal.css`); + + const props = constructProps(block); + block.innerHTML = ''; + + // Wraps the block's whole rendered output in Spectrum's own theme host so + // its design-token CSS custom properties (--spectrum-*) are actually + // defined for descendants — without it, the topActions icons (real + // Spectrum Web Components, see mini-editor-widget.js) fall back to + // unstyled defaults and don't match the intended look. + await import('../../scripts/widgets/spectrum/dist/theme.js'); + const themeHost = createTag('sp-theme', { + system: 'spectrum-two', color: 'light', scale: 'medium', dir: 'ltr', + }); + block.append(themeHost); + + const header = buildContentHeader(props); + themeHost.append(header); + decorateCta(header); + + const quotes = getPageQuotes(); + + try { + // Backgrounds and fonts load in parallel — the font loader owns its own + // source selection (Typekit vs fallback fonts); backgrounds always fetch + // from the template service. + const [cards, fontOptions] = await Promise.all([ + getCardBackgrounds(props), + getFontOptions(), + ]); + if (!cards.length || !quotes.length) { + block.closest('.section')?.remove(); + return; + } + const cardSet = buildCardSet(cards, quotes); + const a11y = { + trapFocus, + handleEscapeClose, + disableBackgroundScroll, + restoreBackgroundScroll, + copyQuoteToClipboard, + }; + const deps = { createTag, getIconElementDeprecated }; + + const editor = await createMiniEditorWidget({ + root: block, + // Placeholder handlers — real edit/share behavior (deep-link to the + // Express editor and Web Share API) is follow-up work. + topActions: [ + { type: 'edit', onClick: () => console.info('mini-editor: edit action not yet implemented') }, + { type: 'share', onClick: () => console.info('mini-editor: share action not yet implemented') }, + { type: 'download', onClick: () => downloadCard(block, editor) }, + ], + fontOptions, + backgrounds: { cardSet, decoCount: DECO_CARD_COUNT }, + a11y, + deps, + }); + + // Decorations are appended to the header (not the stage) so they can be + // positioned to span from just below the header down to the editor's + // bottom edge, per the Figma reference, without extending past it. + header.append(editor.decorations); + themeHost.append(editor.stage); + + // "Create a design" on collapsible-rows' quotes (see collapsible-rows.js) + // opens this modal — showing just the centre editor card, identically + // across desktop/tablet/mobile — instead of scrolling to this inline + // block. One modal per page regardless of how many mini-editor blocks + // are authored (modalPromise, not a DOM query, so two blocks decorating + // concurrently can't both build one), reusing this block's own fetched + // cards/fonts. + modalPromise ??= createMiniEditorModal({ + fontOptions, + backgrounds: { cardSet, decoCount: DECO_CARD_COUNT }, + a11y, + deps, + }).then((modal) => { + document.body.append(modal.el); + return modal; + }); + await modalPromise; + } catch (error) { + window.lana?.log(`Error in mini-editor: ${error?.message || error}`, { + tags: 'mini-editor', + severity: 'error', + }); + block.closest('.section')?.remove(); + } +} diff --git a/express/code/icons/arc-nav-left.svg b/express/code/icons/arc-nav-left.svg new file mode 100644 index 000000000..9e6c91942 --- /dev/null +++ b/express/code/icons/arc-nav-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/express/code/icons/arc-nav-right.svg b/express/code/icons/arc-nav-right.svg new file mode 100644 index 000000000..1f0311d0f --- /dev/null +++ b/express/code/icons/arc-nav-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/express/code/icons/copy-quote.svg b/express/code/icons/copy-quote.svg new file mode 100644 index 000000000..ef9bdd1f0 --- /dev/null +++ b/express/code/icons/copy-quote.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/express/code/icons/create-design.svg b/express/code/icons/create-design.svg new file mode 100644 index 000000000..9979edf79 --- /dev/null +++ b/express/code/icons/create-design.svg @@ -0,0 +1,4 @@ + + + + diff --git a/express/code/libs/deps/README.md b/express/code/libs/deps/README.md index 8d5f8b7b8..584601521 100644 --- a/express/code/libs/deps/README.md +++ b/express/code/libs/deps/README.md @@ -1,15 +1,17 @@ -# Lit Library Dependencies +# Vendored Library Dependencies -## Files +## Lit + +### Files - `lit.js` - Browser-safe re-export wrapper - `lit-all.min.js` - Bundled Lit library (35 KB) -## Purpose +### Purpose Provides Lit library for Brad's Web Components in a Franklin-compatible way. -## Architecture +### Architecture Franklin cannot resolve bare specifiers like `import { html } from 'lit'` without a bundler or import map. @@ -19,7 +21,7 @@ Franklin cannot resolve bare specifiers like `import { html } from 'lit'` withou - All imports use relative paths - No build step needed -## Usage +### Usage Lit components import from this file: @@ -27,7 +29,7 @@ Lit components import from this file: import { LitElement, html } from '../../../deps/lit.js'; ``` -## Loading Flow +### Loading Flow ``` Block (color-explore.js) @@ -43,6 +45,46 @@ deps/lit.js (this file) deps/lit-all.min.js (bundled Lit) ``` -## Source +### Source Copied from `color-poc` branch. + +## html2canvas + +### Files + +- `html2canvas.js` - Browser-safe re-export wrapper +- `html2canvas-all.min.js` - Bundled `html2canvas` npm package (~200 KB) + +### Purpose + +Provides `html2canvas` for `express/code/scripts/utils/download-utils.js`, which captures an arbitrary `HTMLElement` and downloads a rasterized image of it. No consumer needs to add this dependency themselves — importing `download-utils.js` pulls it in on demand. + +### Architecture + +Same reasoning as Lit above: Franklin cannot resolve bare specifiers like `import html2canvas from 'html2canvas'` without a bundler or import map, so the npm package is bundled once into a self-contained ESM file and imported via a relative path from then on. + +**Solution:** +- Add `html2canvas` as a `devDependency` only (never resolved at runtime, only used to produce the bundle below) +- Bundle it into `html2canvas-all.min.js` via the esbuild CLI, using a tiny inline entry so the output has one clean default export regardless of html2canvas's internal UMD/CJS wrapper: + ```bash + echo "import html2canvas from 'html2canvas'; export default html2canvas;" | \ + npx esbuild --bundle --format=esm --minify --legal-comments=none \ + --banner:js="/* eslint-disable */ + /* Generated by da-express-milo */" \ + --outfile=express/code/libs/deps/html2canvas-all.min.js + ``` +- Use `html2canvas.js` as a re-export wrapper (`import html2canvas from './html2canvas-all.min.js'; export default html2canvas;`) +- Re-run the command above and commit the updated `html2canvas-all.min.js` when bumping the pinned version + +### Usage + +`download-utils.js` loads this lazily via dynamic `import()` on first actual use, not eagerly: + +```javascript +const html2canvas = (await import('../../libs/deps/html2canvas.js')).default; +``` + +### Source + +Bundled from the `html2canvas` npm package (pinned version, see `package.json` devDependencies) — see PR that introduced `express/code/scripts/utils/download-utils.js`. diff --git a/express/code/libs/deps/html2canvas-all.min.js b/express/code/libs/deps/html2canvas-all.min.js new file mode 100644 index 000000000..95feb3e68 --- /dev/null +++ b/express/code/libs/deps/html2canvas-all.min.js @@ -0,0 +1,7 @@ +/* eslint-disable */ +/* Generated by da-express-milo */ +var RQ=Object.create;var Qn=Object.defineProperty;var VQ=Object.getOwnPropertyDescriptor;var NQ=Object.getOwnPropertyNames;var XQ=Object.getPrototypeOf,_Q=Object.prototype.hasOwnProperty;var JQ=(G,L)=>()=>(L||G((L={exports:{}}).exports,L),L.exports);var PQ=(G,L,oA,X)=>{if(L&&typeof L=="object"||typeof L=="function")for(let S of NQ(L))!_Q.call(G,S)&&S!==oA&&Qn(G,S,{get:()=>L[S],enumerable:!(X=VQ(L,S))||X.enumerable});return G};var kQ=(G,L,oA)=>(oA=G!=null?RQ(XQ(G)):{},PQ(L||!G||!G.__esModule?Qn(oA,"default",{value:G,enumerable:!0}):oA,G));var gn=JQ((nt,st)=>{(function(G,L){typeof nt=="object"&&typeof st<"u"?st.exports=L():typeof define=="function"&&define.amd?define(L):(G=typeof globalThis<"u"?globalThis:G||self,G.html2canvas=L())})(nt,function(){"use strict";var G=function(e,A){return G=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,r){t.__proto__=r}||function(t,r){for(var B in r)Object.prototype.hasOwnProperty.call(r,B)&&(t[B]=r[B])},G(e,A)};function L(e,A){if(typeof A!="function"&&A!==null)throw new TypeError("Class extends value "+String(A)+" is not a constructor or null");G(e,A);function t(){this.constructor=e}e.prototype=A===null?Object.create(A):(t.prototype=A.prototype,new t)}var oA=function(){return oA=Object.assign||function(A){for(var t,r=1,B=arguments.length;r0&&n[n.length-1])&&(o[0]===6||o[0]===2)){t=0;continue}if(o[0]===3&&(!n||o[1]>n[0]&&o[1]=55296&&B<=56319&&t>10)+55296,s%1024+56320)),(B+1===t||r.length>16384)&&(n+=String.fromCharCode.apply(String,r),r.length=0)}return n},at="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",Cn=typeof Uint8Array>"u"?[]:new Uint8Array(256),he=0;he"u"?[]:new Uint8Array(256),de=0;de>4,Q[B++]=(s&15)<<4|i>>2,Q[B++]=(i&3)<<6|a&63;return o},ln=function(e){for(var A=e.length,t=[],r=0;r>yA,Fn=1<>yA,dn=ot+hn,En=dn,Hn=32,pn=En+Hn,In=65536>>Qr,vn=1<=0){if(A<55296||A>56319&&A<=65535)return t=this.index[A>>yA],t=(t<>yA)],t=(t<>Qr),t=this.index[t],t+=A>>yA&yn,t=this.index[t],t=(t<"u"?[]:new Uint8Array(256),Ee=0;Eewt?(B.push(!0),i-=wt):B.push(!1),["normal","auto","loose"].indexOf(A)!==-1&&[8208,8211,12316,12448].indexOf(n)!==-1)return r.push(s),t.push(ur);if(i===Tn||i===cr){if(s===0)return r.push(s),t.push(KA);var a=t[s-1];return Xn.indexOf(a)===-1?(r.push(r[s-1]),t.push(a)):(r.push(s),t.push(KA))}if(r.push(s),i===Rn)return t.push(A==="strict"?lr:ee);if(i===ht||i===Gn)return t.push(KA);if(i===Vn)return n>=131072&&n<=196605||n>=196608&&n<=262141?t.push(ee):t.push(KA);t.push(i)}),[r,t,B]},Ir=function(e,A,t,r){var B=r[t];if(Array.isArray(e)?e.indexOf(B)!==-1:e===B)for(var n=t;n<=r.length;){n++;var s=r[n];if(s===A)return!0;if(s!==lA)break}if(B===lA)for(var n=t;n>0;){n--;var i=r[n];if(Array.isArray(e)?e.indexOf(i)!==-1:e===i)for(var a=t;a<=r.length;){a++;var s=r[a];if(s===A)return!0;if(s!==lA)break}if(i!==lA)break}return!1},It=function(e,A){for(var t=e;t>=0;){var r=A[t];if(r===lA)t--;else return r}return 0},Pn=function(e,A,t,r,B){if(t[r]===0)return I;var n=r-1;if(Array.isArray(B)&&B[n]===!0)return I;var s=n-1,i=n+1,a=A[n],o=s>=0?A[s]:0,Q=A[i];if(a===ct&&Q===Ct)return I;if(pr.indexOf(a)!==-1)return dt;if(pr.indexOf(Q)!==-1||Et.indexOf(Q)!==-1)return I;if(It(n,A)===lt)return Ke;if(Hr.get(e[n])===cr||(a===pe||a===Ie)&&Hr.get(e[i])===cr||a===ut||Q===ut||a===ft||[lA,Cr,qA].indexOf(a)===-1&&Q===ft||[He,jA,Mn,OA,MA].indexOf(Q)!==-1||It(n,A)===zA||Ir(fr,zA,n,A)||Ir([He,jA],lr,n,A)||Ir(Ut,Ut,n,A))return I;if(a===lA)return Ke;if(a===fr||Q===fr)return I;if(Q===ur||a===ur)return Ke;if([Cr,qA,lr].indexOf(Q)!==-1||a===On||o===hr&&_n.indexOf(a)!==-1||a===MA&&Q===hr||Q===Ft||gA.indexOf(Q)!==-1&&a===z||gA.indexOf(a)!==-1&&Q===z||a===Ae&&[ee,pe,Ie].indexOf(Q)!==-1||[ee,pe,Ie].indexOf(a)!==-1&&Q===$A||gA.indexOf(a)!==-1&&Ht.indexOf(Q)!==-1||Ht.indexOf(a)!==-1&&gA.indexOf(Q)!==-1||[Ae,$A].indexOf(a)!==-1&&(Q===z||[zA,qA].indexOf(Q)!==-1&&A[i+1]===z)||[zA,qA].indexOf(a)!==-1&&Q===z||a===z&&[z,MA,OA].indexOf(Q)!==-1)return I;if([z,MA,OA,He,jA].indexOf(Q)!==-1)for(var g=n;g>=0;){var w=A[g];if(w===z)return I;if([MA,OA].indexOf(w)!==-1)g--;else break}if([Ae,$A].indexOf(Q)!==-1)for(var g=[He,jA].indexOf(a)!==-1?s:n;g>=0;){var w=A[g];if(w===z)return I;if([MA,OA].indexOf(w)!==-1)g--;else break}if(dr===a&&[dr,ve,Ur,Fr].indexOf(Q)!==-1||[ve,Ur].indexOf(a)!==-1&&[ve,ye].indexOf(Q)!==-1||[ye,Fr].indexOf(a)!==-1&&Q===ye||pt.indexOf(a)!==-1&&[Ft,$A].indexOf(Q)!==-1||pt.indexOf(Q)!==-1&&a===Ae||gA.indexOf(a)!==-1&&gA.indexOf(Q)!==-1||a===OA&&gA.indexOf(Q)!==-1||gA.concat(z).indexOf(a)!==-1&&Q===zA&&Nn.indexOf(e[i])===-1||gA.concat(z).indexOf(Q)!==-1&&a===jA)return I;if(a===Er&&Q===Er){for(var f=t[n],c=1;f>0&&(f--,A[f]===Er);)c++;if(c%2!==0)return I}return a===pe&&Q===Ie?I:Ke},kn=function(e,A){A||(A={lineBreak:"normal",wordBreak:"normal"});var t=Jn(e,A.lineBreak),r=t[0],B=t[1],n=t[2];(A.wordBreak==="break-all"||A.wordBreak==="break-word")&&(B=B.map(function(i){return[z,KA,ht].indexOf(i)!==-1?ee:i}));var s=A.wordBreak==="keep-all"?n.map(function(i,a){return i&&e[a]>=19968&&e[a]<=40959}):void 0;return[r,B,s]},Yn=function(){function e(A,t,r,B){this.codePoints=A,this.required=t===dt,this.start=r,this.end=B}return e.prototype.slice=function(){return O.apply(void 0,this.codePoints.slice(this.start,this.end))},e}(),Wn=function(e,A){var t=Fe(e),r=kn(t,A),B=r[0],n=r[1],s=r[2],i=t.length,a=0,o=0;return{next:function(){if(o>=i)return{done:!0,value:null};for(var Q=I;o=Lt&&e<=57},Ks=function(e){return e>=55296&&e<=57343},GA=function(e){return Y(e)||e>=xt&&e<=St||e>=Dt&&e<=Hs},ms=function(e){return e>=Dt&&e<=Is},Ls=function(e){return e>=xt&&e<=ys},Ds=function(e){return ms(e)||Ls(e)},bs=function(e){return e>=cs},Te=function(e){return e===me||e===jn||e===zn},Se=function(e){return Ds(e)||bs(e)||e===rs},Ot=function(e){return Se(e)||Y(e)||e===W},xs=function(e){return e>=fs&&e<=Us||e===Fs||e>=hs&&e<=ds||e===Es},fA=function(e,A){return e!==te?!1:A!==me},Oe=function(e,A,t){return e===W?Se(A)||fA(A,t):Se(e)?!0:!!(e===te&&fA(e,A))},yr=function(e,A,t){return e===mA||e===W?Y(A)?!0:A===se&&Y(t):Y(e===se?A:e)},Ts=function(e){var A=0,t=1;(e[A]===mA||e[A]===W)&&(e[A]===W&&(t=-1),A++);for(var r=[];Y(e[A]);)r.push(e[A++]);var B=r.length?parseInt(O.apply(void 0,r),10):0;e[A]===se&&A++;for(var n=[];Y(e[A]);)n.push(e[A++]);var s=n.length,i=s?parseInt(O.apply(void 0,n),10):0;(e[A]===Tt||e[A]===bt)&&A++;var a=1;(e[A]===mA||e[A]===W)&&(e[A]===W&&(a=-1),A++);for(var o=[];Y(e[A]);)o.push(e[A++]);var Q=o.length?parseInt(O.apply(void 0,o),10):0;return t*(B+i*Math.pow(10,-s))*Math.pow(10,a*Q)},Ss={type:2},Os={type:3},Ms={type:4},Gs={type:13},Rs={type:8},Vs={type:21},Ns={type:9},Xs={type:10},_s={type:11},Js={type:12},Ps={type:14},Me={type:23},ks={type:1},Ys={type:25},Ws={type:24},Zs={type:26},qs={type:27},js={type:28},zs={type:29},$s={type:31},Kr={type:32},Mt=function(){function e(){this._value=[]}return e.prototype.write=function(A){this._value=this._value.concat(Fe(A))},e.prototype.read=function(){for(var A=[],t=this.consumeToken();t!==Kr;)A.push(t),t=this.consumeToken();return A},e.prototype.consumeToken=function(){var A=this.consumeCodePoint();switch(A){case Le:return this.consumeStringToken(Le);case $n:var t=this.peekCodePoint(0),r=this.peekCodePoint(1),B=this.peekCodePoint(2);if(Ot(t)||fA(r,B)){var n=Oe(t,r,B)?qn:Zn,s=this.consumeName();return{type:5,value:s,flags:n}}break;case As:if(this.peekCodePoint(0)===Be)return this.consumeCodePoint(),Gs;break;case De:return this.consumeStringToken(De);case be:return Ss;case ne:return Os;case vr:if(this.peekCodePoint(0)===Be)return this.consumeCodePoint(),Ps;break;case mA:if(yr(A,this.peekCodePoint(0),this.peekCodePoint(1)))return this.reconsumeCodePoint(A),this.consumeNumericToken();break;case Cs:return Ms;case W:var i=A,a=this.peekCodePoint(0),o=this.peekCodePoint(1);if(yr(i,a,o))return this.reconsumeCodePoint(A),this.consumeNumericToken();if(Oe(i,a,o))return this.reconsumeCodePoint(A),this.consumeIdentLikeToken();if(a===W&&o===ns)return this.consumeCodePoint(),this.consumeCodePoint(),Ws;break;case se:if(yr(A,this.peekCodePoint(0),this.peekCodePoint(1)))return this.reconsumeCodePoint(A),this.consumeNumericToken();break;case yt:if(this.peekCodePoint(0)===vr)for(this.consumeCodePoint();;){var Q=this.consumeCodePoint();if(Q===vr&&(Q=this.consumeCodePoint(),Q===yt))return this.consumeToken();if(Q===nA)return this.consumeToken()}break;case us:return Zs;case ls:return qs;case Bs:if(this.peekCodePoint(0)===ts&&this.peekCodePoint(1)===W&&this.peekCodePoint(2)===W)return this.consumeCodePoint(),this.consumeCodePoint(),Ys;break;case ss:var g=this.peekCodePoint(0),w=this.peekCodePoint(1),f=this.peekCodePoint(2);if(Oe(g,w,f)){var s=this.consumeName();return{type:7,value:s}}break;case as:return js;case te:if(fA(A,this.peekCodePoint(0)))return this.reconsumeCodePoint(A),this.consumeIdentLikeToken();break;case is:return zs;case os:if(this.peekCodePoint(0)===Be)return this.consumeCodePoint(),Rs;break;case Qs:return _s;case gs:return Js;case ps:case vs:var c=this.peekCodePoint(0),C=this.peekCodePoint(1);return c===mA&&(GA(C)||C===xe)&&(this.consumeCodePoint(),this.consumeUnicodeRangeToken()),this.reconsumeCodePoint(A),this.consumeIdentLikeToken();case Kt:if(this.peekCodePoint(0)===Be)return this.consumeCodePoint(),Ns;if(this.peekCodePoint(0)===Kt)return this.consumeCodePoint(),Vs;break;case ws:if(this.peekCodePoint(0)===Be)return this.consumeCodePoint(),Xs;break;case nA:return Kr}return Te(A)?(this.consumeWhiteSpace(),$s):Y(A)?(this.reconsumeCodePoint(A),this.consumeNumericToken()):Se(A)?(this.reconsumeCodePoint(A),this.consumeIdentLikeToken()):{type:6,value:O(A)}},e.prototype.consumeCodePoint=function(){var A=this._value.shift();return typeof A>"u"?-1:A},e.prototype.reconsumeCodePoint=function(A){this._value.unshift(A)},e.prototype.peekCodePoint=function(A){return A>=this._value.length?-1:this._value[A]},e.prototype.consumeUnicodeRangeToken=function(){for(var A=[],t=this.consumeCodePoint();GA(t)&&A.length<6;)A.push(t),t=this.consumeCodePoint();for(var r=!1;t===xe&&A.length<6;)A.push(t),t=this.consumeCodePoint(),r=!0;if(r){var B=parseInt(O.apply(void 0,A.map(function(a){return a===xe?Lt:a})),16),n=parseInt(O.apply(void 0,A.map(function(a){return a===xe?St:a})),16);return{type:30,start:B,end:n}}var s=parseInt(O.apply(void 0,A),16);if(this.peekCodePoint(0)===W&&GA(this.peekCodePoint(1))){this.consumeCodePoint(),t=this.consumeCodePoint();for(var i=[];GA(t)&&i.length<6;)i.push(t),t=this.consumeCodePoint();var n=parseInt(O.apply(void 0,i),16);return{type:30,start:s,end:n}}else return{type:30,start:s,end:s}},e.prototype.consumeIdentLikeToken=function(){var A=this.consumeName();return A.toLowerCase()==="url"&&this.peekCodePoint(0)===be?(this.consumeCodePoint(),this.consumeUrlToken()):this.peekCodePoint(0)===be?(this.consumeCodePoint(),{type:19,value:A}):{type:20,value:A}},e.prototype.consumeUrlToken=function(){var A=[];if(this.consumeWhiteSpace(),this.peekCodePoint(0)===nA)return{type:22,value:""};var t=this.peekCodePoint(0);if(t===De||t===Le){var r=this.consumeStringToken(this.consumeCodePoint());return r.type===0&&(this.consumeWhiteSpace(),this.peekCodePoint(0)===nA||this.peekCodePoint(0)===ne)?(this.consumeCodePoint(),{type:22,value:r.value}):(this.consumeBadUrlRemnants(),Me)}for(;;){var B=this.consumeCodePoint();if(B===nA||B===ne)return{type:22,value:O.apply(void 0,A)};if(Te(B))return this.consumeWhiteSpace(),this.peekCodePoint(0)===nA||this.peekCodePoint(0)===ne?(this.consumeCodePoint(),{type:22,value:O.apply(void 0,A)}):(this.consumeBadUrlRemnants(),Me);if(B===Le||B===De||B===be||xs(B))return this.consumeBadUrlRemnants(),Me;if(B===te)if(fA(B,this.peekCodePoint(0)))A.push(this.consumeEscapedCodePoint());else return this.consumeBadUrlRemnants(),Me;else A.push(B)}},e.prototype.consumeWhiteSpace=function(){for(;Te(this.peekCodePoint(0));)this.consumeCodePoint()},e.prototype.consumeBadUrlRemnants=function(){for(;;){var A=this.consumeCodePoint();if(A===ne||A===nA)return;fA(A,this.peekCodePoint(0))&&this.consumeEscapedCodePoint()}},e.prototype.consumeStringSlice=function(A){for(var t=5e4,r="";A>0;){var B=Math.min(t,A);r+=O.apply(void 0,this._value.splice(0,B)),A-=B}return this._value.shift(),r},e.prototype.consumeStringToken=function(A){var t="",r=0;do{var B=this._value[r];if(B===nA||B===void 0||B===A)return t+=this.consumeStringSlice(r),{type:0,value:t};if(B===me)return this._value.splice(0,r),ks;if(B===te){var n=this._value[r+1];n!==nA&&n!==void 0&&(n===me?(t+=this.consumeStringSlice(r),r=-1,this._value.shift()):fA(B,n)&&(t+=this.consumeStringSlice(r),t+=O(this.consumeEscapedCodePoint()),r=-1))}r++}while(!0)},e.prototype.consumeNumber=function(){var A=[],t=re,r=this.peekCodePoint(0);for((r===mA||r===W)&&A.push(this.consumeCodePoint());Y(this.peekCodePoint(0));)A.push(this.consumeCodePoint());r=this.peekCodePoint(0);var B=this.peekCodePoint(1);if(r===se&&Y(B))for(A.push(this.consumeCodePoint(),this.consumeCodePoint()),t=vt;Y(this.peekCodePoint(0));)A.push(this.consumeCodePoint());r=this.peekCodePoint(0),B=this.peekCodePoint(1);var n=this.peekCodePoint(2);if((r===Tt||r===bt)&&((B===mA||B===W)&&Y(n)||Y(B)))for(A.push(this.consumeCodePoint(),this.consumeCodePoint()),t=vt;Y(this.peekCodePoint(0));)A.push(this.consumeCodePoint());return[Ts(A),t]},e.prototype.consumeNumericToken=function(){var A=this.consumeNumber(),t=A[0],r=A[1],B=this.peekCodePoint(0),n=this.peekCodePoint(1),s=this.peekCodePoint(2);if(Oe(B,n,s)){var i=this.consumeName();return{type:15,number:t,flags:r,unit:i}}return B===es?(this.consumeCodePoint(),{type:16,number:t,flags:r}):{type:17,number:t,flags:r}},e.prototype.consumeEscapedCodePoint=function(){var A=this.consumeCodePoint();if(GA(A)){for(var t=O(A);GA(this.peekCodePoint(0))&&t.length<6;)t+=O(this.consumeCodePoint());Te(this.peekCodePoint(0))&&this.consumeCodePoint();var r=parseInt(t,16);return r===0||Ks(r)||r>1114111?mt:r}return A===nA?mt:A},e.prototype.consumeName=function(){for(var A="";;){var t=this.consumeCodePoint();if(Ot(t))A+=O(t);else if(fA(t,this.peekCodePoint(0)))A+=O(this.consumeEscapedCodePoint());else return this.reconsumeCodePoint(t),A}},e}(),Gt=function(){function e(A){this._tokens=A}return e.create=function(A){var t=new Mt;return t.write(A),new e(t.read())},e.parseValue=function(A){return e.create(A).parseComponentValue()},e.parseValues=function(A){return e.create(A).parseComponentValues()},e.prototype.parseComponentValue=function(){for(var A=this.consumeToken();A.type===31;)A=this.consumeToken();if(A.type===32)throw new SyntaxError("Error parsing CSS component value, unexpected EOF");this.reconsumeToken(A);var t=this.consumeComponentValue();do A=this.consumeToken();while(A.type===31);if(A.type===32)return t;throw new SyntaxError("Error parsing CSS component value, multiple values found when expecting only one")},e.prototype.parseComponentValues=function(){for(var A=[];;){var t=this.consumeComponentValue();if(t.type===32)return A;A.push(t),A.push()}},e.prototype.consumeComponentValue=function(){var A=this.consumeToken();switch(A.type){case 11:case 28:case 2:return this.consumeSimpleBlock(A.type);case 19:return this.consumeFunction(A)}return A},e.prototype.consumeSimpleBlock=function(A){for(var t={type:A,values:[]},r=this.consumeToken();;){if(r.type===32||ea(r,A))return t;this.reconsumeToken(r),t.values.push(this.consumeComponentValue()),r=this.consumeToken()}},e.prototype.consumeFunction=function(A){for(var t={name:A.value,values:[],type:18};;){var r=this.consumeToken();if(r.type===32||r.type===3)return t;this.reconsumeToken(r),t.values.push(this.consumeComponentValue())}},e.prototype.consumeToken=function(){var A=this._tokens.shift();return typeof A>"u"?Kr:A},e.prototype.reconsumeToken=function(A){this._tokens.unshift(A)},e}(),ae=function(e){return e.type===15},RA=function(e){return e.type===17},D=function(e){return e.type===20},Aa=function(e){return e.type===0},mr=function(e,A){return D(e)&&e.value===A},Rt=function(e){return e.type!==31},VA=function(e){return e.type!==31&&e.type!==4},sA=function(e){var A=[],t=[];return e.forEach(function(r){if(r.type===4){if(t.length===0)throw new Error("Error parsing function args, zero tokens for arg");A.push(t),t=[];return}r.type!==31&&t.push(r)}),t.length&&A.push(t),A},ea=function(e,A){return A===11&&e.type===12||A===28&&e.type===29?!0:A===2&&e.type===3},UA=function(e){return e.type===17||e.type===15},R=function(e){return e.type===16||UA(e)},Vt=function(e){return e.length>1?[e[0],e[1]]:[e[0]]},J={type:17,number:0,flags:re},Lr={type:16,number:50,flags:re},FA={type:16,number:100,flags:re},ie=function(e,A,t){var r=e[0],B=e[1];return[x(r,A),x(typeof B<"u"?B:r,t)]},x=function(e,A){if(e.type===16)return e.number/100*A;if(ae(e))switch(e.unit){case"rem":case"em":return 16*e.number;case"px":default:return e.number}return e.number},Nt="deg",Xt="grad",_t="rad",Jt="turn",Ge={name:"angle",parse:function(e,A){if(A.type===15)switch(A.unit){case Nt:return Math.PI*A.number/180;case Xt:return Math.PI/200*A.number;case _t:return A.number;case Jt:return Math.PI*2*A.number}throw new Error("Unsupported angle type")}},Pt=function(e){return e.type===15&&(e.unit===Nt||e.unit===Xt||e.unit===_t||e.unit===Jt)},kt=function(e){var A=e.filter(D).map(function(t){return t.value}).join(" ");switch(A){case"to bottom right":case"to right bottom":case"left top":case"top left":return[J,J];case"to top":case"bottom":return AA(0);case"to bottom left":case"to left bottom":case"right top":case"top right":return[J,FA];case"to right":case"left":return AA(90);case"to top left":case"to left top":case"right bottom":case"bottom right":return[FA,FA];case"to bottom":case"top":return AA(180);case"to top right":case"to right top":case"left bottom":case"bottom left":return[FA,J];case"to left":case"right":return AA(270)}return 0},AA=function(e){return Math.PI*e/180},hA={name:"color",parse:function(e,A){if(A.type===18){var t=ra[A.name];if(typeof t>"u")throw new Error('Attempting to parse an unsupported color function "'+A.name+'"');return t(e,A.values)}if(A.type===5){if(A.value.length===3){var r=A.value.substring(0,1),B=A.value.substring(1,2),n=A.value.substring(2,3);return EA(parseInt(r+r,16),parseInt(B+B,16),parseInt(n+n,16),1)}if(A.value.length===4){var r=A.value.substring(0,1),B=A.value.substring(1,2),n=A.value.substring(2,3),s=A.value.substring(3,4);return EA(parseInt(r+r,16),parseInt(B+B,16),parseInt(n+n,16),parseInt(s+s,16)/255)}if(A.value.length===6){var r=A.value.substring(0,2),B=A.value.substring(2,4),n=A.value.substring(4,6);return EA(parseInt(r,16),parseInt(B,16),parseInt(n,16),1)}if(A.value.length===8){var r=A.value.substring(0,2),B=A.value.substring(2,4),n=A.value.substring(4,6),s=A.value.substring(6,8);return EA(parseInt(r,16),parseInt(B,16),parseInt(n,16),parseInt(s,16)/255)}}if(A.type===20){var i=wA[A.value.toUpperCase()];if(typeof i<"u")return i}return wA.TRANSPARENT}},dA=function(e){return(255&e)===0},_=function(e){var A=255&e,t=255&e>>8,r=255&e>>16,B=255&e>>24;return A<255?"rgba("+B+","+r+","+t+","+A/255+")":"rgb("+B+","+r+","+t+")"},EA=function(e,A,t,r){return(e<<24|A<<16|t<<8|Math.round(r*255)<<0)>>>0},Yt=function(e,A){if(e.type===17)return e.number;if(e.type===16){var t=A===3?1:255;return A===3?e.number/100*t:Math.round(e.number/100*t)}return 0},Wt=function(e,A){var t=A.filter(VA);if(t.length===3){var r=t.map(Yt),B=r[0],n=r[1],s=r[2];return EA(B,n,s,1)}if(t.length===4){var i=t.map(Yt),B=i[0],n=i[1],s=i[2],a=i[3];return EA(B,n,s,a)}return 0};function Dr(e,A,t){return t<0&&(t+=1),t>=1&&(t-=1),t<1/6?(A-e)*t*6+e:t<1/2?A:t<2/3?(A-e)*6*(2/3-t)+e:e}var Zt=function(e,A){var t=A.filter(VA),r=t[0],B=t[1],n=t[2],s=t[3],i=(r.type===17?AA(r.number):Ge.parse(e,r))/(Math.PI*2),a=R(B)?B.number/100:0,o=R(n)?n.number/100:0,Q=typeof s<"u"&&R(s)?x(s,1):1;if(a===0)return EA(o*255,o*255,o*255,1);var g=o<=.5?o*(a+1):o+a-o*a,w=o*2-g,f=Dr(w,g,i+1/3),c=Dr(w,g,i),C=Dr(w,g,i-1/3);return EA(f*255,c*255,C*255,Q)},ra={hsl:Zt,hsla:Zt,rgb:Wt,rgba:Wt},oe=function(e,A){return hA.parse(e,Gt.create(A).parseComponentValue())},wA={ALICEBLUE:4042850303,ANTIQUEWHITE:4209760255,AQUA:16777215,AQUAMARINE:2147472639,AZURE:4043309055,BEIGE:4126530815,BISQUE:4293182719,BLACK:255,BLANCHEDALMOND:4293643775,BLUE:65535,BLUEVIOLET:2318131967,BROWN:2771004159,BURLYWOOD:3736635391,CADETBLUE:1604231423,CHARTREUSE:2147418367,CHOCOLATE:3530104575,CORAL:4286533887,CORNFLOWERBLUE:1687547391,CORNSILK:4294499583,CRIMSON:3692313855,CYAN:16777215,DARKBLUE:35839,DARKCYAN:9145343,DARKGOLDENROD:3095837695,DARKGRAY:2846468607,DARKGREEN:6553855,DARKGREY:2846468607,DARKKHAKI:3182914559,DARKMAGENTA:2332068863,DARKOLIVEGREEN:1433087999,DARKORANGE:4287365375,DARKORCHID:2570243327,DARKRED:2332033279,DARKSALMON:3918953215,DARKSEAGREEN:2411499519,DARKSLATEBLUE:1211993087,DARKSLATEGRAY:793726975,DARKSLATEGREY:793726975,DARKTURQUOISE:13554175,DARKVIOLET:2483082239,DEEPPINK:4279538687,DEEPSKYBLUE:12582911,DIMGRAY:1768516095,DIMGREY:1768516095,DODGERBLUE:512819199,FIREBRICK:2988581631,FLORALWHITE:4294635775,FORESTGREEN:579543807,FUCHSIA:4278255615,GAINSBORO:3705462015,GHOSTWHITE:4177068031,GOLD:4292280575,GOLDENROD:3668254975,GRAY:2155905279,GREEN:8388863,GREENYELLOW:2919182335,GREY:2155905279,HONEYDEW:4043305215,HOTPINK:4285117695,INDIANRED:3445382399,INDIGO:1258324735,IVORY:4294963455,KHAKI:4041641215,LAVENDER:3873897215,LAVENDERBLUSH:4293981695,LAWNGREEN:2096890111,LEMONCHIFFON:4294626815,LIGHTBLUE:2916673279,LIGHTCORAL:4034953471,LIGHTCYAN:3774873599,LIGHTGOLDENRODYELLOW:4210742015,LIGHTGRAY:3553874943,LIGHTGREEN:2431553791,LIGHTGREY:3553874943,LIGHTPINK:4290167295,LIGHTSALMON:4288707327,LIGHTSEAGREEN:548580095,LIGHTSKYBLUE:2278488831,LIGHTSLATEGRAY:2005441023,LIGHTSLATEGREY:2005441023,LIGHTSTEELBLUE:2965692159,LIGHTYELLOW:4294959359,LIME:16711935,LIMEGREEN:852308735,LINEN:4210091775,MAGENTA:4278255615,MAROON:2147483903,MEDIUMAQUAMARINE:1724754687,MEDIUMBLUE:52735,MEDIUMORCHID:3126187007,MEDIUMPURPLE:2473647103,MEDIUMSEAGREEN:1018393087,MEDIUMSLATEBLUE:2070474495,MEDIUMSPRINGGREEN:16423679,MEDIUMTURQUOISE:1221709055,MEDIUMVIOLETRED:3340076543,MIDNIGHTBLUE:421097727,MINTCREAM:4127193855,MISTYROSE:4293190143,MOCCASIN:4293178879,NAVAJOWHITE:4292783615,NAVY:33023,OLDLACE:4260751103,OLIVE:2155872511,OLIVEDRAB:1804477439,ORANGE:4289003775,ORANGERED:4282712319,ORCHID:3664828159,PALEGOLDENROD:4008225535,PALEGREEN:2566625535,PALETURQUOISE:2951671551,PALEVIOLETRED:3681588223,PAPAYAWHIP:4293907967,PEACHPUFF:4292524543,PERU:3448061951,PINK:4290825215,PLUM:3718307327,POWDERBLUE:2967529215,PURPLE:2147516671,REBECCAPURPLE:1714657791,RED:4278190335,ROSYBROWN:3163525119,ROYALBLUE:1097458175,SADDLEBROWN:2336560127,SALMON:4202722047,SANDYBROWN:4104413439,SEAGREEN:780883967,SEASHELL:4294307583,SIENNA:2689740287,SILVER:3233857791,SKYBLUE:2278484991,SLATEBLUE:1784335871,SLATEGRAY:1887473919,SLATEGREY:1887473919,SNOW:4294638335,SPRINGGREEN:16744447,STEELBLUE:1182971135,TAN:3535047935,TEAL:8421631,THISTLE:3636451583,TOMATO:4284696575,TRANSPARENT:0,TURQUOISE:1088475391,VIOLET:4001558271,WHEAT:4125012991,WHITE:4294967295,WHITESMOKE:4126537215,YELLOW:4294902015,YELLOWGREEN:2597139199},ta={name:"background-clip",initialValue:"border-box",prefix:!1,type:1,parse:function(e,A){return A.map(function(t){if(D(t))switch(t.value){case"padding-box":return 1;case"content-box":return 2}return 0})}},Ba={name:"background-color",initialValue:"transparent",prefix:!1,type:3,format:"color"},Re=function(e,A){var t=hA.parse(e,A[0]),r=A[1];return r&&R(r)?{color:t,stop:r}:{color:t,stop:null}},qt=function(e,A){var t=e[0],r=e[e.length-1];t.stop===null&&(t.stop=J),r.stop===null&&(r.stop=FA);for(var B=[],n=0,s=0;sn?B.push(a):B.push(n),n=a}else B.push(null)}for(var o=null,s=0;ss.optimumDistance)?{optimumCorner:i,optimumDistance:Q}:s},{optimumDistance:B?1/0:-1/0,optimumCorner:null}).optimumCorner},aa=function(e,A,t,r,B){var n=0,s=0;switch(e.size){case 0:e.shape===0?n=s=Math.min(Math.abs(A),Math.abs(A-r),Math.abs(t),Math.abs(t-B)):e.shape===1&&(n=Math.min(Math.abs(A),Math.abs(A-r)),s=Math.min(Math.abs(t),Math.abs(t-B)));break;case 2:if(e.shape===0)n=s=Math.min(BA(A,t),BA(A,t-B),BA(A-r,t),BA(A-r,t-B));else if(e.shape===1){var i=Math.min(Math.abs(t),Math.abs(t-B))/Math.min(Math.abs(A),Math.abs(A-r)),a=jt(r,B,A,t,!0),o=a[0],Q=a[1];n=BA(o-A,(Q-t)/i),s=i*n}break;case 1:e.shape===0?n=s=Math.max(Math.abs(A),Math.abs(A-r),Math.abs(t),Math.abs(t-B)):e.shape===1&&(n=Math.max(Math.abs(A),Math.abs(A-r)),s=Math.max(Math.abs(t),Math.abs(t-B)));break;case 3:if(e.shape===0)n=s=Math.max(BA(A,t),BA(A,t-B),BA(A-r,t),BA(A-r,t-B));else if(e.shape===1){var i=Math.max(Math.abs(t),Math.abs(t-B))/Math.max(Math.abs(A),Math.abs(A-r)),g=jt(r,B,A,t,!1),o=g[0],Q=g[1];n=BA(o-A,(Q-t)/i),s=i*n}break}return Array.isArray(e.size)&&(n=x(e.size[0],r),s=e.size.length===2?x(e.size[1],B):n),[n,s]},ia=function(e,A){var t=AA(180),r=[];return sA(A).forEach(function(B,n){if(n===0){var s=B[0];if(s.type===20&&s.value==="to"){t=kt(B);return}else if(Pt(s)){t=Ge.parse(e,s);return}}var i=Re(e,B);r.push(i)}),{angle:t,stops:r,type:1}},Ve=function(e,A){var t=AA(180),r=[];return sA(A).forEach(function(B,n){if(n===0){var s=B[0];if(s.type===20&&["top","left","right","bottom"].indexOf(s.value)!==-1){t=kt(B);return}else if(Pt(s)){t=(Ge.parse(e,s)+AA(270))%AA(360);return}}var i=Re(e,B);r.push(i)}),{angle:t,stops:r,type:1}},oa=function(e,A){var t=AA(180),r=[],B=1,n=0,s=3,i=[];return sA(A).forEach(function(a,o){var Q=a[0];if(o===0){if(D(Q)&&Q.value==="linear"){B=1;return}else if(D(Q)&&Q.value==="radial"){B=2;return}}if(Q.type===18){if(Q.name==="from"){var g=hA.parse(e,Q.values[0]);r.push({stop:J,color:g})}else if(Q.name==="to"){var g=hA.parse(e,Q.values[0]);r.push({stop:FA,color:g})}else if(Q.name==="color-stop"){var w=Q.values.filter(VA);if(w.length===2){var g=hA.parse(e,w[1]),f=w[0];RA(f)&&r.push({stop:{type:16,number:f.number*100,flags:f.flags},color:g})}}}}),B===1?{angle:(t+AA(180))%AA(360),stops:r,type:B}:{size:s,shape:n,stops:r,position:i,type:B}},zt="closest-side",$t="farthest-side",AB="closest-corner",eB="farthest-corner",rB="circle",tB="ellipse",BB="cover",nB="contain",Qa=function(e,A){var t=0,r=3,B=[],n=[];return sA(A).forEach(function(s,i){var a=!0;if(i===0){var o=!1;a=s.reduce(function(g,w){if(o)if(D(w))switch(w.value){case"center":return n.push(Lr),g;case"top":case"left":return n.push(J),g;case"right":case"bottom":return n.push(FA),g}else(R(w)||UA(w))&&n.push(w);else if(D(w))switch(w.value){case rB:return t=0,!1;case tB:return t=1,!1;case"at":return o=!0,!1;case zt:return r=0,!1;case BB:case $t:return r=1,!1;case nB:case AB:return r=2,!1;case eB:return r=3,!1}else if(UA(w)||R(w))return Array.isArray(r)||(r=[]),r.push(w),!1;return g},a)}if(a){var Q=Re(e,s);B.push(Q)}}),{size:r,shape:t,stops:B,position:n,type:2}},Ne=function(e,A){var t=0,r=3,B=[],n=[];return sA(A).forEach(function(s,i){var a=!0;if(i===0?a=s.reduce(function(Q,g){if(D(g))switch(g.value){case"center":return n.push(Lr),!1;case"top":case"left":return n.push(J),!1;case"right":case"bottom":return n.push(FA),!1}else if(R(g)||UA(g))return n.push(g),!1;return Q},a):i===1&&(a=s.reduce(function(Q,g){if(D(g))switch(g.value){case rB:return t=0,!1;case tB:return t=1,!1;case nB:case zt:return r=0,!1;case $t:return r=1,!1;case AB:return r=2,!1;case BB:case eB:return r=3,!1}else if(UA(g)||R(g))return Array.isArray(r)||(r=[]),r.push(g),!1;return Q},a)),a){var o=Re(e,s);B.push(o)}}),{size:r,shape:t,stops:B,position:n,type:2}},ga=function(e){return e.type===1},wa=function(e){return e.type===2},br={name:"image",parse:function(e,A){if(A.type===22){var t={url:A.value,type:0};return e.cache.addImage(A.value),t}if(A.type===18){var r=sB[A.name];if(typeof r>"u")throw new Error('Attempting to parse an unsupported image function "'+A.name+'"');return r(e,A.values)}throw new Error("Unsupported image type "+A.type)}};function ca(e){return!(e.type===20&&e.value==="none")&&(e.type!==18||!!sB[e.name])}var sB={"linear-gradient":ia,"-moz-linear-gradient":Ve,"-ms-linear-gradient":Ve,"-o-linear-gradient":Ve,"-webkit-linear-gradient":Ve,"radial-gradient":Qa,"-moz-radial-gradient":Ne,"-ms-radial-gradient":Ne,"-o-radial-gradient":Ne,"-webkit-radial-gradient":Ne,"-webkit-gradient":oa},Ca={name:"background-image",initialValue:"none",type:1,prefix:!1,parse:function(e,A){if(A.length===0)return[];var t=A[0];return t.type===20&&t.value==="none"?[]:A.filter(function(r){return VA(r)&&ca(r)}).map(function(r){return br.parse(e,r)})}},ua={name:"background-origin",initialValue:"border-box",prefix:!1,type:1,parse:function(e,A){return A.map(function(t){if(D(t))switch(t.value){case"padding-box":return 1;case"content-box":return 2}return 0})}},la={name:"background-position",initialValue:"0% 0%",type:1,prefix:!1,parse:function(e,A){return sA(A).map(function(t){return t.filter(R)}).map(Vt)}},fa={name:"background-repeat",initialValue:"repeat",prefix:!1,type:1,parse:function(e,A){return sA(A).map(function(t){return t.filter(D).map(function(r){return r.value}).join(" ")}).map(Ua)}},Ua=function(e){switch(e){case"no-repeat":return 1;case"repeat-x":case"repeat no-repeat":return 2;case"repeat-y":case"no-repeat repeat":return 3;case"repeat":default:return 0}},NA;(function(e){e.AUTO="auto",e.CONTAIN="contain",e.COVER="cover"})(NA||(NA={}));var Fa={name:"background-size",initialValue:"0",prefix:!1,type:1,parse:function(e,A){return sA(A).map(function(t){return t.filter(ha)})}},ha=function(e){return D(e)||R(e)},Xe=function(e){return{name:"border-"+e+"-color",initialValue:"transparent",prefix:!1,type:3,format:"color"}},da=Xe("top"),Ea=Xe("right"),Ha=Xe("bottom"),pa=Xe("left"),_e=function(e){return{name:"border-radius-"+e,initialValue:"0 0",prefix:!1,type:1,parse:function(A,t){return Vt(t.filter(R))}}},Ia=_e("top-left"),va=_e("top-right"),ya=_e("bottom-right"),Ka=_e("bottom-left"),Je=function(e){return{name:"border-"+e+"-style",initialValue:"solid",prefix:!1,type:2,parse:function(A,t){switch(t){case"none":return 0;case"dashed":return 2;case"dotted":return 3;case"double":return 4}return 1}}},ma=Je("top"),La=Je("right"),Da=Je("bottom"),ba=Je("left"),Pe=function(e){return{name:"border-"+e+"-width",initialValue:"0",type:0,prefix:!1,parse:function(A,t){return ae(t)?t.number:0}}},xa=Pe("top"),Ta=Pe("right"),Sa=Pe("bottom"),Oa=Pe("left"),Ma={name:"color",initialValue:"transparent",prefix:!1,type:3,format:"color"},Ga={name:"direction",initialValue:"ltr",prefix:!1,type:2,parse:function(e,A){switch(A){case"rtl":return 1;case"ltr":default:return 0}}},Ra={name:"display",initialValue:"inline-block",prefix:!1,type:1,parse:function(e,A){return A.filter(D).reduce(function(t,r){return t|Va(r.value)},0)}},Va=function(e){switch(e){case"block":case"-webkit-box":return 2;case"inline":return 4;case"run-in":return 8;case"flow":return 16;case"flow-root":return 32;case"table":return 64;case"flex":case"-webkit-flex":return 128;case"grid":case"-ms-grid":return 256;case"ruby":return 512;case"subgrid":return 1024;case"list-item":return 2048;case"table-row-group":return 4096;case"table-header-group":return 8192;case"table-footer-group":return 16384;case"table-row":return 32768;case"table-cell":return 65536;case"table-column-group":return 131072;case"table-column":return 262144;case"table-caption":return 524288;case"ruby-base":return 1048576;case"ruby-text":return 2097152;case"ruby-base-container":return 4194304;case"ruby-text-container":return 8388608;case"contents":return 16777216;case"inline-block":return 33554432;case"inline-list-item":return 67108864;case"inline-table":return 134217728;case"inline-flex":return 268435456;case"inline-grid":return 536870912}return 0},Na={name:"float",initialValue:"none",prefix:!1,type:2,parse:function(e,A){switch(A){case"left":return 1;case"right":return 2;case"inline-start":return 3;case"inline-end":return 4}return 0}},Xa={name:"letter-spacing",initialValue:"0",prefix:!1,type:0,parse:function(e,A){return A.type===20&&A.value==="normal"?0:A.type===17||A.type===15?A.number:0}},ke;(function(e){e.NORMAL="normal",e.STRICT="strict"})(ke||(ke={}));var _a={name:"line-break",initialValue:"normal",prefix:!1,type:2,parse:function(e,A){switch(A){case"strict":return ke.STRICT;case"normal":default:return ke.NORMAL}}},Ja={name:"line-height",initialValue:"normal",prefix:!1,type:4},aB=function(e,A){return D(e)&&e.value==="normal"?1.2*A:e.type===17?A*e.number:R(e)?x(e,A):A},Pa={name:"list-style-image",initialValue:"none",type:0,prefix:!1,parse:function(e,A){return A.type===20&&A.value==="none"?null:br.parse(e,A)}},ka={name:"list-style-position",initialValue:"outside",prefix:!1,type:2,parse:function(e,A){switch(A){case"inside":return 0;case"outside":default:return 1}}},xr={name:"list-style-type",initialValue:"none",prefix:!1,type:2,parse:function(e,A){switch(A){case"disc":return 0;case"circle":return 1;case"square":return 2;case"decimal":return 3;case"cjk-decimal":return 4;case"decimal-leading-zero":return 5;case"lower-roman":return 6;case"upper-roman":return 7;case"lower-greek":return 8;case"lower-alpha":return 9;case"upper-alpha":return 10;case"arabic-indic":return 11;case"armenian":return 12;case"bengali":return 13;case"cambodian":return 14;case"cjk-earthly-branch":return 15;case"cjk-heavenly-stem":return 16;case"cjk-ideographic":return 17;case"devanagari":return 18;case"ethiopic-numeric":return 19;case"georgian":return 20;case"gujarati":return 21;case"gurmukhi":return 22;case"hebrew":return 22;case"hiragana":return 23;case"hiragana-iroha":return 24;case"japanese-formal":return 25;case"japanese-informal":return 26;case"kannada":return 27;case"katakana":return 28;case"katakana-iroha":return 29;case"khmer":return 30;case"korean-hangul-formal":return 31;case"korean-hanja-formal":return 32;case"korean-hanja-informal":return 33;case"lao":return 34;case"lower-armenian":return 35;case"malayalam":return 36;case"mongolian":return 37;case"myanmar":return 38;case"oriya":return 39;case"persian":return 40;case"simp-chinese-formal":return 41;case"simp-chinese-informal":return 42;case"tamil":return 43;case"telugu":return 44;case"thai":return 45;case"tibetan":return 46;case"trad-chinese-formal":return 47;case"trad-chinese-informal":return 48;case"upper-armenian":return 49;case"disclosure-open":return 50;case"disclosure-closed":return 51;case"none":default:return-1}}},Ye=function(e){return{name:"margin-"+e,initialValue:"0",prefix:!1,type:4}},Ya=Ye("top"),Wa=Ye("right"),Za=Ye("bottom"),qa=Ye("left"),ja={name:"overflow",initialValue:"visible",prefix:!1,type:1,parse:function(e,A){return A.filter(D).map(function(t){switch(t.value){case"hidden":return 1;case"scroll":return 2;case"clip":return 3;case"auto":return 4;case"visible":default:return 0}})}},za={name:"overflow-wrap",initialValue:"normal",prefix:!1,type:2,parse:function(e,A){switch(A){case"break-word":return"break-word";case"normal":default:return"normal"}}},We=function(e){return{name:"padding-"+e,initialValue:"0",prefix:!1,type:3,format:"length-percentage"}},$a=We("top"),Ai=We("right"),ei=We("bottom"),ri=We("left"),ti={name:"text-align",initialValue:"left",prefix:!1,type:2,parse:function(e,A){switch(A){case"right":return 2;case"center":case"justify":return 1;case"left":default:return 0}}},Bi={name:"position",initialValue:"static",prefix:!1,type:2,parse:function(e,A){switch(A){case"relative":return 1;case"absolute":return 2;case"fixed":return 3;case"sticky":return 4}return 0}},ni={name:"text-shadow",initialValue:"none",type:1,prefix:!1,parse:function(e,A){return A.length===1&&mr(A[0],"none")?[]:sA(A).map(function(t){for(var r={color:wA.TRANSPARENT,offsetX:J,offsetY:J,blur:J},B=0,n=0;n"u")throw new Error('Attempting to parse an unsupported transform function "'+A.name+'"');return t(A.values)}return null}},ii=function(e){var A=e.filter(function(t){return t.type===17}).map(function(t){return t.number});return A.length===6?A:null},oi=function(e){var A=e.filter(function(a){return a.type===17}).map(function(a){return a.number}),t=A[0],r=A[1];A[2],A[3];var B=A[4],n=A[5];A[6],A[7],A[8],A[9],A[10],A[11];var s=A[12],i=A[13];return A[14],A[15],A.length===16?[t,r,B,n,s,i]:null},Qi={matrix:ii,matrix3d:oi},iB={type:16,number:50,flags:re},gi=[iB,iB],wi={name:"transform-origin",initialValue:"50% 50%",prefix:!0,type:1,parse:function(e,A){var t=A.filter(R);return t.length!==2?gi:[t[0],t[1]]}},ci={name:"visible",initialValue:"none",prefix:!1,type:2,parse:function(e,A){switch(A){case"hidden":return 1;case"collapse":return 2;case"visible":default:return 0}}},Qe;(function(e){e.NORMAL="normal",e.BREAK_ALL="break-all",e.KEEP_ALL="keep-all"})(Qe||(Qe={}));for(var Ci={name:"word-break",initialValue:"normal",prefix:!1,type:2,parse:function(e,A){switch(A){case"break-all":return Qe.BREAK_ALL;case"keep-all":return Qe.KEEP_ALL;case"normal":default:return Qe.NORMAL}}},ui={name:"z-index",initialValue:"auto",prefix:!1,type:0,parse:function(e,A){if(A.type===20)return{auto:!0,order:0};if(RA(A))return{auto:!1,order:A.number};throw new Error("Invalid z-index number parsed")}},oB={name:"time",parse:function(e,A){if(A.type===15)switch(A.unit.toLowerCase()){case"s":return 1e3*A.number;case"ms":return A.number}throw new Error("Unsupported time type")}},li={name:"opacity",initialValue:"1",type:0,prefix:!1,parse:function(e,A){return RA(A)?A.number:1}},fi={name:"text-decoration-color",initialValue:"transparent",prefix:!1,type:3,format:"color"},Ui={name:"text-decoration-line",initialValue:"none",prefix:!1,type:1,parse:function(e,A){return A.filter(D).map(function(t){switch(t.value){case"underline":return 1;case"overline":return 2;case"line-through":return 3;case"none":return 4}return 0}).filter(function(t){return t!==0})}},Fi={name:"font-family",initialValue:"",prefix:!1,type:1,parse:function(e,A){var t=[],r=[];return A.forEach(function(B){switch(B.type){case 20:case 0:t.push(B.value);break;case 17:t.push(B.number.toString());break;case 4:r.push(t.join(" ")),t.length=0;break}}),t.length&&r.push(t.join(" ")),r.map(function(B){return B.indexOf(" ")===-1?B:"'"+B+"'"})}},hi={name:"font-size",initialValue:"0",prefix:!1,type:3,format:"length"},di={name:"font-weight",initialValue:"normal",type:0,prefix:!1,parse:function(e,A){if(RA(A))return A.number;if(D(A))switch(A.value){case"bold":return 700;case"normal":default:return 400}return 400}},Ei={name:"font-variant",initialValue:"none",type:1,prefix:!1,parse:function(e,A){return A.filter(D).map(function(t){return t.value})}},Hi={name:"font-style",initialValue:"normal",prefix:!1,type:2,parse:function(e,A){switch(A){case"oblique":return"oblique";case"italic":return"italic";case"normal":default:return"normal"}}},N=function(e,A){return(e&A)!==0},pi={name:"content",initialValue:"none",type:1,prefix:!1,parse:function(e,A){if(A.length===0)return[];var t=A[0];return t.type===20&&t.value==="none"?[]:A}},Ii={name:"counter-increment",initialValue:"none",prefix:!0,type:1,parse:function(e,A){if(A.length===0)return null;var t=A[0];if(t.type===20&&t.value==="none")return null;for(var r=[],B=A.filter(Rt),n=0;n1?1:0],this.overflowWrap=U(A,za,t.overflowWrap),this.paddingTop=U(A,$a,t.paddingTop),this.paddingRight=U(A,Ai,t.paddingRight),this.paddingBottom=U(A,ei,t.paddingBottom),this.paddingLeft=U(A,ri,t.paddingLeft),this.paintOrder=U(A,Li,t.paintOrder),this.position=U(A,Bi,t.position),this.textAlign=U(A,ti,t.textAlign),this.textDecorationColor=U(A,fi,(r=t.textDecorationColor)!==null&&r!==void 0?r:t.color),this.textDecorationLine=U(A,Ui,(B=t.textDecorationLine)!==null&&B!==void 0?B:t.textDecoration),this.textShadow=U(A,ni,t.textShadow),this.textTransform=U(A,si,t.textTransform),this.transform=U(A,ai,t.transform),this.transformOrigin=U(A,wi,t.transformOrigin),this.visibility=U(A,ci,t.visibility),this.webkitTextStrokeColor=U(A,Di,t.webkitTextStrokeColor),this.webkitTextStrokeWidth=U(A,bi,t.webkitTextStrokeWidth),this.wordBreak=U(A,Ci,t.wordBreak),this.zIndex=U(A,ui,t.zIndex)}return e.prototype.isVisible=function(){return this.display>0&&this.opacity>0&&this.visibility===0},e.prototype.isTransparent=function(){return dA(this.backgroundColor)},e.prototype.isTransformed=function(){return this.transform!==null},e.prototype.isPositioned=function(){return this.position!==0},e.prototype.isPositionedWithZIndex=function(){return this.isPositioned()&&!this.zIndex.auto},e.prototype.isFloating=function(){return this.float!==0},e.prototype.isInlineLevel=function(){return N(this.display,4)||N(this.display,33554432)||N(this.display,268435456)||N(this.display,536870912)||N(this.display,67108864)||N(this.display,134217728)},e}(),Ti=function(){function e(A,t){this.content=U(A,pi,t.content),this.quotes=U(A,Ki,t.quotes)}return e}(),gB=function(){function e(A,t){this.counterIncrement=U(A,Ii,t.counterIncrement),this.counterReset=U(A,vi,t.counterReset)}return e}(),U=function(e,A,t){var r=new Mt,B=t!==null&&typeof t<"u"?t.toString():A.initialValue;r.write(B);var n=new Gt(r.read());switch(A.type){case 2:var s=n.parseComponentValue();return A.parse(e,D(s)?s.value:A.initialValue);case 0:return A.parse(e,n.parseComponentValue());case 1:return A.parse(e,n.parseComponentValues());case 4:return n.parseComponentValue();case 3:switch(A.format){case"angle":return Ge.parse(e,n.parseComponentValue());case"color":return hA.parse(e,n.parseComponentValue());case"image":return br.parse(e,n.parseComponentValue());case"length":var i=n.parseComponentValue();return UA(i)?i:J;case"length-percentage":var a=n.parseComponentValue();return R(a)?a:J;case"time":return oB.parse(e,n.parseComponentValue())}break}},Si="data-html2canvas-debug",Oi=function(e){var A=e.getAttribute(Si);switch(A){case"all":return 1;case"clone":return 2;case"parse":return 3;case"render":return 4;default:return 0}},Tr=function(e,A){var t=Oi(e);return t===1||A===t},aA=function(){function e(A,t){if(this.context=A,this.textNodes=[],this.elements=[],this.flags=0,Tr(t,3))debugger;this.styles=new xi(A,window.getComputedStyle(t,null)),Wr(t)&&(this.styles.animationDuration.some(function(r){return r>0})&&(t.style.animationDuration="0s"),this.styles.transform!==null&&(t.style.transform="none")),this.bounds=Ue(this.context,t),Tr(t,4)&&(this.flags|=16)}return e}(),Mi="AAAAAAAAAAAAEA4AGBkAAFAaAAACAAAAAAAIABAAGAAwADgACAAQAAgAEAAIABAACAAQAAgAEAAIABAACAAQAAgAEAAIABAAQABIAEQATAAIABAACAAQAAgAEAAIABAAVABcAAgAEAAIABAACAAQAGAAaABwAHgAgACIAI4AlgAIABAAmwCjAKgAsAC2AL4AvQDFAMoA0gBPAVYBWgEIAAgACACMANoAYgFkAWwBdAF8AX0BhQGNAZUBlgGeAaMBlQGWAasBswF8AbsBwwF0AcsBYwHTAQgA2wG/AOMBdAF8AekB8QF0AfkB+wHiAHQBfAEIAAMC5gQIAAsCEgIIAAgAFgIeAggAIgIpAggAMQI5AkACygEIAAgASAJQAlgCYAIIAAgACAAKBQoFCgUTBRMFGQUrBSsFCAAIAAgACAAIAAgACAAIAAgACABdAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABoAmgCrwGvAQgAbgJ2AggAHgEIAAgACADnAXsCCAAIAAgAgwIIAAgACAAIAAgACACKAggAkQKZAggAPADJAAgAoQKkAqwCsgK6AsICCADJAggA0AIIAAgACAAIANYC3gIIAAgACAAIAAgACABAAOYCCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAkASoB+QIEAAgACAA8AEMCCABCBQgACABJBVAFCAAIAAgACAAIAAgACAAIAAgACABTBVoFCAAIAFoFCABfBWUFCAAIAAgACAAIAAgAbQUIAAgACAAIAAgACABzBXsFfQWFBYoFigWKBZEFigWKBYoFmAWfBaYFrgWxBbkFCAAIAAgACAAIAAgACAAIAAgACAAIAMEFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAMgFCADQBQgACAAIAAgACAAIAAgACAAIAAgACAAIAO4CCAAIAAgAiQAIAAgACABAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAD0AggACAD8AggACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIANYFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAMDvwAIAAgAJAIIAAgACAAIAAgACAAIAAgACwMTAwgACAB9BOsEGwMjAwgAKwMyAwsFYgE3A/MEPwMIAEUDTQNRAwgAWQOsAGEDCAAIAAgACAAIAAgACABpAzQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFIQUoBSwFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABtAwgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABMAEwACAAIAAgACAAIABgACAAIAAgACAC/AAgACAAyAQgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACACAAIAAwAAgACAAIAAgACAAIAAgACAAIAAAARABIAAgACAAIABQASAAIAAgAIABwAEAAjgCIABsAqAC2AL0AigDQAtwC+IJIQqVAZUBWQqVAZUBlQGVAZUBlQGrC5UBlQGVAZUBlQGVAZUBlQGVAXsKlQGVAbAK6wsrDGUMpQzlDJUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAfAKAAuZA64AtwCJALoC6ADwAAgAuACgA/oEpgO6AqsD+AAIAAgAswMIAAgACAAIAIkAuwP5AfsBwwPLAwgACAAIAAgACADRA9kDCAAIAOED6QMIAAgACAAIAAgACADuA/YDCAAIAP4DyQAIAAgABgQIAAgAXQAOBAgACAAIAAgACAAIABMECAAIAAgACAAIAAgACAD8AAQBCAAIAAgAGgQiBCoECAExBAgAEAEIAAgACAAIAAgACAAIAAgACAAIAAgACAA4BAgACABABEYECAAIAAgATAQYAQgAVAQIAAgACAAIAAgACAAIAAgACAAIAFoECAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAOQEIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAB+BAcACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAEABhgSMBAgACAAIAAgAlAQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAwAEAAQABAADAAMAAwADAAQABAAEAAQABAAEAAQABHATAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAdQMIAAgACAAIAAgACAAIAMkACAAIAAgAfQMIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACACFA4kDCAAIAAgACAAIAOcBCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAIcDCAAIAAgACAAIAAgACAAIAAgACAAIAJEDCAAIAAgACADFAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABgBAgAZgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAbAQCBXIECAAIAHkECAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABAAJwEQACjBKoEsgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAC6BMIECAAIAAgACAAIAAgACABmBAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAxwQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAGYECAAIAAgAzgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAigWKBYoFigWKBYoFigWKBd0FXwUIAOIF6gXxBYoF3gT5BQAGCAaKBYoFigWKBYoFigWKBYoFigWKBYoFigXWBIoFigWKBYoFigWKBYoFigWKBYsFEAaKBYoFigWKBYoFigWKBRQGCACKBYoFigWKBQgACAAIANEECAAIABgGigUgBggAJgYIAC4GMwaKBYoF0wQ3Bj4GigWKBYoFigWKBYoFigWKBYoFigWKBYoFigUIAAgACAAIAAgACAAIAAgAigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWLBf///////wQABAAEAAQABAAEAAQABAAEAAQAAwAEAAQAAgAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAQADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAUAAAAFAAUAAAAFAAUAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUAAQAAAAUABQAFAAUABQAFAAAAAAAFAAUAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAFAAUAAQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUABQAFAAAABwAHAAcAAAAHAAcABwAFAAEAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAcABwAFAAUAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAQABAAAAAAAAAAAAAAAFAAUABQAFAAAABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABwAHAAcAAAAHAAcAAAAAAAUABQAHAAUAAQAHAAEABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABwABAAUABQAFAAUAAAAAAAAAAAAAAAEAAQABAAEAAQABAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABQANAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAQABAAEAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAABQAHAAUABQAFAAAAAAAAAAcABQAFAAUABQAFAAQABAAEAAQABAAEAAQABAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUAAAAFAAUABQAFAAUAAAAFAAUABQAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAAAAAAAAAAAAUABQAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAUAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABwAHAAcABwAFAAcABwAAAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAUABwAHAAUABQAFAAUAAAAAAAcABwAAAAAABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAABQAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAABwAHAAcABQAFAAAAAAAAAAAABQAFAAAAAAAFAAUABQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAFAAUABQAFAAUAAAAFAAUABwAAAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAFAAUABwAFAAUABQAFAAAAAAAHAAcAAAAAAAcABwAFAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABwAAAAAAAAAHAAcABwAAAAcABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAABQAHAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAcABwAAAAUABQAFAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABQAHAAcABQAHAAcAAAAFAAcABwAAAAcABwAFAAUAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAFAAcABwAFAAUABQAAAAUAAAAHAAcABwAHAAcABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAHAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAABwAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAUAAAAFAAAAAAAAAAAABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUABQAFAAUAAAAFAAUAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABwAFAAUABQAFAAUABQAAAAUABQAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABQAFAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABQAFAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAHAAUABQAFAAUABQAFAAUABwAHAAcABwAHAAcABwAHAAUABwAHAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABwAHAAcABwAFAAUABwAHAAcAAAAAAAAAAAAHAAcABQAHAAcABwAHAAcABwAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAHAAUABQAFAAUABQAFAAUAAAAFAAAABQAAAAAABQAFAAUABQAFAAUABQAFAAcABwAHAAcABwAHAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAUABQAFAAUABQAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABwAFAAcABwAHAAcABwAFAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAUABQAFAAUABwAHAAUABQAHAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABQAFAAcABwAHAAUABwAFAAUABQAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAUABQAFAAUABQAFAAUABQAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAcABQAFAAUABQAFAAUABQAAAAAAAAAAAAUAAAAAAAAAAAAAAAAABQAAAAAABwAFAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUAAAAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAABQAAAAAAAAAFAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAUABQAHAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABwAFAAUABQAFAAcABwAFAAUABwAHAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAcABwAFAAUABwAHAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAFAAUABQAAAAAABQAFAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAFAAcABwAAAAAAAAAAAAAABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAFAAcABwAFAAcABwAAAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAFAAUABQAAAAUABQAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABwAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABQAFAAUABQAFAAUABQAFAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAHAAcABQAHAAUABQAAAAAAAAAAAAAAAAAFAAAABwAHAAcABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAcABwAAAAAABwAHAAAAAAAHAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABwAHAAUABQAFAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABQAFAAUABQAFAAUABwAFAAcABwAFAAcABQAFAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABQAFAAUABQAAAAAABwAHAAcABwAFAAUABwAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAHAAUABQAFAAUABQAFAAUABQAHAAcABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAFAAcABwAFAAUABQAFAAUABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAcABwAFAAUABQAFAAcABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABQAHAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAAAAAAFAAUABwAHAAcABwAFAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABwAHAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAHAAUABQAFAAUABQAFAAUABwAFAAUABwAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAAAAAAAABQAAAAUABQAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAHAAcAAAAFAAUAAAAHAAcABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAAAAAAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAUABQAFAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAABQAFAAUABQAFAAUABQAAAAUABQAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAFAAUABQAFAAUADgAOAA4ADgAOAA4ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAAAAAAAAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAMAAwADAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkAAAAAAAAAAAAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAAAAAAAAAAAAsADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwACwAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAADgAOAA4AAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAAAA4ADgAOAA4ADgAOAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAA4AAAAOAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAADgAAAAAAAAAAAA4AAAAOAAAAAAAAAAAADgAOAA4AAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAA4ADgAOAA4ADgAOAA4ADgAOAAAADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4AAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAOAA4ADgAOAA4ADgAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAAAAAAA=",wB="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",ge=typeof Uint8Array>"u"?[]:new Uint8Array(256),Ze=0;Ze>4,Q[B++]=(s&15)<<4|i>>2,Q[B++]=(i&3)<<6|a&63;return o},Ri=function(e){for(var A=e.length,t=[],r=0;r>LA,Xi=1<>LA,Ji=cB+_i,Pi=Ji,ki=32,Yi=Pi+ki,Wi=65536>>Sr,Zi=1<=0){if(A<55296||A>56319&&A<=65535)return t=this.index[A>>LA],t=(t<>LA)],t=(t<>Sr),t=this.index[t],t+=A>>LA&qi,t=this.index[t],t=(t<"u"?[]:new Uint8Array(256),qe=0;qe=55296&&B<=56319&&t>10)+55296,s%1024+56320)),(B+1===t||r.length>16384)&&(n+=String.fromCharCode.apply(String,r),r.length=0)}return n},no=zi(Mi),eA="\xD7",_r="\xF7",so=function(e){return no.get(e)},ao=function(e,A,t){var r=t-2,B=A[r],n=A[t-1],s=A[t];if(n===Gr&&s===Rr)return eA;if(n===Gr||n===Rr||n===lB||s===Gr||s===Rr||s===lB)return _r;if(n===UB&&[UB,Vr,FB,hB].indexOf(s)!==-1||(n===FB||n===Vr)&&(s===Vr||s===Nr)||(n===hB||n===Nr)&&s===Nr||s===dB||s===fB||s===ro||n===eo)return eA;if(n===dB&&s===EB){for(;B===fB;)B=A[--r];if(B===EB)return eA}if(n===Xr&&s===Xr){for(var i=0;B===Xr;)i++,B=A[--r];if(i%2===0)return eA}return _r},io=function(e){var A=to(e),t=A.length,r=0,B=0,n=A.map(so);return{next:function(){if(r>=t)return{done:!0,value:null};for(var s=eA;rs.x||Q.y>s.y;return s=Q,o===0?!0:g});return e.body.removeChild(A),i},wo=function(){return typeof new Image().crossOrigin<"u"},co=function(){return typeof new XMLHttpRequest().responseType=="string"},Co=function(e){var A=new Image,t=e.createElement("canvas"),r=t.getContext("2d");if(!r)return!1;A.src="data:image/svg+xml,";try{r.drawImage(A,0,0),t.toDataURL()}catch{return!1}return!0},HB=function(e){return e[0]===0&&e[1]===255&&e[2]===0&&e[3]===255},uo=function(e){var A=e.createElement("canvas"),t=100;A.width=t,A.height=t;var r=A.getContext("2d");if(!r)return Promise.reject(!1);r.fillStyle="rgb(0, 255, 0)",r.fillRect(0,0,t,t);var B=new Image,n=A.toDataURL();B.src=n;var s=Jr(t,t,0,0,B);return r.fillStyle="red",r.fillRect(0,0,t,t),pB(s).then(function(i){r.drawImage(i,0,0);var a=r.getImageData(0,0,t,t).data;r.fillStyle="red",r.fillRect(0,0,t,t);var o=e.createElement("div");return o.style.backgroundImage="url("+n+")",o.style.height=t+"px",HB(a)?pB(Jr(t,t,0,0,o)):Promise.reject(!1)}).then(function(i){return r.drawImage(i,0,0),HB(r.getImageData(0,0,t,t).data)}).catch(function(){return!1})},Jr=function(e,A,t,r,B){var n="http://www.w3.org/2000/svg",s=document.createElementNS(n,"svg"),i=document.createElementNS(n,"foreignObject");return s.setAttributeNS(null,"width",e.toString()),s.setAttributeNS(null,"height",A.toString()),i.setAttributeNS(null,"width","100%"),i.setAttributeNS(null,"height","100%"),i.setAttributeNS(null,"x",t.toString()),i.setAttributeNS(null,"y",r.toString()),i.setAttributeNS(null,"externalResourcesRequired","true"),s.appendChild(i),i.appendChild(B),s},pB=function(e){return new Promise(function(A,t){var r=new Image;r.onload=function(){return A(r)},r.onerror=t,r.src="data:image/svg+xml;charset=utf-8,"+encodeURIComponent(new XMLSerializer().serializeToString(e))})},P={get SUPPORT_RANGE_BOUNDS(){var e=Qo(document);return Object.defineProperty(P,"SUPPORT_RANGE_BOUNDS",{value:e}),e},get SUPPORT_WORD_BREAKING(){var e=P.SUPPORT_RANGE_BOUNDS&&go(document);return Object.defineProperty(P,"SUPPORT_WORD_BREAKING",{value:e}),e},get SUPPORT_SVG_DRAWING(){var e=Co(document);return Object.defineProperty(P,"SUPPORT_SVG_DRAWING",{value:e}),e},get SUPPORT_FOREIGNOBJECT_DRAWING(){var e=typeof Array.from=="function"&&typeof window.fetch=="function"?uo(document):Promise.resolve(!1);return Object.defineProperty(P,"SUPPORT_FOREIGNOBJECT_DRAWING",{value:e}),e},get SUPPORT_CORS_IMAGES(){var e=wo();return Object.defineProperty(P,"SUPPORT_CORS_IMAGES",{value:e}),e},get SUPPORT_RESPONSE_TYPE(){var e=co();return Object.defineProperty(P,"SUPPORT_RESPONSE_TYPE",{value:e}),e},get SUPPORT_CORS_XHR(){var e="withCredentials"in new XMLHttpRequest;return Object.defineProperty(P,"SUPPORT_CORS_XHR",{value:e}),e},get SUPPORT_NATIVE_TEXT_SEGMENTATION(){var e=!!(typeof Intl<"u"&&Intl.Segmenter);return Object.defineProperty(P,"SUPPORT_NATIVE_TEXT_SEGMENTATION",{value:e}),e}},we=function(){function e(A,t){this.text=A,this.bounds=t}return e}(),lo=function(e,A,t,r){var B=Fo(A,t),n=[],s=0;return B.forEach(function(i){if(t.textDecorationLine.length||i.trim().length>0)if(P.SUPPORT_RANGE_BOUNDS){var a=IB(r,s,i.length).getClientRects();if(a.length>1){var o=Pr(i),Q=0;o.forEach(function(w){n.push(new we(w,QA.fromDOMRectList(e,IB(r,Q+s,w.length).getClientRects()))),Q+=w.length})}else n.push(new we(i,QA.fromDOMRectList(e,a)))}else{var g=r.splitText(i.length);n.push(new we(i,fo(e,r))),r=g}else P.SUPPORT_RANGE_BOUNDS||(r=r.splitText(i.length));s+=i.length}),n},fo=function(e,A){var t=A.ownerDocument;if(t){var r=t.createElement("html2canvaswrapper");r.appendChild(A.cloneNode(!0));var B=A.parentNode;if(B){B.replaceChild(r,A);var n=Ue(e,r);return r.firstChild&&B.replaceChild(r.firstChild,r),n}}return QA.EMPTY},IB=function(e,A,t){var r=e.ownerDocument;if(!r)throw new Error("Node has no owner document");var B=r.createRange();return B.setStart(e,A),B.setEnd(e,A+t),B},Pr=function(e){if(P.SUPPORT_NATIVE_TEXT_SEGMENTATION){var A=new Intl.Segmenter(void 0,{granularity:"grapheme"});return Array.from(A.segment(e)).map(function(t){return t.segment})}return oo(e)},Uo=function(e,A){if(P.SUPPORT_NATIVE_TEXT_SEGMENTATION){var t=new Intl.Segmenter(void 0,{granularity:"word"});return Array.from(t.segment(e)).map(function(r){return r.segment})}return Eo(e,A)},Fo=function(e,A){return A.letterSpacing!==0?Pr(e):Uo(e,A)},ho=[32,160,4961,65792,65793,4153,4241],Eo=function(e,A){for(var t=Wn(e,{lineBreak:A.lineBreak,wordBreak:A.overflowWrap==="break-word"?"break-word":A.wordBreak}),r=[],B,n=function(){if(B.value){var s=B.value.slice(),i=Fe(s),a="";i.forEach(function(o){ho.indexOf(o)===-1?a+=O(o):(a.length&&r.push(a),r.push(O(o)),a="")}),a.length&&r.push(a)}};!(B=t.next()).done;)n();return r},Ho=function(){function e(A,t,r){this.text=po(t.data,r.textTransform),this.textBounds=lo(A,this.text,r,t)}return e}(),po=function(e,A){switch(A){case 1:return e.toLowerCase();case 3:return e.replace(Io,vo);case 2:return e.toUpperCase();default:return e}},Io=/(^|\s|:|-|\(|\))([a-z])/g,vo=function(e,A,t){return e.length>0?A+t.toUpperCase():e},vB=function(e){L(A,e);function A(t,r){var B=e.call(this,t,r)||this;return B.src=r.currentSrc||r.src,B.intrinsicWidth=r.naturalWidth,B.intrinsicHeight=r.naturalHeight,B.context.cache.addImage(B.src),B}return A}(aA),yB=function(e){L(A,e);function A(t,r){var B=e.call(this,t,r)||this;return B.canvas=r,B.intrinsicWidth=r.width,B.intrinsicHeight=r.height,B}return A}(aA),KB=function(e){L(A,e);function A(t,r){var B=e.call(this,t,r)||this,n=new XMLSerializer,s=Ue(t,r);return r.setAttribute("width",s.width+"px"),r.setAttribute("height",s.height+"px"),B.svg="data:image/svg+xml,"+encodeURIComponent(n.serializeToString(r)),B.intrinsicWidth=r.width.baseVal.value,B.intrinsicHeight=r.height.baseVal.value,B.context.cache.addImage(B.svg),B}return A}(aA),mB=function(e){L(A,e);function A(t,r){var B=e.call(this,t,r)||this;return B.value=r.value,B}return A}(aA),kr=function(e){L(A,e);function A(t,r){var B=e.call(this,t,r)||this;return B.start=r.start,B.reversed=typeof r.reversed=="boolean"&&r.reversed===!0,B}return A}(aA),yo=[{type:15,flags:0,unit:"px",number:3}],Ko=[{type:16,flags:0,number:50}],mo=function(e){return e.width>e.height?new QA(e.left+(e.width-e.height)/2,e.top,e.height,e.height):e.width0)t.textNodes.push(new Ho(e,B,t.styles));else if(XA(B))if(XB(B)&&B.assignedNodes)B.assignedNodes().forEach(function(i){return $e(e,i,t,r)});else{var s=TB(e,B);s.styles.isVisible()&&(xo(B,s,r)?s.flags|=4:To(s.styles)&&(s.flags|=2),bo.indexOf(B.tagName)!==-1&&(s.flags|=8),t.elements.push(s),B.slot,B.shadowRoot?$e(e,B.shadowRoot,s,r):!er(B)&&!MB(B)&&!rr(B)&&$e(e,B,s,r))}},TB=function(e,A){return qr(A)?new vB(e,A):GB(A)?new yB(e,A):MB(A)?new KB(e,A):So(A)?new mB(e,A):Oo(A)?new kr(e,A):Mo(A)?new Yr(e,A):rr(A)?new DB(e,A):er(A)?new bB(e,A):VB(A)?new xB(e,A):new aA(e,A)},SB=function(e,A){var t=TB(e,A);return t.flags|=4,$e(e,A,t,t),t},xo=function(e,A,t){return A.styles.isPositionedWithZIndex()||A.styles.opacity<1||A.styles.isTransformed()||Zr(e)&&t.styles.isTransparent()},To=function(e){return e.isPositioned()||e.isFloating()},OB=function(e){return e.nodeType===Node.TEXT_NODE},XA=function(e){return e.nodeType===Node.ELEMENT_NODE},Wr=function(e){return XA(e)&&typeof e.style<"u"&&!Ar(e)},Ar=function(e){return typeof e.className=="object"},So=function(e){return e.tagName==="LI"},Oo=function(e){return e.tagName==="OL"},Mo=function(e){return e.tagName==="INPUT"},Go=function(e){return e.tagName==="HTML"},MB=function(e){return e.tagName==="svg"},Zr=function(e){return e.tagName==="BODY"},GB=function(e){return e.tagName==="CANVAS"},RB=function(e){return e.tagName==="VIDEO"},qr=function(e){return e.tagName==="IMG"},VB=function(e){return e.tagName==="IFRAME"},NB=function(e){return e.tagName==="STYLE"},Ro=function(e){return e.tagName==="SCRIPT"},er=function(e){return e.tagName==="TEXTAREA"},rr=function(e){return e.tagName==="SELECT"},XB=function(e){return e.tagName==="SLOT"},_B=function(e){return e.tagName.indexOf("-")>0},Vo=function(){function e(){this.counters={}}return e.prototype.getCounterValue=function(A){var t=this.counters[A];return t&&t.length?t[t.length-1]:1},e.prototype.getCounterValues=function(A){var t=this.counters[A];return t||[]},e.prototype.pop=function(A){var t=this;A.forEach(function(r){return t.counters[r].pop()})},e.prototype.parse=function(A){var t=this,r=A.counterIncrement,B=A.counterReset,n=!0;r!==null&&r.forEach(function(i){var a=t.counters[i.counter];a&&i.increment!==0&&(n=!1,a.length||a.push(1),a[Math.max(0,a.length-1)]+=i.increment)});var s=[];return n&&B.forEach(function(i){var a=t.counters[i.counter];s.push(i.counter),a||(a=t.counters[i.counter]=[]),a.push(i.reset)}),s},e}(),JB={integers:[1e3,900,500,400,100,90,50,40,10,9,5,4,1],values:["M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I"]},PB={integers:[9e3,8e3,7e3,6e3,5e3,4e3,3e3,2e3,1e3,900,800,700,600,500,400,300,200,100,90,80,70,60,50,40,30,20,10,9,8,7,6,5,4,3,2,1],values:["\u0554","\u0553","\u0552","\u0551","\u0550","\u054F","\u054E","\u054D","\u054C","\u054B","\u054A","\u0549","\u0548","\u0547","\u0546","\u0545","\u0544","\u0543","\u0542","\u0541","\u0540","\u053F","\u053E","\u053D","\u053C","\u053B","\u053A","\u0539","\u0538","\u0537","\u0536","\u0535","\u0534","\u0533","\u0532","\u0531"]},No={integers:[1e4,9e3,8e3,7e3,6e3,5e3,4e3,3e3,2e3,1e3,400,300,200,100,90,80,70,60,50,40,30,20,19,18,17,16,15,10,9,8,7,6,5,4,3,2,1],values:["\u05D9\u05F3","\u05D8\u05F3","\u05D7\u05F3","\u05D6\u05F3","\u05D5\u05F3","\u05D4\u05F3","\u05D3\u05F3","\u05D2\u05F3","\u05D1\u05F3","\u05D0\u05F3","\u05EA","\u05E9","\u05E8","\u05E7","\u05E6","\u05E4","\u05E2","\u05E1","\u05E0","\u05DE","\u05DC","\u05DB","\u05D9\u05D8","\u05D9\u05D7","\u05D9\u05D6","\u05D8\u05D6","\u05D8\u05D5","\u05D9","\u05D8","\u05D7","\u05D6","\u05D5","\u05D4","\u05D3","\u05D2","\u05D1","\u05D0"]},Xo={integers:[1e4,9e3,8e3,7e3,6e3,5e3,4e3,3e3,2e3,1e3,900,800,700,600,500,400,300,200,100,90,80,70,60,50,40,30,20,10,9,8,7,6,5,4,3,2,1],values:["\u10F5","\u10F0","\u10EF","\u10F4","\u10EE","\u10ED","\u10EC","\u10EB","\u10EA","\u10E9","\u10E8","\u10E7","\u10E6","\u10E5","\u10E4","\u10F3","\u10E2","\u10E1","\u10E0","\u10DF","\u10DE","\u10DD","\u10F2","\u10DC","\u10DB","\u10DA","\u10D9","\u10D8","\u10D7","\u10F1","\u10D6","\u10D5","\u10D4","\u10D3","\u10D2","\u10D1","\u10D0"]},_A=function(e,A,t,r,B,n){return et?Ce(e,B,n.length>0):r.integers.reduce(function(s,i,a){for(;e>=i;)e-=i,s+=r.values[a];return s},"")+n},kB=function(e,A,t,r){var B="";do t||e--,B=r(e)+B,e/=A;while(e*A>=A);return B},M=function(e,A,t,r,B){var n=t-A+1;return(e<0?"-":"")+(kB(Math.abs(e),n,r,function(s){return O(Math.floor(s%n)+A)})+B)},DA=function(e,A,t){t===void 0&&(t=". ");var r=A.length;return kB(Math.abs(e),r,!1,function(B){return A[Math.floor(B%r)]})+t},JA=1,HA=2,pA=4,ce=8,cA=function(e,A,t,r,B,n){if(e<-9999||e>9999)return Ce(e,4,B.length>0);var s=Math.abs(e),i=B;if(s===0)return A[0]+i;for(var a=0;s>0&&a<=4;a++){var o=s%10;o===0&&N(n,JA)&&i!==""?i=A[o]+i:o>1||o===1&&a===0||o===1&&a===1&&N(n,HA)||o===1&&a===1&&N(n,pA)&&e>100||o===1&&a>1&&N(n,ce)?i=A[o]+(a>0?t[a-1]:"")+i:o===1&&a>0&&(i=t[a-1]+i),s=Math.floor(s/10)}return(e<0?r:"")+i},YB="\u5341\u767E\u5343\u842C",WB="\u62FE\u4F70\u4EDF\u842C",ZB="\u30DE\u30A4\u30CA\u30B9",jr="\uB9C8\uC774\uB108\uC2A4",Ce=function(e,A,t){var r=t?". ":"",B=t?"\u3001":"",n=t?", ":"",s=t?" ":"";switch(A){case 0:return"\u2022"+s;case 1:return"\u25E6"+s;case 2:return"\u25FE"+s;case 5:var i=M(e,48,57,!0,r);return i.length<4?"0"+i:i;case 4:return DA(e,"\u3007\u4E00\u4E8C\u4E09\u56DB\u4E94\u516D\u4E03\u516B\u4E5D",B);case 6:return _A(e,1,3999,JB,3,r).toLowerCase();case 7:return _A(e,1,3999,JB,3,r);case 8:return M(e,945,969,!1,r);case 9:return M(e,97,122,!1,r);case 10:return M(e,65,90,!1,r);case 11:return M(e,1632,1641,!0,r);case 12:case 49:return _A(e,1,9999,PB,3,r);case 35:return _A(e,1,9999,PB,3,r).toLowerCase();case 13:return M(e,2534,2543,!0,r);case 14:case 30:return M(e,6112,6121,!0,r);case 15:return DA(e,"\u5B50\u4E11\u5BC5\u536F\u8FB0\u5DF3\u5348\u672A\u7533\u9149\u620C\u4EA5",B);case 16:return DA(e,"\u7532\u4E59\u4E19\u4E01\u620A\u5DF1\u5E9A\u8F9B\u58EC\u7678",B);case 17:case 48:return cA(e,"\u96F6\u4E00\u4E8C\u4E09\u56DB\u4E94\u516D\u4E03\u516B\u4E5D",YB,"\u8CA0",B,HA|pA|ce);case 47:return cA(e,"\u96F6\u58F9\u8CB3\u53C3\u8086\u4F0D\u9678\u67D2\u634C\u7396",WB,"\u8CA0",B,JA|HA|pA|ce);case 42:return cA(e,"\u96F6\u4E00\u4E8C\u4E09\u56DB\u4E94\u516D\u4E03\u516B\u4E5D",YB,"\u8D1F",B,HA|pA|ce);case 41:return cA(e,"\u96F6\u58F9\u8D30\u53C1\u8086\u4F0D\u9646\u67D2\u634C\u7396",WB,"\u8D1F",B,JA|HA|pA|ce);case 26:return cA(e,"\u3007\u4E00\u4E8C\u4E09\u56DB\u4E94\u516D\u4E03\u516B\u4E5D","\u5341\u767E\u5343\u4E07",ZB,B,0);case 25:return cA(e,"\u96F6\u58F1\u5F10\u53C2\u56DB\u4F0D\u516D\u4E03\u516B\u4E5D","\u62FE\u767E\u5343\u4E07",ZB,B,JA|HA|pA);case 31:return cA(e,"\uC601\uC77C\uC774\uC0BC\uC0AC\uC624\uC721\uCE60\uD314\uAD6C","\uC2ED\uBC31\uCC9C\uB9CC",jr,n,JA|HA|pA);case 33:return cA(e,"\u96F6\u4E00\u4E8C\u4E09\u56DB\u4E94\u516D\u4E03\u516B\u4E5D","\u5341\u767E\u5343\u842C",jr,n,0);case 32:return cA(e,"\u96F6\u58F9\u8CB3\u53C3\u56DB\u4E94\u516D\u4E03\u516B\u4E5D","\u62FE\u767E\u5343",jr,n,JA|HA|pA);case 18:return M(e,2406,2415,!0,r);case 20:return _A(e,1,19999,Xo,3,r);case 21:return M(e,2790,2799,!0,r);case 22:return M(e,2662,2671,!0,r);case 22:return _A(e,1,10999,No,3,r);case 23:return DA(e,"\u3042\u3044\u3046\u3048\u304A\u304B\u304D\u304F\u3051\u3053\u3055\u3057\u3059\u305B\u305D\u305F\u3061\u3064\u3066\u3068\u306A\u306B\u306C\u306D\u306E\u306F\u3072\u3075\u3078\u307B\u307E\u307F\u3080\u3081\u3082\u3084\u3086\u3088\u3089\u308A\u308B\u308C\u308D\u308F\u3090\u3091\u3092\u3093");case 24:return DA(e,"\u3044\u308D\u306F\u306B\u307B\u3078\u3068\u3061\u308A\u306C\u308B\u3092\u308F\u304B\u3088\u305F\u308C\u305D\u3064\u306D\u306A\u3089\u3080\u3046\u3090\u306E\u304A\u304F\u3084\u307E\u3051\u3075\u3053\u3048\u3066\u3042\u3055\u304D\u3086\u3081\u307F\u3057\u3091\u3072\u3082\u305B\u3059");case 27:return M(e,3302,3311,!0,r);case 28:return DA(e,"\u30A2\u30A4\u30A6\u30A8\u30AA\u30AB\u30AD\u30AF\u30B1\u30B3\u30B5\u30B7\u30B9\u30BB\u30BD\u30BF\u30C1\u30C4\u30C6\u30C8\u30CA\u30CB\u30CC\u30CD\u30CE\u30CF\u30D2\u30D5\u30D8\u30DB\u30DE\u30DF\u30E0\u30E1\u30E2\u30E4\u30E6\u30E8\u30E9\u30EA\u30EB\u30EC\u30ED\u30EF\u30F0\u30F1\u30F2\u30F3",B);case 29:return DA(e,"\u30A4\u30ED\u30CF\u30CB\u30DB\u30D8\u30C8\u30C1\u30EA\u30CC\u30EB\u30F2\u30EF\u30AB\u30E8\u30BF\u30EC\u30BD\u30C4\u30CD\u30CA\u30E9\u30E0\u30A6\u30F0\u30CE\u30AA\u30AF\u30E4\u30DE\u30B1\u30D5\u30B3\u30A8\u30C6\u30A2\u30B5\u30AD\u30E6\u30E1\u30DF\u30B7\u30F1\u30D2\u30E2\u30BB\u30B9",B);case 34:return M(e,3792,3801,!0,r);case 37:return M(e,6160,6169,!0,r);case 38:return M(e,4160,4169,!0,r);case 39:return M(e,2918,2927,!0,r);case 40:return M(e,1776,1785,!0,r);case 43:return M(e,3046,3055,!0,r);case 44:return M(e,3174,3183,!0,r);case 45:return M(e,3664,3673,!0,r);case 46:return M(e,3872,3881,!0,r);case 3:default:return M(e,48,57,!0,r)}},qB="data-html2canvas-ignore",jB=function(){function e(A,t,r){if(this.context=A,this.options=r,this.scrolledElements=[],this.referenceElement=t,this.counters=new Vo,this.quoteDepth=0,!t.ownerDocument)throw new Error("Cloned element does not have an owner document");this.documentElement=this.cloneNode(t.ownerDocument.documentElement,!1)}return e.prototype.toIFrame=function(A,t){var r=this,B=_o(A,t);if(!B.contentWindow)return Promise.reject("Unable to find iframe window");var n=A.defaultView.pageXOffset,s=A.defaultView.pageYOffset,i=B.contentWindow,a=i.document,o=ko(B).then(function(){return X(r,void 0,void 0,function(){var Q,g;return S(this,function(w){switch(w.label){case 0:return this.scrolledElements.forEach(qo),i&&(i.scrollTo(t.left,t.top),/(iPad|iPhone|iPod)/g.test(navigator.userAgent)&&(i.scrollY!==t.top||i.scrollX!==t.left)&&(this.context.logger.warn("Unable to restore scroll position for cloned document"),this.context.windowBounds=this.context.windowBounds.add(i.scrollX-t.left,i.scrollY-t.top,0,0))),Q=this.options.onclone,g=this.clonedReferenceElement,typeof g>"u"?[2,Promise.reject("Error finding the "+this.referenceElement.nodeName+" in the cloned document")]:a.fonts&&a.fonts.ready?[4,a.fonts.ready]:[3,2];case 1:w.sent(),w.label=2;case 2:return/(AppleWebKit)/g.test(navigator.userAgent)?[4,Po(a)]:[3,4];case 3:w.sent(),w.label=4;case 4:return typeof Q=="function"?[2,Promise.resolve().then(function(){return Q(a,g)}).then(function(){return B})]:[2,B]}})})});return a.open(),a.write(Wo(document.doctype)+""),Zo(this.referenceElement.ownerDocument,n,s),a.replaceChild(a.adoptNode(this.documentElement),a.documentElement),a.close(),o},e.prototype.createElementClone=function(A){if(Tr(A,2))debugger;if(GB(A))return this.createCanvasClone(A);if(RB(A))return this.createVideoClone(A);if(NB(A))return this.createStyleClone(A);var t=A.cloneNode(!1);return qr(t)&&(qr(A)&&A.currentSrc&&A.currentSrc!==A.src&&(t.src=A.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager")),_B(t)?this.createCustomElementClone(t):t},e.prototype.createCustomElementClone=function(A){var t=document.createElement("html2canvascustomelement");return zr(A.style,t),t},e.prototype.createStyleClone=function(A){try{var t=A.sheet;if(t&&t.cssRules){var r=[].slice.call(t.cssRules,0).reduce(function(n,s){return s&&typeof s.cssText=="string"?n+s.cssText:n},""),B=A.cloneNode(!1);return B.textContent=r,B}}catch(n){if(this.context.logger.error("Unable to access cssRules property",n),n.name!=="SecurityError")throw n}return A.cloneNode(!1)},e.prototype.createCanvasClone=function(A){var t;if(this.options.inlineImages&&A.ownerDocument){var r=A.ownerDocument.createElement("img");try{return r.src=A.toDataURL(),r}catch{this.context.logger.info("Unable to inline canvas contents, canvas is tainted",A)}}var B=A.cloneNode(!1);try{B.width=A.width,B.height=A.height;var n=A.getContext("2d"),s=B.getContext("2d");if(s)if(!this.options.allowTaint&&n)s.putImageData(n.getImageData(0,0,A.width,A.height),0,0);else{var i=(t=A.getContext("webgl2"))!==null&&t!==void 0?t:A.getContext("webgl");if(i){var a=i.getContextAttributes();a?.preserveDrawingBuffer===!1&&this.context.logger.warn("Unable to clone WebGL context as it has preserveDrawingBuffer=false",A)}s.drawImage(A,0,0)}return B}catch{this.context.logger.info("Unable to clone canvas as it is tainted",A)}return B},e.prototype.createVideoClone=function(A){var t=A.ownerDocument.createElement("canvas");t.width=A.offsetWidth,t.height=A.offsetHeight;var r=t.getContext("2d");try{return r&&(r.drawImage(A,0,0,t.width,t.height),this.options.allowTaint||r.getImageData(0,0,t.width,t.height)),t}catch{this.context.logger.info("Unable to clone video as it is tainted",A)}var B=A.ownerDocument.createElement("canvas");return B.width=A.offsetWidth,B.height=A.offsetHeight,B},e.prototype.appendChildNode=function(A,t,r){(!XA(t)||!Ro(t)&&!t.hasAttribute(qB)&&(typeof this.options.ignoreElements!="function"||!this.options.ignoreElements(t)))&&(!this.options.copyStyles||!XA(t)||!NB(t))&&A.appendChild(this.cloneNode(t,r))},e.prototype.cloneChildNodes=function(A,t,r){for(var B=this,n=A.shadowRoot?A.shadowRoot.firstChild:A.firstChild;n;n=n.nextSibling)if(XA(n)&&XB(n)&&typeof n.assignedNodes=="function"){var s=n.assignedNodes();s.length&&s.forEach(function(i){return B.appendChildNode(t,i,r)})}else this.appendChildNode(t,n,r)},e.prototype.cloneNode=function(A,t){if(OB(A))return document.createTextNode(A.data);if(!A.ownerDocument)return A.cloneNode(!1);var r=A.ownerDocument.defaultView;if(r&&XA(A)&&(Wr(A)||Ar(A))){var B=this.createElementClone(A);B.style.transitionProperty="none";var n=r.getComputedStyle(A),s=r.getComputedStyle(A,":before"),i=r.getComputedStyle(A,":after");this.referenceElement===A&&Wr(B)&&(this.clonedReferenceElement=B),Zr(B)&&$o(B);var a=this.counters.parse(new gB(this.context,n)),o=this.resolvePseudoContent(A,B,s,ue.BEFORE);_B(A)&&(t=!0),RB(A)||this.cloneChildNodes(A,B,t),o&&B.insertBefore(o,B.firstChild);var Q=this.resolvePseudoContent(A,B,i,ue.AFTER);return Q&&B.appendChild(Q),this.counters.pop(a),(n&&(this.options.copyStyles||Ar(A))&&!VB(A)||t)&&zr(n,B),(A.scrollTop!==0||A.scrollLeft!==0)&&this.scrolledElements.push([B,A.scrollLeft,A.scrollTop]),(er(A)||rr(A))&&(er(B)||rr(B))&&(B.value=A.value),B}return A.cloneNode(!1)},e.prototype.resolvePseudoContent=function(A,t,r,B){var n=this;if(r){var s=r.content,i=t.ownerDocument;if(!(!i||!s||s==="none"||s==="-moz-alt-content"||r.display==="none")){this.counters.parse(new gB(this.context,r));var a=new Ti(this.context,r),o=i.createElement("html2canvaspseudoelement");zr(r,o),a.content.forEach(function(g){if(g.type===0)o.appendChild(i.createTextNode(g.value));else if(g.type===22){var w=i.createElement("img");w.src=g.value,w.style.opacity="1",o.appendChild(w)}else if(g.type===18){if(g.name==="attr"){var f=g.values.filter(D);f.length&&o.appendChild(i.createTextNode(A.getAttribute(f[0].value)||""))}else if(g.name==="counter"){var c=g.values.filter(VA),C=c[0],H=c[1];if(C&&D(C)){var h=n.counters.getCounterValue(C.value),F=H&&D(H)?xr.parse(n.context,H.value):3;o.appendChild(i.createTextNode(Ce(h,F,!1)))}}else if(g.name==="counters"){var K=g.values.filter(VA),C=K[0],p=K[1],H=K[2];if(C&&D(C)){var d=n.counters.getCounterValues(C.value),l=H&&D(H)?xr.parse(n.context,H.value):3,v=p&&p.type===0?p.value:"",y=d.map(function(Z){return Ce(Z,l,!1)}).join(v);o.appendChild(i.createTextNode(y))}}}else if(g.type===20)switch(g.value){case"open-quote":o.appendChild(i.createTextNode(QB(a.quotes,n.quoteDepth++,!0)));break;case"close-quote":o.appendChild(i.createTextNode(QB(a.quotes,--n.quoteDepth,!1)));break;default:o.appendChild(i.createTextNode(g.value))}}),o.className=$r+" "+At;var Q=B===ue.BEFORE?" "+$r:" "+At;return Ar(t)?t.className.baseValue+=Q:t.className+=Q,o}}},e.destroy=function(A){return A.parentNode?(A.parentNode.removeChild(A),!0):!1},e}(),ue;(function(e){e[e.BEFORE=0]="BEFORE",e[e.AFTER=1]="AFTER"})(ue||(ue={}));var _o=function(e,A){var t=e.createElement("iframe");return t.className="html2canvas-container",t.style.visibility="hidden",t.style.position="fixed",t.style.left="-10000px",t.style.top="0px",t.style.border="0",t.width=A.width.toString(),t.height=A.height.toString(),t.scrolling="no",t.setAttribute(qB,"true"),e.body.appendChild(t),t},Jo=function(e){return new Promise(function(A){if(e.complete){A();return}if(!e.src){A();return}e.onload=A,e.onerror=A})},Po=function(e){return Promise.all([].slice.call(e.images,0).map(Jo))},ko=function(e){return new Promise(function(A,t){var r=e.contentWindow;if(!r)return t("No window assigned for iframe");var B=r.document;r.onload=e.onload=function(){r.onload=e.onload=null;var n=setInterval(function(){B.body.childNodes.length>0&&B.readyState==="complete"&&(clearInterval(n),A(e))},50)}})},Yo=["all","d","content"],zr=function(e,A){for(var t=e.length-1;t>=0;t--){var r=e.item(t);Yo.indexOf(r)===-1&&A.style.setProperty(r,e.getPropertyValue(r))}return A},Wo=function(e){var A="";return e&&(A+=""),A},Zo=function(e,A,t){e&&e.defaultView&&(A!==e.defaultView.pageXOffset||t!==e.defaultView.pageYOffset)&&e.defaultView.scrollTo(A,t)},qo=function(e){var A=e[0],t=e[1],r=e[2];A.scrollLeft=t,A.scrollTop=r},jo=":before",zo=":after",$r="___html2canvas___pseudoelement_before",At="___html2canvas___pseudoelement_after",zB=`{ + content: "" !important; + display: none !important; +}`,$o=function(e){AQ(e,"."+$r+jo+zB+` + .`+At+zo+zB)},AQ=function(e,A){var t=e.ownerDocument;if(t){var r=t.createElement("style");r.textContent=A,e.appendChild(r)}},$B=function(){function e(){}return e.getOrigin=function(A){var t=e._link;return t?(t.href=A,t.href=t.href,t.protocol+t.hostname+t.port):"about:blank"},e.isSameOrigin=function(A){return e.getOrigin(A)===e._origin},e.setContext=function(A){e._link=A.document.createElement("a"),e._origin=e.getOrigin(A.location.href)},e._origin="about:blank",e}(),eQ=function(){function e(A,t){this.context=A,this._options=t,this._cache={}}return e.prototype.addImage=function(A){var t=Promise.resolve();return this.has(A)||(rt(A)||nQ(A))&&(this._cache[A]=this.loadImage(A)).catch(function(){}),t},e.prototype.match=function(A){return this._cache[A]},e.prototype.loadImage=function(A){return X(this,void 0,void 0,function(){var t,r,B,n,s=this;return S(this,function(i){switch(i.label){case 0:return t=$B.isSameOrigin(A),r=!et(A)&&this._options.useCORS===!0&&P.SUPPORT_CORS_IMAGES&&!t,B=!et(A)&&!t&&!rt(A)&&typeof this._options.proxy=="string"&&P.SUPPORT_CORS_XHR&&!r,!t&&this._options.allowTaint===!1&&!et(A)&&!rt(A)&&!B&&!r?[2]:(n=A,B?[4,this.proxy(n)]:[3,2]);case 1:n=i.sent(),i.label=2;case 2:return this.context.logger.debug("Added image "+A.substring(0,256)),[4,new Promise(function(a,o){var Q=new Image;Q.onload=function(){return a(Q)},Q.onerror=o,(sQ(n)||r)&&(Q.crossOrigin="anonymous"),Q.src=n,Q.complete===!0&&setTimeout(function(){return a(Q)},500),s._options.imageTimeout>0&&setTimeout(function(){return o("Timed out ("+s._options.imageTimeout+"ms) loading image")},s._options.imageTimeout)})];case 3:return[2,i.sent()]}})})},e.prototype.has=function(A){return typeof this._cache[A]<"u"},e.prototype.keys=function(){return Promise.resolve(Object.keys(this._cache))},e.prototype.proxy=function(A){var t=this,r=this._options.proxy;if(!r)throw new Error("No proxy defined");var B=A.substring(0,256);return new Promise(function(n,s){var i=P.SUPPORT_RESPONSE_TYPE?"blob":"text",a=new XMLHttpRequest;a.onload=function(){if(a.status===200)if(i==="text")n(a.response);else{var g=new FileReader;g.addEventListener("load",function(){return n(g.result)},!1),g.addEventListener("error",function(w){return s(w)},!1),g.readAsDataURL(a.response)}else s("Failed to proxy resource "+B+" with status code "+a.status)},a.onerror=s;var o=r.indexOf("?")>-1?"&":"?";if(a.open("GET",""+r+o+"url="+encodeURIComponent(A)+"&responseType="+i),i!=="text"&&a instanceof XMLHttpRequest&&(a.responseType=i),t._options.imageTimeout){var Q=t._options.imageTimeout;a.timeout=Q,a.ontimeout=function(){return s("Timed out ("+Q+"ms) proxying "+B)}}a.send()})},e}(),rQ=/^data:image\/svg\+xml/i,tQ=/^data:image\/.*;base64,/i,BQ=/^data:image\/.*/i,nQ=function(e){return P.SUPPORT_SVG_DRAWING||!aQ(e)},et=function(e){return BQ.test(e)},sQ=function(e){return tQ.test(e)},rt=function(e){return e.substr(0,4)==="blob"},aQ=function(e){return e.substr(-3).toLowerCase()==="svg"||rQ.test(e)},u=function(){function e(A,t){this.type=0,this.x=A,this.y=t}return e.prototype.add=function(A,t){return new e(this.x+A,this.y+t)},e}(),PA=function(e,A,t){return new u(e.x+(A.x-e.x)*t,e.y+(A.y-e.y)*t)},tr=function(){function e(A,t,r,B){this.type=1,this.start=A,this.startControl=t,this.endControl=r,this.end=B}return e.prototype.subdivide=function(A,t){var r=PA(this.start,this.startControl,A),B=PA(this.startControl,this.endControl,A),n=PA(this.endControl,this.end,A),s=PA(r,B,A),i=PA(B,n,A),a=PA(s,i,A);return t?new e(this.start,r,s,a):new e(a,i,n,this.end)},e.prototype.add=function(A,t){return new e(this.start.add(A,t),this.startControl.add(A,t),this.endControl.add(A,t),this.end.add(A,t))},e.prototype.reverse=function(){return new e(this.end,this.endControl,this.startControl,this.start)},e}(),rA=function(e){return e.type===1},iQ=function(){function e(A){var t=A.styles,r=A.bounds,B=ie(t.borderTopLeftRadius,r.width,r.height),n=B[0],s=B[1],i=ie(t.borderTopRightRadius,r.width,r.height),a=i[0],o=i[1],Q=ie(t.borderBottomRightRadius,r.width,r.height),g=Q[0],w=Q[1],f=ie(t.borderBottomLeftRadius,r.width,r.height),c=f[0],C=f[1],H=[];H.push((n+a)/r.width),H.push((c+g)/r.width),H.push((s+C)/r.height),H.push((o+w)/r.height);var h=Math.max.apply(Math,H);h>1&&(n/=h,s/=h,a/=h,o/=h,g/=h,w/=h,c/=h,C/=h);var F=r.width-a,K=r.height-w,p=r.width-g,d=r.height-C,l=t.borderTopWidth,v=t.borderRightWidth,y=t.borderBottomWidth,E=t.borderLeftWidth,V=x(t.paddingTop,A.bounds.width),Z=x(t.paddingRight,A.bounds.width),$=x(t.paddingBottom,A.bounds.width),b=x(t.paddingLeft,A.bounds.width);this.topLeftBorderDoubleOuterBox=n>0||s>0?T(r.left+E/3,r.top+l/3,n-E/3,s-l/3,m.TOP_LEFT):new u(r.left+E/3,r.top+l/3),this.topRightBorderDoubleOuterBox=n>0||s>0?T(r.left+F,r.top+l/3,a-v/3,o-l/3,m.TOP_RIGHT):new u(r.left+r.width-v/3,r.top+l/3),this.bottomRightBorderDoubleOuterBox=g>0||w>0?T(r.left+p,r.top+K,g-v/3,w-y/3,m.BOTTOM_RIGHT):new u(r.left+r.width-v/3,r.top+r.height-y/3),this.bottomLeftBorderDoubleOuterBox=c>0||C>0?T(r.left+E/3,r.top+d,c-E/3,C-y/3,m.BOTTOM_LEFT):new u(r.left+E/3,r.top+r.height-y/3),this.topLeftBorderDoubleInnerBox=n>0||s>0?T(r.left+E*2/3,r.top+l*2/3,n-E*2/3,s-l*2/3,m.TOP_LEFT):new u(r.left+E*2/3,r.top+l*2/3),this.topRightBorderDoubleInnerBox=n>0||s>0?T(r.left+F,r.top+l*2/3,a-v*2/3,o-l*2/3,m.TOP_RIGHT):new u(r.left+r.width-v*2/3,r.top+l*2/3),this.bottomRightBorderDoubleInnerBox=g>0||w>0?T(r.left+p,r.top+K,g-v*2/3,w-y*2/3,m.BOTTOM_RIGHT):new u(r.left+r.width-v*2/3,r.top+r.height-y*2/3),this.bottomLeftBorderDoubleInnerBox=c>0||C>0?T(r.left+E*2/3,r.top+d,c-E*2/3,C-y*2/3,m.BOTTOM_LEFT):new u(r.left+E*2/3,r.top+r.height-y*2/3),this.topLeftBorderStroke=n>0||s>0?T(r.left+E/2,r.top+l/2,n-E/2,s-l/2,m.TOP_LEFT):new u(r.left+E/2,r.top+l/2),this.topRightBorderStroke=n>0||s>0?T(r.left+F,r.top+l/2,a-v/2,o-l/2,m.TOP_RIGHT):new u(r.left+r.width-v/2,r.top+l/2),this.bottomRightBorderStroke=g>0||w>0?T(r.left+p,r.top+K,g-v/2,w-y/2,m.BOTTOM_RIGHT):new u(r.left+r.width-v/2,r.top+r.height-y/2),this.bottomLeftBorderStroke=c>0||C>0?T(r.left+E/2,r.top+d,c-E/2,C-y/2,m.BOTTOM_LEFT):new u(r.left+E/2,r.top+r.height-y/2),this.topLeftBorderBox=n>0||s>0?T(r.left,r.top,n,s,m.TOP_LEFT):new u(r.left,r.top),this.topRightBorderBox=a>0||o>0?T(r.left+F,r.top,a,o,m.TOP_RIGHT):new u(r.left+r.width,r.top),this.bottomRightBorderBox=g>0||w>0?T(r.left+p,r.top+K,g,w,m.BOTTOM_RIGHT):new u(r.left+r.width,r.top+r.height),this.bottomLeftBorderBox=c>0||C>0?T(r.left,r.top+d,c,C,m.BOTTOM_LEFT):new u(r.left,r.top+r.height),this.topLeftPaddingBox=n>0||s>0?T(r.left+E,r.top+l,Math.max(0,n-E),Math.max(0,s-l),m.TOP_LEFT):new u(r.left+E,r.top+l),this.topRightPaddingBox=a>0||o>0?T(r.left+Math.min(F,r.width-v),r.top+l,F>r.width+v?0:Math.max(0,a-v),Math.max(0,o-l),m.TOP_RIGHT):new u(r.left+r.width-v,r.top+l),this.bottomRightPaddingBox=g>0||w>0?T(r.left+Math.min(p,r.width-E),r.top+Math.min(K,r.height-y),Math.max(0,g-v),Math.max(0,w-y),m.BOTTOM_RIGHT):new u(r.left+r.width-v,r.top+r.height-y),this.bottomLeftPaddingBox=c>0||C>0?T(r.left+E,r.top+Math.min(d,r.height-y),Math.max(0,c-E),Math.max(0,C-y),m.BOTTOM_LEFT):new u(r.left+E,r.top+r.height-y),this.topLeftContentBox=n>0||s>0?T(r.left+E+b,r.top+l+V,Math.max(0,n-(E+b)),Math.max(0,s-(l+V)),m.TOP_LEFT):new u(r.left+E+b,r.top+l+V),this.topRightContentBox=a>0||o>0?T(r.left+Math.min(F,r.width+E+b),r.top+l+V,F>r.width+E+b?0:a-E+b,o-(l+V),m.TOP_RIGHT):new u(r.left+r.width-(v+Z),r.top+l+V),this.bottomRightContentBox=g>0||w>0?T(r.left+Math.min(p,r.width-(E+b)),r.top+Math.min(K,r.height+l+V),Math.max(0,g-(v+Z)),w-(y+$),m.BOTTOM_RIGHT):new u(r.left+r.width-(v+Z),r.top+r.height-(y+$)),this.bottomLeftContentBox=c>0||C>0?T(r.left+E+b,r.top+d,Math.max(0,c-(E+b)),C-(y+$),m.BOTTOM_LEFT):new u(r.left+E+b,r.top+r.height-(y+$))}return e}(),m;(function(e){e[e.TOP_LEFT=0]="TOP_LEFT",e[e.TOP_RIGHT=1]="TOP_RIGHT",e[e.BOTTOM_RIGHT=2]="BOTTOM_RIGHT",e[e.BOTTOM_LEFT=3]="BOTTOM_LEFT"})(m||(m={}));var T=function(e,A,t,r,B){var n=4*((Math.sqrt(2)-1)/3),s=t*n,i=r*n,a=e+t,o=A+r;switch(B){case m.TOP_LEFT:return new tr(new u(e,o),new u(e,o-i),new u(a-s,A),new u(a,A));case m.TOP_RIGHT:return new tr(new u(e,A),new u(e+s,A),new u(a,o-i),new u(a,o));case m.BOTTOM_RIGHT:return new tr(new u(a,A),new u(a,A+i),new u(e+s,o),new u(e,o));case m.BOTTOM_LEFT:default:return new tr(new u(a,o),new u(a-s,o),new u(e,A+i),new u(e,A))}},Br=function(e){return[e.topLeftBorderBox,e.topRightBorderBox,e.bottomRightBorderBox,e.bottomLeftBorderBox]},oQ=function(e){return[e.topLeftContentBox,e.topRightContentBox,e.bottomRightContentBox,e.bottomLeftContentBox]},nr=function(e){return[e.topLeftPaddingBox,e.topRightPaddingBox,e.bottomRightPaddingBox,e.bottomLeftPaddingBox]},QQ=function(){function e(A,t,r){this.offsetX=A,this.offsetY=t,this.matrix=r,this.type=0,this.target=6}return e}(),sr=function(){function e(A,t){this.path=A,this.target=t,this.type=1}return e}(),gQ=function(){function e(A){this.opacity=A,this.type=2,this.target=6}return e}(),wQ=function(e){return e.type===0},An=function(e){return e.type===1},cQ=function(e){return e.type===2},en=function(e,A){return e.length===A.length?e.some(function(t,r){return t===A[r]}):!1},CQ=function(e,A,t,r,B){return e.map(function(n,s){switch(s){case 0:return n.add(A,t);case 1:return n.add(A+r,t);case 2:return n.add(A+r,t+B);case 3:return n.add(A,t+B)}return n})},rn=function(){function e(A){this.element=A,this.inlineLevel=[],this.nonInlineLevel=[],this.negativeZIndex=[],this.zeroOrAutoZIndexOrTransformedOrOpacity=[],this.positiveZIndex=[],this.nonPositionedFloats=[],this.nonPositionedInlineLevel=[]}return e}(),tn=function(){function e(A,t){if(this.container=A,this.parent=t,this.effects=[],this.curves=new iQ(this.container),this.container.styles.opacity<1&&this.effects.push(new gQ(this.container.styles.opacity)),this.container.styles.transform!==null){var r=this.container.bounds.left+this.container.styles.transformOrigin[0].number,B=this.container.bounds.top+this.container.styles.transformOrigin[1].number,n=this.container.styles.transform;this.effects.push(new QQ(r,B,n))}if(this.container.styles.overflowX!==0){var s=Br(this.curves),i=nr(this.curves);en(s,i)?this.effects.push(new sr(s,6)):(this.effects.push(new sr(s,2)),this.effects.push(new sr(i,4)))}}return e.prototype.getEffects=function(A){for(var t=[2,3].indexOf(this.container.styles.position)===-1,r=this.parent,B=this.effects.slice(0);r;){var n=r.effects.filter(function(a){return!An(a)});if(t||r.container.styles.position!==0||!r.parent){if(B.unshift.apply(B,n),t=[2,3].indexOf(r.container.styles.position)===-1,r.container.styles.overflowX!==0){var s=Br(r.curves),i=nr(r.curves);en(s,i)||B.unshift(new sr(i,6))}}else B.unshift.apply(B,n);r=r.parent}return B.filter(function(a){return N(a.target,A)})},e}(),tt=function(e,A,t,r){e.container.elements.forEach(function(B){var n=N(B.flags,4),s=N(B.flags,2),i=new tn(B,e);N(B.styles.display,2048)&&r.push(i);var a=N(B.flags,8)?[]:r;if(n||s){var o=n||B.styles.isPositioned()?t:A,Q=new rn(i);if(B.styles.isPositioned()||B.styles.opacity<1||B.styles.isTransformed()){var g=B.styles.zIndex.order;if(g<0){var w=0;o.negativeZIndex.some(function(c,C){return g>c.element.container.styles.zIndex.order?(w=C,!1):w>0}),o.negativeZIndex.splice(w,0,Q)}else if(g>0){var f=0;o.positiveZIndex.some(function(c,C){return g>=c.element.container.styles.zIndex.order?(f=C+1,!1):f>0}),o.positiveZIndex.splice(f,0,Q)}else o.zeroOrAutoZIndexOrTransformedOrOpacity.push(Q)}else B.styles.isFloating()?o.nonPositionedFloats.push(Q):o.nonPositionedInlineLevel.push(Q);tt(i,Q,n?Q:t,a)}else B.styles.isInlineLevel()?A.inlineLevel.push(i):A.nonInlineLevel.push(i),tt(i,A,t,a);N(B.flags,8)&&Bn(B,a)})},Bn=function(e,A){for(var t=e instanceof kr?e.start:1,r=e instanceof kr?e.reversed:!1,B=0;B"u"?e[0]:t},EQ=function(e,A,t,r,B){var n=A[0],s=A[1],i=t[0],a=t[1];switch(e){case 2:return[new u(Math.round(r.left),Math.round(r.top+s)),new u(Math.round(r.left+r.width),Math.round(r.top+s)),new u(Math.round(r.left+r.width),Math.round(a+r.top+s)),new u(Math.round(r.left),Math.round(a+r.top+s))];case 3:return[new u(Math.round(r.left+n),Math.round(r.top)),new u(Math.round(r.left+n+i),Math.round(r.top)),new u(Math.round(r.left+n+i),Math.round(r.height+r.top)),new u(Math.round(r.left+n),Math.round(r.height+r.top))];case 1:return[new u(Math.round(r.left+n),Math.round(r.top+s)),new u(Math.round(r.left+n+i),Math.round(r.top+s)),new u(Math.round(r.left+n+i),Math.round(r.top+s+a)),new u(Math.round(r.left+n),Math.round(r.top+s+a))];default:return[new u(Math.round(B.left),Math.round(B.top)),new u(Math.round(B.left+B.width),Math.round(B.top)),new u(Math.round(B.left+B.width),Math.round(B.height+B.top)),new u(Math.round(B.left),Math.round(B.height+B.top))]}},HQ="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",an="Hidden Text",pQ=function(){function e(A){this._data={},this._document=A}return e.prototype.parseMetrics=function(A,t){var r=this._document.createElement("div"),B=this._document.createElement("img"),n=this._document.createElement("span"),s=this._document.body;r.style.visibility="hidden",r.style.fontFamily=A,r.style.fontSize=t,r.style.margin="0",r.style.padding="0",r.style.whiteSpace="nowrap",s.appendChild(r),B.src=HQ,B.width=1,B.height=1,B.style.margin="0",B.style.padding="0",B.style.verticalAlign="baseline",n.style.fontFamily=A,n.style.fontSize=t,n.style.margin="0",n.style.padding="0",n.appendChild(this._document.createTextNode(an)),r.appendChild(n),r.appendChild(B);var i=B.offsetTop-n.offsetTop+2;r.removeChild(n),r.appendChild(this._document.createTextNode(an)),r.style.lineHeight="normal",B.style.verticalAlign="super";var a=B.offsetTop-r.offsetTop+2;return s.removeChild(r),{baseline:i,middle:a}},e.prototype.getMetrics=function(A,t){var r=A+" "+t;return typeof this._data[r]>"u"&&(this._data[r]=this.parseMetrics(A,t)),this._data[r]},e}(),on=function(){function e(A,t){this.context=A,this.options=t}return e}(),IQ=1e4,vQ=function(e){L(A,e);function A(t,r){var B=e.call(this,t,r)||this;return B._activeEffects=[],B.canvas=r.canvas?r.canvas:document.createElement("canvas"),B.ctx=B.canvas.getContext("2d"),r.canvas||(B.canvas.width=Math.floor(r.width*r.scale),B.canvas.height=Math.floor(r.height*r.scale),B.canvas.style.width=r.width+"px",B.canvas.style.height=r.height+"px"),B.fontMetrics=new pQ(document),B.ctx.scale(B.options.scale,B.options.scale),B.ctx.translate(-r.x,-r.y),B.ctx.textBaseline="bottom",B._activeEffects=[],B.context.logger.debug("Canvas renderer initialized ("+r.width+"x"+r.height+") with scale "+r.scale),B}return A.prototype.applyEffects=function(t){for(var r=this;this._activeEffects.length;)this.popEffect();t.forEach(function(B){return r.applyEffect(B)})},A.prototype.applyEffect=function(t){this.ctx.save(),cQ(t)&&(this.ctx.globalAlpha=t.opacity),wQ(t)&&(this.ctx.translate(t.offsetX,t.offsetY),this.ctx.transform(t.matrix[0],t.matrix[1],t.matrix[2],t.matrix[3],t.matrix[4],t.matrix[5]),this.ctx.translate(-t.offsetX,-t.offsetY)),An(t)&&(this.path(t.path),this.ctx.clip()),this._activeEffects.push(t)},A.prototype.popEffect=function(){this._activeEffects.pop(),this.ctx.restore()},A.prototype.renderStack=function(t){return X(this,void 0,void 0,function(){var r;return S(this,function(B){switch(B.label){case 0:return r=t.element.container.styles,r.isVisible()?[4,this.renderStackContent(t)]:[3,2];case 1:B.sent(),B.label=2;case 2:return[2]}})})},A.prototype.renderNode=function(t){return X(this,void 0,void 0,function(){return S(this,function(r){switch(r.label){case 0:if(N(t.container.flags,16))debugger;return t.container.styles.isVisible()?[4,this.renderNodeBackgroundAndBorders(t)]:[3,3];case 1:return r.sent(),[4,this.renderNodeContent(t)];case 2:r.sent(),r.label=3;case 3:return[2]}})})},A.prototype.renderTextWithLetterSpacing=function(t,r,B){var n=this;if(r===0)this.ctx.fillText(t.text,t.bounds.left,t.bounds.top+B);else{var s=Pr(t.text);s.reduce(function(i,a){return n.ctx.fillText(a,i,t.bounds.top+B),i+n.ctx.measureText(a).width},t.bounds.left)}},A.prototype.createFontStyle=function(t){var r=t.fontVariant.filter(function(s){return s==="normal"||s==="small-caps"}).join(""),B=DQ(t.fontFamily).join(", "),n=ae(t.fontSize)?""+t.fontSize.number+t.fontSize.unit:t.fontSize.number+"px";return[[t.fontStyle,r,t.fontWeight,n,B].join(" "),B,n]},A.prototype.renderTextNode=function(t,r){return X(this,void 0,void 0,function(){var B,n,s,i,a,o,Q,g,w=this;return S(this,function(f){return B=this.createFontStyle(r),n=B[0],s=B[1],i=B[2],this.ctx.font=n,this.ctx.direction=r.direction===1?"rtl":"ltr",this.ctx.textAlign="left",this.ctx.textBaseline="alphabetic",a=this.fontMetrics.getMetrics(s,i),o=a.baseline,Q=a.middle,g=r.paintOrder,t.textBounds.forEach(function(c){g.forEach(function(C){switch(C){case 0:w.ctx.fillStyle=_(r.color),w.renderTextWithLetterSpacing(c,r.letterSpacing,o);var H=r.textShadow;H.length&&c.text.trim().length&&(H.slice(0).reverse().forEach(function(h){w.ctx.shadowColor=_(h.color),w.ctx.shadowOffsetX=h.offsetX.number*w.options.scale,w.ctx.shadowOffsetY=h.offsetY.number*w.options.scale,w.ctx.shadowBlur=h.blur.number,w.renderTextWithLetterSpacing(c,r.letterSpacing,o)}),w.ctx.shadowColor="",w.ctx.shadowOffsetX=0,w.ctx.shadowOffsetY=0,w.ctx.shadowBlur=0),r.textDecorationLine.length&&(w.ctx.fillStyle=_(r.textDecorationColor||r.color),r.textDecorationLine.forEach(function(h){switch(h){case 1:w.ctx.fillRect(c.bounds.left,Math.round(c.bounds.top+o),c.bounds.width,1);break;case 2:w.ctx.fillRect(c.bounds.left,Math.round(c.bounds.top),c.bounds.width,1);break;case 3:w.ctx.fillRect(c.bounds.left,Math.ceil(c.bounds.top+Q),c.bounds.width,1);break}}));break;case 1:r.webkitTextStrokeWidth&&c.text.trim().length&&(w.ctx.strokeStyle=_(r.webkitTextStrokeColor),w.ctx.lineWidth=r.webkitTextStrokeWidth,w.ctx.lineJoin=window.chrome?"miter":"round",w.ctx.strokeText(c.text,c.bounds.left,c.bounds.top+o)),w.ctx.strokeStyle="",w.ctx.lineWidth=0,w.ctx.lineJoin="miter";break}})}),[2]})})},A.prototype.renderReplacedElement=function(t,r,B){if(B&&t.intrinsicWidth>0&&t.intrinsicHeight>0){var n=ir(t),s=nr(r);this.path(s),this.ctx.save(),this.ctx.clip(),this.ctx.drawImage(B,0,0,t.intrinsicWidth,t.intrinsicHeight,n.left,n.top,n.width,n.height),this.ctx.restore()}},A.prototype.renderNodeContent=function(t){return X(this,void 0,void 0,function(){var r,B,n,s,i,a,F,F,o,Q,g,w,p,f,c,d,C,H,h,F,K,p,d;return S(this,function(l){switch(l.label){case 0:this.applyEffects(t.getEffects(4)),r=t.container,B=t.curves,n=r.styles,s=0,i=r.textNodes,l.label=1;case 1:return s0&&k>0&&(y=n.ctx.createPattern(d,"repeat"),n.renderRepeat(V,y,q,j))):wa(Q)&&(E=Bt(t,r,[null,null,null]),V=E[0],Z=E[1],$=E[2],b=E[3],k=E[4],IA=Q.position.length===0?[Lr]:Q.position,q=x(IA[0],b),j=x(IA[IA.length-1],k),bA=aa(Q,q,j,b,k),iA=bA[0],vA=bA[1],iA>0&&vA>0&&(xA=n.ctx.createRadialGradient(Z+q,$+j,0,Z+q,$+j,iA),qt(Q.stops,iA*2).forEach(function(le){return xA.addColorStop(le.stop,_(le.color))}),n.path(V),n.ctx.fillStyle=xA,iA!==vA?(TA=t.bounds.left+.5*t.bounds.width,CA=t.bounds.top+.5*t.bounds.height,SA=vA/iA,uA=1/SA,n.ctx.save(),n.ctx.translate(TA,CA),n.ctx.transform(1,0,0,SA,0,0),n.ctx.translate(-TA,-CA),n.ctx.fillRect(Z,uA*($-CA)+CA,b,k*uA),n.ctx.restore()):n.ctx.fill())),WA.label=6;case 6:return r--,[2]}})},n=this,s=0,i=t.styles.backgroundImage.slice(0).reverse(),o.label=1;case 1:return s0?Q.style!==2?[3,5]:[4,this.renderDashedDottedBorder(Q.color,Q.width,i,t.curves,2)]:[3,11]):[3,13];case 4:return w.sent(),[3,11];case 5:return Q.style!==3?[3,7]:[4,this.renderDashedDottedBorder(Q.color,Q.width,i,t.curves,3)];case 6:return w.sent(),[3,11];case 7:return Q.style!==4?[3,9]:[4,this.renderDoubleBorder(Q.color,Q.width,i,t.curves)];case 8:return w.sent(),[3,11];case 9:return[4,this.renderSolidBorder(Q.color,i,t.curves)];case 10:w.sent(),w.label=11;case 11:i++,w.label=12;case 12:return a++,[3,3];case 13:return[2]}})})},A.prototype.renderDashedDottedBorder=function(t,r,B,n,s){return X(this,void 0,void 0,function(){var i,a,o,Q,g,w,f,c,C,H,h,F,K,p,d,l,d,l;return S(this,function(v){return this.ctx.save(),i=UQ(n,B),a=nn(n,B),s===2&&(this.path(a),this.ctx.clip()),rA(a[0])?(o=a[0].start.x,Q=a[0].start.y):(o=a[0].x,Q=a[0].y),rA(a[1])?(g=a[1].end.x,w=a[1].end.y):(g=a[1].x,w=a[1].y),B===0||B===2?f=Math.abs(o-g):f=Math.abs(Q-w),this.ctx.beginPath(),s===3?this.formatPath(i):this.formatPath(a.slice(0,2)),c=r<3?r*3:r*2,C=r<3?r*2:r,s===3&&(c=r,C=r),H=!0,f<=c*2?H=!1:f<=c*2+C?(h=f/(2*c+C),c*=h,C*=h):(F=Math.floor((f+C)/(c+C)),K=(f-F*c)/(F-1),p=(f-(F+1)*c)/F,C=p<=0||Math.abs(C-K)`-to-canvas rendering, which is unreliable on Safari 15 (this repo's minimum supported browser per `.browserslistrc`). + +### `captureElementAsImage(element, options?)` + +Capture the current rendered appearance of `element` as an image `Blob`, without triggering a download. Use this when you need the image data itself (e.g. to upload it) rather than saving a file. + +- **element** (HTMLElement) — Must be connected to the document with a non-zero rendered size +- **options.scale** (number) — Output resolution multiplier (default: `Math.max(devicePixelRatio, 2)`) +- **options.format** (`'png'|'jpeg'`) — default `'png'` +- **options.quality** (number) — JPEG quality 0-1, ignored for png (default: `0.92`) +- **options.backgroundColor** (string|null) — default `null` for png (transparent), `'#ffffff'` for jpeg +- **options.useCORS** (boolean) — fetch cross-origin images in CORS mode (default: `true`) +- **options.isolate** (boolean) — render an off-screen `cloneNode()` copy instead of the live node, to avoid capturing transient `:hover`/`:focus`/caret state (default: `false`) +- **options.timeoutMs** (number) — default `15000` +- **options.html2canvasOptions** (Object) — escape hatch spread into the underlying `html2canvas()` call; `foreignObjectRendering` is always forced `false` +- **Returns** — `Promise` + +**Known limitation (CORS):** a cross-origin ``/`background-image` in `element` must either be served with `Access-Control-Allow-Origin`, or have `crossorigin="anonymous"` set on the `` tag, or the resulting canvas is tainted and the promise rejects with a descriptive error. + +Example: +```js +const blob = await captureElementAsImage(document.querySelector('.card')); +``` + +### `downloadElementAsImage(element, options?)` + +Same as `captureElementAsImage`, plus immediately triggers a browser "Save As" download of the result. + +- **options.filename** (string) — extension appended automatically if omitted (default: `` `screenshot-${Date.now()}.` ``) +- **Returns** — `Promise<{ blob: Blob, filename: string }>` + +Example: +```js +await downloadElementAsImage(document.querySelector('.card'), { filename: 'my-card' }); +``` + +This utility is headless and never shows UI text itself, so the `Error`s it throws/rejects with are developer-facing (console/`lana` logging), not subject to the no-hardcoded-text rule. To surface a failure to a user, catch the rejection and resolve a user-visible message via `replaceKey` in the calling block. + +Manual end-to-end verification (no automated test exercises the real `html2canvas` render or real Safari 15 behavior): +```js +const { downloadElementAsImage } = await import('/express/code/scripts/utils/download-utils.js'); +await downloadElementAsImage(document.querySelector('header'), { filename: 'manual-test' }); +``` diff --git a/express/code/scripts/utils/copy-toast.css b/express/code/scripts/utils/copy-toast.css new file mode 100644 index 000000000..824b4b377 --- /dev/null +++ b/express/code/scripts/utils/copy-toast.css @@ -0,0 +1,77 @@ +.copy-toast-container { + position: fixed; + bottom: var(--spacing-400); + left: 50%; + transform: translateX(-50%); + z-index: 10000; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-100); + pointer-events: none; +} + +.copy-toast { + display: flex; + align-items: center; + gap: var(--spacing-100); + box-sizing: border-box; + padding: var(--spacing-200) var(--spacing-100) var(--spacing-200) var(--spacing-300); + background: var(--color-green-900); + border-radius: 10px; + color: var(--color-white); + font-family: var(--body-font-family); + font-size: var(--Global-Typography-Size-Label-Label-M); + line-height: var(--Global-Typography-Line-height-Label-Label-M); + white-space: nowrap; + box-shadow: 0 4px 12px rgb(0 0 0 / 20%); + opacity: 0; + transform: translateY(8px); + transition: opacity 0.2s ease, transform 0.2s ease; + /* .copy-toast-container is pointer-events: none so it never blocks clicks + on whatever's behind it — this re-enables just the toast pill itself, + which now has an actually-clickable close button. */ + pointer-events: auto; +} + +.copy-toast.is-visible { + opacity: 1; + transform: translateY(0); +} + +/* sp-icon-checkmark-circle sizes/colours itself via these CSS custom + property hooks — same pattern as mini-editor-widget.css's + .me-action-icon, no Spectrum theme/design tokens required. */ +.copy-toast-icon { + flex-shrink: 0; + --mod-icon-size: 18px; + --mod-icon-color: var(--color-white); +} + +.copy-toast-close { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + margin: 0; + background: none; + border: none; + border-radius: 16px; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.copy-toast-close:hover, +.copy-toast-close:focus-visible { + background: rgb(255 255 255 / 15%); +} + +/* sp-icon-close sizes/colours itself via these CSS custom property hooks — + same pattern as .copy-toast-icon above. */ +.copy-toast-close-icon { + --mod-icon-size: 12px; + --mod-icon-color: var(--color-white); +} diff --git a/express/code/scripts/utils/copy-toast.js b/express/code/scripts/utils/copy-toast.js new file mode 100644 index 000000000..242964bac --- /dev/null +++ b/express/code/scripts/utils/copy-toast.js @@ -0,0 +1,55 @@ +import { getLibs } from '../utils.js'; + +let createTag; +let loadStyle; +let container; + +/** + * Lightweight, dependency-free bottom-of-screen toast for copy-to-clipboard + * feedback, per Figma node 0-19315: a pill with a checkmark icon that + * auto-dismisses after 5s. Deliberately not the Spectrum `showExpressToast` + * (express-toast.js) — that pulls in the full Spectrum Web Components + * machinery, which is disproportionate for a single plain-text toast shared + * across unrelated blocks (mini-editor, collapsible-rows). + */ +export default async function showCopyToast(message) { + if (!createTag) { + let getConfig; + ({ createTag, loadStyle, getConfig } = await import(`${getLibs()}/utils/utils.js`)); + loadStyle(`${getConfig().codeRoot}/scripts/utils/copy-toast.css`); + // sp-icon-checkmark-circle-outline / sp-icon-close are real Spectrum + // Web Components custom elements (same pattern as + // mini-editor-widget.js's topActions) — load their definitions once, + // before first use. + await import(`${getConfig().codeRoot}/scripts/widgets/spectrum/dist/icons-workflow.js`); + } + if (!container) { + container = createTag('div', { class: 'copy-toast-container', role: 'status', 'aria-live': 'polite' }); + document.body.append(container); + } + + container.querySelectorAll('.copy-toast').forEach((t) => t.remove()); + + const toast = createTag('div', { class: 'copy-toast' }, [ + createTag('sp-icon-checkmark-circle-outline', { class: 'copy-toast-icon', 'aria-hidden': 'true' }), + createTag('span', { class: 'copy-toast-message' }, [message]), + ]); + container.append(toast); + + const remove = () => { + toast.classList.remove('is-visible'); + toast.addEventListener('transitionend', () => toast.remove(), { once: true }); + }; + + const closeBtn = createTag('button', { + type: 'button', + class: 'copy-toast-close', + 'aria-label': 'Close', + }, [createTag('sp-icon-close', { class: 'copy-toast-close-icon', 'aria-hidden': 'true' })]); + closeBtn.addEventListener('click', remove); + toast.append(closeBtn); + + requestAnimationFrame(() => toast.classList.add('is-visible')); + + setTimeout(remove, 5000); +} diff --git a/express/code/scripts/utils/download-utils.js b/express/code/scripts/utils/download-utils.js new file mode 100644 index 000000000..d41e51567 --- /dev/null +++ b/express/code/scripts/utils/download-utils.js @@ -0,0 +1,207 @@ +/** + * Generic "capture an HTMLElement and download it as an image" utility. + * + * This module is headless: it never renders UI and never shows text to a + * user, so the Errors it throws/rejects with are developer-facing (console, + * lana logging, control flow) and are not subject to the no-hardcoded-text + * rule. To surface a failure to a user, catch the rejection, log it, and + * resolve any user-visible message via replaceKey, e.g.: + * + * try { + * await downloadElementAsImage(cardEl, { filename: 'my-card' }); + * } catch (err) { + * const tags = 'download-utils'; + * window.lana?.log(`downloadElementAsImage failed: ${err.message}`, { tags }); + * const { replaceKey } = await import(`${getLibs()}/features/placeholders.js`); + * showToast(await replaceKey('screenshot-download-failed', getConfig())); + * } + * + * Known limitation: cross-origin images embedded in the target element must + * either be served with an Access-Control-Allow-Origin header, or have + * crossorigin="anonymous" set on the tag, or the resulting canvas will + * be tainted and captureElementAsImage will reject (see canvasToBlob below). + */ + +let html2canvasModulePromise; + +async function loadHtml2Canvas() { + if (!html2canvasModulePromise) { + // Reset on failure so a later call retries instead of staying rejected forever. + html2canvasModulePromise = import('../../libs/deps/html2canvas.js') + .then((mod) => mod.default) + .catch((err) => { + html2canvasModulePromise = undefined; + throw err; + }); + } + return html2canvasModulePromise; +} + +// Exported so tests can stub the loader instead of exercising the real import. +export const Html2CanvasLoader = { load: loadHtml2Canvas }; + +function assertCapturable(element) { + if (!(element instanceof HTMLElement)) { + throw new TypeError('captureElementAsImage: element must be an HTMLElement'); + } + if (!element.isConnected) { + throw new Error('captureElementAsImage: element must be attached to the document'); + } + const { width, height } = element.getBoundingClientRect(); + if (width <= 0 || height <= 0) { + throw new Error('captureElementAsImage: element has zero rendered size (is it display:none or detached?)'); + } +} + +function withTimeout(promise, ms, message) { + let timer; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +function createOffscreenClone(element) { + const { width, height } = element.getBoundingClientRect(); + const container = document.createElement('div'); + container.style.position = 'fixed'; + container.style.top = '0'; + container.style.left = '-99999px'; + container.style.width = `${width}px`; + container.style.height = `${height}px`; + container.style.pointerEvents = 'none'; + container.setAttribute('aria-hidden', 'true'); + const clone = element.cloneNode(true); + container.append(clone); + document.body.append(container); + return { clone, cleanup: () => container.remove() }; +} + +function canvasToBlob(canvas, format, quality) { + const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png'; + return new Promise((resolve, reject) => { + try { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('captureElementAsImage: canvas.toBlob returned null')); + } + }, mimeType, format === 'jpeg' ? quality : undefined); + } catch (err) { + if (err?.name === 'SecurityError') { + reject(new Error( + 'captureElementAsImage: canvas is tainted by a cross-origin image loaded without CORS. ' + + 'Ensure cross-origin /background-image sources send Access-Control-Allow-Origin, ' + + 'set crossorigin="anonymous" on such elements, or exclude them via ' + + 'options.html2canvasOptions.ignoreElements.', + )); + } else { + reject(err); + } + } + }); +} + +async function captureValidatedElementAsImage(element, options) { + const { + scale = Math.max(window.devicePixelRatio || 1, 2), + format = 'png', + quality = 0.92, + backgroundColor = format === 'jpeg' ? '#ffffff' : null, + useCORS = true, + isolate = false, + timeoutMs = 15000, + html2canvasOptions = {}, + } = options; + + if (format !== 'png' && format !== 'jpeg') { + throw new Error(`captureElementAsImage: unsupported format "${format}"`); + } + + const html2canvas = await withTimeout( + Html2CanvasLoader.load(), + timeoutMs, + 'captureElementAsImage: timed out loading html2canvas', + ); + + const rendered = isolate ? createOffscreenClone(element) : null; + const target = rendered ? rendered.clone : element; + + try { + const canvas = await withTimeout( + html2canvas(target, { + scale, + backgroundColor, + useCORS, + ...html2canvasOptions, + foreignObjectRendering: false, + }), + timeoutMs, + 'captureElementAsImage: timed out rendering element to canvas', + ); + return await canvasToBlob(canvas, format, quality); + } finally { + rendered?.cleanup(); + } +} + +/** + * Capture the current rendered appearance of `element` as an image Blob. + * @param {HTMLElement} element - Must be connected to the document, non-zero rendered size. + * @param {Object} [options] + * @param {number} [options.scale] - Output resolution multiplier. + * Default: Math.max(devicePixelRatio, 2). + * @param {'png'|'jpeg'} [options.format='png'] + * @param {number} [options.quality=0.92] - JPEG quality 0-1, ignored for png. + * @param {string|null} [options.backgroundColor] - Default: null for png (transparent), + * '#ffffff' for jpeg. + * @param {boolean} [options.useCORS=true] - Fetch cross-origin images in CORS mode. + * @param {boolean} [options.isolate=false] - Render an off-screen cloneNode() copy + * instead of the live node. + * @param {number} [options.timeoutMs=15000] + * @param {Object} [options.html2canvasOptions] - Escape hatch spread into html2canvas(); + * foreignObjectRendering is always forced false (Safari 15 support). + * @returns {Promise} + */ +export function captureElementAsImage(element, options = {}) { + // Validate synchronously, outside the async function above, so misuse + // throws immediately to the caller instead of surfacing as an unhandled + // promise rejection. + assertCapturable(element); + return captureValidatedElementAsImage(element, options); +} + +function triggerBlobDownload(blob, filename) { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +function resolveFilename(requestedFilename, ext) { + if (!requestedFilename) return `screenshot-${Date.now()}.${ext}`; + if (/\.[a-z0-9]+$/i.test(requestedFilename)) return requestedFilename; + return `${requestedFilename}.${ext}`; +} + +/** + * Capture `element` and immediately trigger a browser download of the result. + * @param {HTMLElement} element + * @param {Object} [options] - Same as captureElementAsImage, plus: + * @param {string} [options.filename] - Extension appended automatically if omitted. + * Default: `screenshot-${Date.now()}.`. + * @returns {Promise<{ blob: Blob, filename: string }>} + */ +export async function downloadElementAsImage(element, options = {}) { + const { format = 'png' } = options; + const blob = await captureElementAsImage(element, options); + const ext = format === 'jpeg' ? 'jpg' : 'png'; + const filename = resolveFilename(options.filename, ext); + triggerBlobDownload(blob, filename); + return { blob, filename }; +} diff --git a/express/code/scripts/utils/mini-editor-card-export.js b/express/code/scripts/utils/mini-editor-card-export.js new file mode 100644 index 000000000..53f855fec --- /dev/null +++ b/express/code/scripts/utils/mini-editor-card-export.js @@ -0,0 +1,152 @@ +import { + drawMiniEditorCard, + drawMiniEditorText, + MINI_EDITOR_EXPORT_HEIGHT, + MINI_EDITOR_EXPORT_WIDTH, +} from './mini-editor-card-renderer.js'; + +const WORKER_TIMEOUT_MS = 10000; +const MiniEditorCardExporter = {}; + +function resolveFontFamily(family) { + const bodyFont = getComputedStyle(document.documentElement) + .getPropertyValue('--body-font-family') + .trim() || 'sans-serif'; + return family.replace(/var\(--body-font-family(?:,\s*([^)]+))?\)/g, (_match, fallback) => ( + bodyFont || fallback || 'sans-serif' + )); +} + +function normalizeModel(model) { + return { + ...model, + backgroundUrl: new URL(model.backgroundUrl, window.location.href).href, + font: { ...model.font, family: resolveFontFamily(model.font.family) }, + }; +} + +async function waitForFont(model) { + if (!document.fonts) return; + const font = `${model.font.style} ${model.font.weight} 40px ${model.font.family}`; + await Promise.all([document.fonts.load(font, model.quote), document.fonts.ready]); +} + +function loadBackgroundImage(url) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.addEventListener('load', () => resolve(image), { once: true }); + image.addEventListener('error', () => reject(new Error('Mini-editor background image failed to load')), { once: true }); + image.src = url; + }); +} + +function renderBackgroundInWorker(backgroundUrl) { + return new Promise((resolve, reject) => { + const worker = MiniEditorCardExporter.createWorker(); + let settled = false; + let timeout; + const finish = (callback, value) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + worker.terminate(); + callback(value); + }; + timeout = setTimeout(() => { + const error = new Error('Mini-editor worker timed out'); + error.recoverable = true; + finish(reject, error); + }, WORKER_TIMEOUT_MS); + worker.addEventListener('message', ({ data }) => { + if (data.type === 'complete') { + finish(resolve, data.bitmap); + } else if (data.type === 'error') { + const error = new Error(data.message); + error.code = data.code; + finish(reject, error); + } + }); + worker.addEventListener('error', (event) => { + const error = new Error(event.message || 'Mini-editor worker failed'); + error.recoverable = true; + finish(reject, error); + }); + worker.postMessage({ backgroundUrl }); + }); +} + +function canvasToBlob(canvas) { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Mini-editor canvas encoding returned no image')); + }, 'image/png'); + }); +} + +function triggerDownload(blob, filename) { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +function supportsWorkerRendering() { + // Safari without OffscreenCanvas uses the main-thread Canvas fallback below. + // eslint-disable-next-line compat/compat + const OffscreenCanvasConstructor = window.OffscreenCanvas; + if (!window.Worker || !OffscreenCanvasConstructor || !window.createImageBitmap) return false; + try { + return !!new OffscreenCanvasConstructor(1, 1).getContext('2d'); + } catch { + return false; + } +} + +async function createCardBlob(inputModel) { + const model = normalizeModel(inputModel); + const canvas = document.createElement('canvas'); + canvas.width = MINI_EDITOR_EXPORT_WIDTH; + canvas.height = MINI_EDITOR_EXPORT_HEIGHT; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Canvas 2D context is unavailable'); + + const fontReady = waitForFont(model); + if (MiniEditorCardExporter.supportsWorkerRendering()) { + try { + const background = await renderBackgroundInWorker(model.backgroundUrl); + await fontReady; + context.drawImage(background, 0, 0); + background.close(); + drawMiniEditorText(context, model); + return canvasToBlob(canvas); + } catch (error) { + if (!error.recoverable) throw error; + } + } + + const [background] = await Promise.all([loadBackgroundImage(model.backgroundUrl), fontReady]); + drawMiniEditorCard(context, background, model); + return canvasToBlob(canvas); +} + +async function download(model) { + const blob = await createCardBlob(model); + const filename = `screenshot-${Date.now()}.png`; + triggerDownload(blob, filename); + return { blob, filename }; +} + +Object.assign(MiniEditorCardExporter, { + createWorker: () => new Worker(new URL('./mini-editor-card-worker.js', import.meta.url), { type: 'module' }), + supportsWorkerRendering, + createCardBlob, + download, +}); + +export default MiniEditorCardExporter; diff --git a/express/code/scripts/utils/mini-editor-card-renderer.js b/express/code/scripts/utils/mini-editor-card-renderer.js new file mode 100644 index 000000000..1693c69e6 --- /dev/null +++ b/express/code/scripts/utils/mini-editor-card-renderer.js @@ -0,0 +1,122 @@ +export const MINI_EDITOR_EXPORT_WIDTH = 1084; +export const MINI_EDITOR_EXPORT_HEIGHT = 700; + +const QUOTE_MAX_WIDTH = 624; +const QUOTE_FONT_SIZE = 40; +const QUOTE_LINE_HEIGHT = 52; +const AUTHOR_FONT_SIZE = 32; +const AUTHOR_BOTTOM = 24; + +export function calculateCoverCrop(sourceWidth, sourceHeight, targetWidth, targetHeight) { + const scale = Math.max(targetWidth / sourceWidth, targetHeight / sourceHeight); + const width = targetWidth / scale; + const height = targetHeight / scale; + return { + sourceX: (sourceWidth - width) / 2, + sourceY: (sourceHeight - height) / 2, + sourceWidth: width, + sourceHeight: height, + }; +} + +function splitLongWord(context, word, maxWidth) { + const parts = []; + let part = ''; + Array.from(word).forEach((character) => { + const candidate = `${part}${character}`; + if (part && context.measureText(candidate).width > maxWidth) { + parts.push(part); + part = character; + } else { + part = candidate; + } + }); + if (part) parts.push(part); + return parts; +} + +export function wrapCanvasText(context, text, maxWidth) { + const lines = []; + let currentLine = ''; + text.trim().split(/\s+/).filter(Boolean).forEach((word) => { + const parts = context.measureText(word).width > maxWidth + ? splitLongWord(context, word, maxWidth) + : [word]; + parts.forEach((part) => { + const candidate = currentLine ? `${currentLine} ${part}` : part; + if (currentLine && context.measureText(candidate).width > maxWidth) { + lines.push(currentLine); + currentLine = part; + } else { + currentLine = candidate; + } + }); + }); + if (currentLine) lines.push(currentLine); + return lines; +} + +function getImageDimensions(image) { + return { + width: image.naturalWidth || image.videoWidth || image.width, + height: image.naturalHeight || image.videoHeight || image.height, + }; +} + +export function drawCoverImage( + context, + image, + width = MINI_EDITOR_EXPORT_WIDTH, + height = MINI_EDITOR_EXPORT_HEIGHT, +) { + const source = getImageDimensions(image); + const crop = calculateCoverCrop(source.width, source.height, width, height); + context.drawImage( + image, + crop.sourceX, + crop.sourceY, + crop.sourceWidth, + crop.sourceHeight, + 0, + 0, + width, + height, + ); +} + +function buildCanvasFont(font, size) { + return `${font.style || 'normal'} ${font.weight || 'normal'} ${size}px ${font.family}`; +} + +export function drawMiniEditorText(context, model) { + context.save(); + context.fillStyle = '#131313'; + context.font = buildCanvasFont(model.font, QUOTE_FONT_SIZE); + context.textAlign = 'center'; + context.textBaseline = 'middle'; + + const lines = wrapCanvasText(context, model.quote, QUOTE_MAX_WIDTH); + const firstLineY = (MINI_EDITOR_EXPORT_HEIGHT / 2) + - (((lines.length - 1) * QUOTE_LINE_HEIGHT) / 2); + lines.forEach((line, index) => { + context.fillText(line, MINI_EDITOR_EXPORT_WIDTH / 2, firstLineY + (index * QUOTE_LINE_HEIGHT)); + }); + + if (model.author) { + context.fillStyle = '#505050'; + context.font = buildCanvasFont(model.font, AUTHOR_FONT_SIZE); + context.textBaseline = 'bottom'; + context.fillText( + model.author, + MINI_EDITOR_EXPORT_WIDTH / 2, + MINI_EDITOR_EXPORT_HEIGHT - AUTHOR_BOTTOM, + ); + } + context.restore(); +} + +export function drawMiniEditorCard(context, background, model) { + context.clearRect(0, 0, MINI_EDITOR_EXPORT_WIDTH, MINI_EDITOR_EXPORT_HEIGHT); + drawCoverImage(context, background); + drawMiniEditorText(context, model); +} diff --git a/express/code/scripts/utils/mini-editor-card-worker.js b/express/code/scripts/utils/mini-editor-card-worker.js new file mode 100644 index 000000000..34852b5e4 --- /dev/null +++ b/express/code/scripts/utils/mini-editor-card-worker.js @@ -0,0 +1,33 @@ +/* global globalThis */ + +import { + drawCoverImage, + MINI_EDITOR_EXPORT_HEIGHT, + MINI_EDITOR_EXPORT_WIDTH, +} from './mini-editor-card-renderer.js'; + +globalThis.addEventListener('message', async ({ data }) => { + try { + const response = await fetch(data.backgroundUrl, { mode: 'cors', credentials: 'omit' }); + if (!response.ok) throw new Error(`Background request failed with status ${response.status}`); + const source = await createImageBitmap(await response.blob()); + // The worker is only created after the main thread detects OffscreenCanvas support. + // eslint-disable-next-line compat/compat + const canvas = new globalThis.OffscreenCanvas( + MINI_EDITOR_EXPORT_WIDTH, + MINI_EDITOR_EXPORT_HEIGHT, + ); + const context = canvas.getContext('2d'); + if (!context) throw new Error('OffscreenCanvas 2D context is unavailable'); + drawCoverImage(context, source); + source.close(); + const bitmap = canvas.transferToImageBitmap(); + globalThis.postMessage({ type: 'complete', bitmap }, [bitmap]); + } catch (error) { + globalThis.postMessage({ + type: 'error', + code: 'BACKGROUND_RENDER_FAILED', + message: error?.message || String(error), + }); + } +}); diff --git a/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.css b/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.css new file mode 100644 index 000000000..78f65f0ac --- /dev/null +++ b/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.css @@ -0,0 +1,152 @@ +/* ============================================================================ + Mini Editor Modal + Wraps mini-editor-widget's centre editor card (decorations disabled) in an + animated dialog, per Figma node 54:8824. Same experience at every + breakpoint — desktop, tablet, mobile. + ============================================================================ */ + +/* Prevents the modal's scroll-lock (toggling body overflow) from shifting + layout by removing/restoring the scrollbar gutter — applies page-wide, + not just while the modal is open, so there's never a jump either way. */ +html { + scrollbar-gutter: stable; +} + +.me-modal-overlay { + position: fixed; + inset: 0; + z-index: 1300; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-400); + background: rgb(0 0 0 / 69%); + opacity: 0; + visibility: hidden; + transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.me-modal-overlay--open { + visibility: visible; + opacity: 1; +} + +.me-modal-overlay--closing { + transition-duration: 150ms; +} + +.me-modal { + position: relative; + display: flex; + align-items: flex-start; + justify-content: center; + max-width: 100%; + max-height: 100%; +} + +.me-modal-card { + box-sizing: border-box; + width: 558px; + max-width: 100%; + max-height: calc(100vh - 2 * var(--spacing-400)); + overflow: auto; + padding: var(--spacing-100); + border-radius: 24px; + background: var(--color-white); + transform: scale(0.96); + opacity: 0; + transition: transform 250ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 250ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.me-modal-overlay--open .me-modal-card { + transform: scale(1); + opacity: 1; +} + +.me-modal-overlay--closing .me-modal-card { + transition-duration: 150ms; +} + +.me-modal-close { + /* Fully outside the card (not overlapping its corner) — per Figma node + 54:8824, the close button sits an 8px gap to the right of the card, + top-aligned with it. */ + position: absolute; + top: 0; + left: calc(100% + var(--spacing-100)); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + border: none; + border-radius: 100px; + background: rgb(255 255 255 / 80%); + cursor: pointer; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); +} + +/* sp-icon-close sizes/colours itself via these CSS custom property hooks — + same pattern as mini-editor-widget.css's .me-action-icon, no Spectrum + theme/design tokens required. */ +.me-modal-close-icon { + --mod-icon-size: 20px; + --mod-icon-color: #292929; +} + +.me-modal-close:hover { + background: rgb(255 255 255 / 95%); +} + +@media (width <= 900px) { + /* Not enough room beside the card at this width — float it over the + card's top-right corner instead, half outside/half on the card, so it + stays reachable without the modal needing extra horizontal space. */ + .me-modal-close { + top: -22px; + left: auto; + right: -22px; + } +} + +@media (width <= 767px) { + /* No room beside the card at this width — float it above the card's top + edge instead (outside, over the backdrop), rather than the tablet + fallback's half-overlapping-the-corner position or insetting inside + the card, which the design calls for staying clear of entirely. */ + .me-modal-close { + top: auto; + right: var(--spacing-200); + bottom: calc(100% + var(--spacing-200)); + left: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .me-modal-overlay, + .me-modal-card { + transition: none; + } +} + +/* Tablet/desktop only: the inline font/colour row, always open, instead of + the collapsible-panel default — overriding mini-editor-widget.css's own + .me-panel display. Paired with panelMode: 'always-open-inline' (see + mini-editor-modal.js and its own <=767px matchMedia check), which keeps + data-me-panel always set to 'fonts' or 'colour' at these widths. + Below 767px the modal falls back to the same collapsible-panel + + bottom-sheet behaviour as the inline block — no override needed there; + mini-editor-widget.css's own breakpoint already handles it. */ +@media (width > 767px) { + .me-modal-card-root .me-panel { + display: block; + } + + .me-modal-card-root .me-sheet-overlay { + display: none; + } +} diff --git a/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.js b/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.js new file mode 100644 index 000000000..248394fdd --- /dev/null +++ b/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.js @@ -0,0 +1,188 @@ +/** + * Mini Editor Modal + * + * Wraps the mini-editor-widget's centre editor card (no decorative cards / + * arc carousel — see `decorations: false`) in an animated modal dialog, per + * Figma node 54:8824. Opened from collapsible-rows' "Create a design" + * button (`mini-editor:use-quote`) — the same event the inline mini-editor + * block previously listened for — so this replaces that scroll-to-and-swap + * behaviour with a modal, identically across desktop/tablet/mobile. + * + * Usage: + * import createMiniEditorModal from + * '../../scripts/widgets/mini-editor-modal/mini-editor-modal.js'; + * + * const modal = await createMiniEditorModal({ + * fontOptions, backgrounds, a11y, deps, + * }); + * document.body.append(modal.el); + */ + +import createMiniEditorWidget from '../mini-editor-widget/mini-editor-widget.js'; + +let createTag; + +const OPEN_DURATION = 250; +const CLOSE_DURATION = 150; + +function prefersReducedMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +// Matches mini-editor-widget.css's own <=767px breakpoint, where the modal +// falls back to the normal collapsible-panel + bottom-sheet behaviour +// instead of always-open-inline (see panelMode's mobile matchMedia guards +// in mini-editor-widget.js). +function isMobileSheetWidth() { + return window.matchMedia('(width <= 767px)').matches; +} + +export default async function createMiniEditorModal(config = {}) { + const { + fontOptions, + backgrounds, + a11y, + deps, + } = config; + const { + trapFocus, handleEscapeClose, disableBackgroundScroll, restoreBackgroundScroll, + } = a11y; + + ({ createTag } = deps); + + // sp-icon-close is a real Spectrum Web Components custom element (same + // pattern as topActions' sp-icon-edit/share/download in + // mini-editor-widget.js) — load its definition before using the tag. + await import('../spectrum/dist/icons-workflow.js'); + + const overlay = createTag('div', { class: 'me-modal-overlay', 'aria-hidden': 'true', inert: '' }); + const dialog = createTag('div', { + class: 'me-modal', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': 'Create a design', + tabindex: '-1', + }); + const cardWrap = createTag('div', { class: 'me-modal-card' }); + const closeBtn = createTag('button', { + type: 'button', + class: 'me-modal-close', + 'aria-label': 'Close', + }, [createTag('sp-icon-close', { class: 'me-modal-close-icon', 'aria-hidden': 'true' })]); + dialog.append(cardWrap, closeBtn); + overlay.append(dialog); + + // The widget needs a root to set --me-* CSS vars / data-me-panel on — + // cardWrap plays that role here (in the block, it's the block element + // itself). me-modal-card-root (not mini-editor) opts into just the + // shared --me-card-bg/--me-quote-font* var defaults and panel-open + // selector in mini-editor-widget.css, without mini-editor.css's block + // layout (padding/gap/flex-column) or arc-sizing tokens, which this + // modal doesn't use — decorations: false skips building the desktop + // zig-zag / tablet-mobile arc carousel entirely, per the modal's + // "centre editor only" design. See mini-editor-widget.css. + cardWrap.classList.add('me-modal-card-root'); + const editor = await createMiniEditorWidget({ + root: cardWrap, + topActions: [], + fontOptions, + backgrounds, + a11y, + deps, + decorations: false, + // One of font/colour always stays open, as an inline row, at every + // width — never a collapsible panel or a mobile bottom sheet. See + // mini-editor-modal.css for the accompanying CSS override that + // suppresses the bottom sheet the widget still builds either way. + panelMode: 'always-open-inline', + }); + cardWrap.append(editor.stage); + + let isOpen = false; + let openHousekeepingTimer = null; + let closeTimer = null; + let focusTrap = null; + let escapeRelease = null; + let previouslyFocused = null; + + function close() { + if (!isOpen) return; + isOpen = false; + clearTimeout(openHousekeepingTimer); + clearTimeout(closeTimer); + restoreBackgroundScroll(); + focusTrap?.release(); + focusTrap = null; + escapeRelease?.release(); + escapeRelease = null; + cardWrap.style.transform = ''; + + const reduceMotion = prefersReducedMotion(); + overlay.classList.remove('me-modal-overlay--open'); + overlay.classList.toggle('me-modal-overlay--closing', !reduceMotion); + + const finish = () => { + overlay.setAttribute('aria-hidden', 'true'); + overlay.setAttribute('inert', ''); + overlay.classList.remove('me-modal-overlay--closing'); + previouslyFocused?.focus(); + previouslyFocused = null; + }; + if (reduceMotion) finish(); + else closeTimer = setTimeout(finish, CLOSE_DURATION); + } + + function open({ quote, author } = {}) { + if (isOpen) return; + isOpen = true; + clearTimeout(closeTimer); + previouslyFocused = document.activeElement; + if (quote) editor.useQuote({ quote, author }); + + // The widget only sets its *initial* data-me-panel once, at creation — + // re-assert the correct starting state for the *current* viewport on + // every open, since this modal instance is built once at page load and + // can be opened again after a resize/rotation crossed the mobile + // breakpoint since then. + cardWrap.setAttribute('data-me-panel', isMobileSheetWidth() ? 'none' : 'fonts'); + + overlay.removeAttribute('aria-hidden'); + overlay.removeAttribute('inert'); + disableBackgroundScroll(); + dialog.focus(); + focusTrap = trapFocus(dialog); + escapeRelease = handleEscapeClose(dialog, close); + + const reduceMotion = prefersReducedMotion(); + overlay.classList.add('me-modal-overlay--open'); + if (reduceMotion) return; + + // Housekeeping, not a visual change: once the open transition settles, + // drop the card's transform so a later copy-quote toast (anchored via + // this same card) positions against the card's untransformed box. + openHousekeepingTimer = setTimeout(() => { + cardWrap.style.transform = 'none'; + }, OPEN_DURATION); + } + + closeBtn.addEventListener('click', close); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + + // Dispatched by collapsible-rows' "Create a design" button (see + // collapsible-rows.js) — the two blocks stay decoupled via this event + // instead of importing one into the other. + const onUseQuoteEvent = (e) => open(e.detail); + document.addEventListener('mini-editor:use-quote', onUseQuoteEvent); + + return { + el: overlay, + open, + close, + destroy: () => { + close(); + document.removeEventListener('mini-editor:use-quote', onUseQuoteEvent); + editor.destroy(); + overlay.remove(); + }, + }; +} diff --git a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css new file mode 100644 index 000000000..addcdb176 --- /dev/null +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css @@ -0,0 +1,1048 @@ +/* ============================================================================ + Mini Editor Widget + The editing surface: desktop widget card + zig-zag decorative previews, the + tablet/mobile arc carousel, the shared font/background controls (inline rows + + mobile bottom sheets). Arc-sizing tokens, the block padding/gap, and the + header live in the host block's own stylesheet (mini-editor.css); this + stylesheet consumes those tokens. UI is intentionally identical to the + original in-block implementation — no Spectrum dependency. + ============================================================================ */ + +/* --me-card-bg / --me-quote-font* defaults: shared by every host of this + widget (the .mini-editor block AND the "Create a design" modal's own card + root, see mini-editor-modal.css) — unlike the arc-sizing tokens and block + layout below them in mini-editor.css's own .mini-editor rule, which only + apply when decorations are enabled. buildFontControl/buildColorControl set + these inline once a font/background is picked; these are just the + before-first-paint fallback. */ +.mini-editor, +.me-modal-card-root { + --me-card-bg: none; + --me-quote-font: var(--body-font-family); + --me-quote-font-style: normal; + --me-quote-font-weight: normal; +} + +.mini-editor-stage { + position: relative; + display: flex; + justify-content: center; + width: 100%; +} + +.mini-editor-widget { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 542px; +} + +.me-card { + position: relative; + width: 100%; + height: 350px; + margin-bottom: var(--spacing-300); + box-sizing: border-box; + padding: var(--spacing-350); + border-radius: var(--Radius-corner-radius-200); + background-image: var(--me-card-bg); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: hidden; + transition: background-image 0.35s ease; +} + +/* Whole-card focus ring, per Figma node 54:8144 ("Interactive Widget", + Property 1=Focus) — shown when keyboard focus lands anywhere inside the + card (the quote button or an action button), not just on the card itself + (which isn't focusable). outline (not the Figma reference's absolutely + positioned border div) so it doesn't need extra markup and never affects + layout. */ +.me-card:focus-within { + outline: 2px solid var(--color-focus-ring-strong); + outline-offset: 2px; +} + +/* Top-right hover action bar (edit/share/download), per Figma node 1099-5050. + Anchored to .mini-editor-widget (not .me-card) so the same element/CSS + rule works unchanged in both the desktop card layout and the tablet/mobile + arc carousel — see the .me-carousel-mode tie-in below for why the + top-right corner still lines up with the visible card in both cases. */ +.me-actions { + position: absolute; + top: var(--spacing-300); + right: var(--spacing-300); + z-index: 4; + display: flex; + align-items: center; + gap: var(--spacing-80); + padding: var(--spacing-80); + border-radius: 16px; + background: var(--transparent-white-400); + border: 1px solid var(--transparent-white-500); + -webkit-backdrop-filter: blur(2.5px); + backdrop-filter: blur(2.5px); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} + +.mini-editor-widget:hover .me-actions, +.mini-editor-widget:focus-within .me-actions { + opacity: 1; + pointer-events: auto; +} + +.me-action { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: var(--spacing-80); + box-sizing: border-box; + border: 0; + border-radius: 8px; + background: none; + cursor: pointer; +} + +/* sp-icon-* (see TOP_ACTION_DEFS in mini-editor-widget.js) sizes/colours + itself via these CSS custom property hooks — no Spectrum theme/design + tokens required, so a plain 20px/#292929 override here is enough. */ +.me-action-icon { + --mod-icon-size: 20px; + --mod-icon-color: #292929; +} + +.me-action:hover { + background: var(--color-gray-150); +} + +.me-quote-wrap { + position: relative; + box-sizing: border-box; + max-width: 322px; + padding: var(--spacing-75); + cursor: pointer; + border: 1px solid transparent; + border-radius: 12px; + transition: background-color 0.18s ease, border-color 0.18s ease; +} + +.me-quote-wrap:hover, +.me-quote-wrap:focus-visible { + background: rgb(255 255 255 / 20%); + border-color: rgb(255 255 255 / 30%); +} + +/* Adds the blue keyboard-focus ring on top of the shared hover/focus frosted + treatment above, per Figma node 54:8728 ("Quote text", state=Focus) — + Focus there is Hover's look plus this ring, not a separate treatment. */ +.me-quote-wrap:focus-visible { + outline: 2px solid var(--color-focus-ring-strong); + outline-offset: 2px; +} + +.me-quote { + margin: 0; + font-family: var(--me-quote-font); + font-style: var(--me-quote-font-style); + font-weight: var(--me-quote-font-weight); + font-size: var(--Global-Typography-Size-Label-Label-2XL); + line-height: var(--Global-Typography-Line-height-Label-Label-2XL); + text-align: center; + word-break: break-word; + color: var(--Alias-content-typography-Heading); +} + +.me-author { + /* Absolutely positioned (not in-flow with margin-top) so it doesn't share + .me-card's flex centering group with .me-quote-wrap — otherwise a short + quote pulls the author up with it instead of the author staying a + fixed offset from the card's bottom edge. Matches .me-arc-author, + which already gets this right. */ + position: absolute; + left: 50%; + bottom: 20px; + transform: translateX(-50%); + margin: 0; + font-family: var(--me-quote-font); + font-style: var(--me-quote-font-style); + font-weight: var(--me-quote-font-weight); + font-size: var(--Global-Typography-Size-Label-Label-L); + text-align: center; + white-space: nowrap; + color: var(--Alias-content-typography-Body); +} + +.me-tip { + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%) translateY(4px); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease, transform 0.15s ease; + z-index: 2; +} + +.me-quote-wrap:hover .me-tip, +.me-quote-wrap:focus-visible .me-tip { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.me-tip-box { + display: block; + max-width: 160px; + padding: var(--spacing-75) var(--spacing-200); + border-radius: var(--border-radius-8); + background: var(--Palette-gray-800); + color: var(--color-white); + font-size: var(--Global-Typography-Size-Label-Label-S); + line-height: var(--Global-Typography-Line-height-Label-Label-M); + text-align: center; + white-space: nowrap; +} + +.me-quote-wrap.is-copied .me-tip-box::before { + content: 'Copied!'; +} + +.me-quote-wrap.is-copied .me-tip-box { + font-size: 0; +} + +.me-quote-wrap.is-copied .me-tip-box::before { + font-size: 12px; +} + +.me-controls { + /* Stays above .me-sheet-overlay (z-index 1200 below) — otherwise, while a + bottom sheet is open, a tap on the *other* trigger button lands on the + open sheet's own full-screen backdrop instead of the button underneath + it (same stacking context, sheet painted later), closing that sheet + instead of switching straight to the one just tapped. */ + position: relative; + z-index: 1201; + display: flex; + align-items: stretch; + gap: var(--spacing-80); + width: 100%; +} + +.me-control { + flex: 1 1 0; + min-width: 0; + height: 58px; + display: flex; + align-items: center; + gap: var(--spacing-100); + box-sizing: border-box; + background: var(--color-gray-150); + border: 1px solid var(--S2AC-Palette-transparent-black-50); + border-radius: var(--Radius-corner-radius-200); + cursor: pointer; + text-align: left; + padding: var(--spacing-80) var(--spacing-300) var(--spacing-80) var(--spacing-80); + font: inherit; + transition: background-color 0.15s ease; +} + +.me-control--colour { + padding: var(--spacing-200); +} + +.me-control:hover, +.me-control[aria-expanded='true'] { + background: var(--S2AC-Palette-gray-300); +} + +/* Per Figma node 54:8489 ("Widget Action Buttons", State=Focus) — the + keyboard-focus ring replaces the hover/expanded background change above + (focus keeps the Default background) rather than stacking with it. */ +.me-control:focus-visible { + background: var(--color-gray-150); + outline: 2px solid var(--color-focus-ring-strong); + outline-offset: 2px; +} + +.me-pill { + /* text-overflow: ellipsis has no effect on flex-laid-out content (the + centering this needs no longer clips with a visible "…", just a hard + cut on both sides) — block + line-height/text-align gets the same + vertical/horizontal centering while keeping ellipsis truncation working. */ + display: block; + flex-shrink: 1; + box-sizing: border-box; + min-width: 46px; + max-width: 110px; + height: 46px; + line-height: 46px; + padding: 0 var(--spacing-200); + background: var(--color-white); + border-radius: 10px; + box-shadow: var(--Drop-shadow-emphasized-default); + font-size: var(--Global-Typography-Size-Label-Label-L); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--cyan-1500); +} + +.me-swatch { + align-self: center; + width: 34px; + height: 34px; + flex-shrink: 0; + border-radius: 10px; + border: 1px solid var(--Palette-transparent-white-800); + background-size: cover; + background-position: center; + box-shadow: var(--Drop-shadow-emphasized-default); +} + +.me-control-label { + flex-shrink: 0; + font-weight: var(--heading-font-weight-medium); + font-size: var(--Global-Typography-Size-Label-Label-M); + color: var(--Alias-content-neutral-default); + white-space: nowrap; +} + +.me-panel { + position: relative; + width: 100%; + height: 65px; + margin-top: var(--spacing-80); + overflow: hidden; +} + +.me-row { + position: absolute; + top: 4px; + left: 0; + display: flex; + align-items: center; + width: 100%; + height: 58px; + box-sizing: border-box; + background: var(--color-gray-150); + border: 1px solid var(--S2AC-Palette-transparent-black-50); + border-radius: var(--Radius-corner-radius-200); + transform: translateY(96.5px); + transition: transform 0.3s cubic-bezier(0.65, 0, 0.35, 1); +} + +.mini-editor[data-me-panel='fonts'] .me-row--fonts, +.mini-editor[data-me-panel='colour'] .me-row--colour, +.me-modal-card-root[data-me-panel='fonts'] .me-row--fonts, +.me-modal-card-root[data-me-panel='colour'] .me-row--colour { + transform: translateY(0); +} + +.me-row--fonts { + gap: var(--spacing-75); + padding: var(--spacing-80) var(--spacing-300) var(--spacing-80) var(--spacing-80); + overflow-x: auto; + scrollbar-width: none; +} + +.me-row--fonts::-webkit-scrollbar { + display: none; +} + +.me-font { + flex: 0 0 auto; + min-width: 46px; + height: 46px; + padding: 0 var(--spacing-200); + border: none; + background: transparent; + border-radius: var(--corner-radius-80); + font-size: var(--Global-Typography-Size-Label-Label-L); + white-space: nowrap; + cursor: pointer; +} + +/* Per Figma node 54:8516 ("Font Selector", state=Focus). */ +.me-font:focus-visible { + outline: 2px solid var(--color-focus-ring-strong); + outline-offset: 2px; +} + +.me-font.is-selected { + background: var(--color-white); + border-radius: 10px; + box-shadow: var(--Drop-shadow-emphasized-default); +} + +.me-row--colour { + justify-content: space-between; + gap: var(--spacing-100); + padding: 0 var(--spacing-250); + overflow-x: auto; + scrollbar-width: none; +} + +.me-row--colour::-webkit-scrollbar { + display: none; +} + +.me-swatch-btn { + position: relative; + flex: 0 0 auto; + width: 28px; + height: 28px; + padding: 0; + border-radius: var(--Corner-radius-corner-radius-500); + border: 1px solid var(--S2AC-Palette-transparent-white-100); + background: none; + cursor: pointer; + box-shadow: var(--Drop-shadow-emphasized-default); + transition: transform 0.12s ease; +} + +.me-swatch-btn:hover { + transform: scale(1.08); +} + +/* Per Figma node 54:8539 ("Colour Selector", property1=Focus) — ring sits + flush on the swatch's own edge (no outline-offset), unlike the other + controls' offset rings, matching that reference exactly. */ +.me-swatch-btn:focus-visible { + outline: 2px solid var(--color-focus-ring-strong); + outline-offset: 0; +} + +.me-swatch-btn.is-selected { + border: 2px solid var(--S2-Buttons-Accent-Color-Default); + box-shadow: var(--Drop-shadow-emphasized-hover); +} + +.me-swatch-fill { + position: absolute; + inset: 0; + border-radius: var(--Corner-radius-corner-radius-500); + background-size: cover; + background-position: center; +} + +.mini-editor-decorations { + /* Anchored to the header (not the stage) so cards can start a little below + the header's top edge and extend down to the editor's bottom edge — + the stage's own box only covers the editor's height, which isn't tall + enough to reach up alongside the header per the Figma reference. The + stage height below is the widget's fixed height (350px card + controls); + the gap matches the header/stage flex gap on .mini-editor. */ + position: absolute; + top: var(--spacing-700); + right: 0; + left: 0; + bottom: calc(-1 * (var(--spacing-500) + 487px)); + z-index: 0; + pointer-events: none; + display: none; +} + +/* + * 4 columns of 2 cards each, desktop only (see the >=1200px block below). + * Matches the Figma reference's two-column-per-side zig-zag: a "near" + * column, whose card's inner (right, for the left side) edge clears the + * 271px-wide widget by ~90px, and a "far" column ~90px past the near + * column's outer edge. Each column is its own flex container (cards + * stacked via `gap`, not fixed pixel positions) so a card's height can vary + * with its quote length (see p.me-deco-quote/DECO_QUOTE_CHAR_LIMIT) without + * the two cards in a column ever overlapping — the old fixed-height, + * fixed-`bottom`-per-card layout couldn't safely do that. Columns anchor + * from the bottom (not the top) since .mini-editor-decorations' bottom edge + * reliably lines up with the editor's bottom edge regardless of how tall + * the header's authored copy is — its top edge doesn't. + */ +.me-deco-col { + position: absolute; + display: flex; + flex-direction: column; + width: 180px; +} + +.me-deco-col--far-left { left: calc(50% - 811px); bottom: 160px; gap: 188px; } +.me-deco-col--near-left { left: calc(50% - 541px); bottom: 0; gap: 212px; } +.me-deco-col--far-right { left: calc(50% + 631px); bottom: 160px; gap: 188px; } +.me-deco-col--near-right { left: calc(50% + 361px); bottom: 0; gap: 212px; } + +.me-deco { + width: 180px; + pointer-events: auto; +} + +.me-deco--1 { transform: rotate(1.5deg); } +.me-deco--2 { transform: rotate(1.5deg); } +.me-deco--3 { transform: rotate(2deg); } +.me-deco--4 { transform: rotate(-2deg); } +.me-deco--5 { transform: rotate(-1.5deg); } +.me-deco--6 { transform: rotate(-1.5deg); } +.me-deco--7 { transform: rotate(-2deg); } +.me-deco--8 { transform: rotate(2deg); } + +.me-deco-card-wrap { + /* The padding-bottom extends this wrap's own hoverable box down through + the gap and the actions row below the card (see .me-deco-card / + .me-deco-actions), so moving the pointer from the card down into the + buttons never exits the hover target — :hover/:focus-within stays live + the whole way down, regardless of how tall the card itself grows. */ + position: relative; + padding-bottom: 54px; +} + +.me-deco:hover, +.me-deco:focus-within { + z-index: 5; +} + +.me-deco-card { + /* Outer sizing/rotation box — unclipped (unlike .me-deco-card-inner + below) so .me-deco-actions can anchor at `top: 100%` against this + element's own actual height without being clipped off. */ + position: relative; + width: 100%; + -webkit-user-select: none; + user-select: none; +} + +.me-deco-card-inner { + width: 100%; + /* Was a fixed height: 148px on the (then-combined) card element — now a + minimum only, matching that original look for short quotes, so the + card can grow taller (width unchanged) as a quote's length approaches + DECO_QUOTE_CHAR_LIMIT instead of ever clipping its own text. Positioned + (not static) so p.me-deco-author below can anchor to this element's own + box via position: absolute, per Figma node 54:7695/54:7699 — the author + sits at a fixed offset from the card's bottom edge regardless of quote + length, same pattern as .me-author/.me-arc-author elsewhere in this + file. */ + position: relative; + min-height: 148px; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + padding: 11px; + border: 0.5px solid var(--Palette-transparent-white-700); + border-radius: 17px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + overflow: hidden; + box-shadow: 0 5px 6px rgb(0 0 0 / 12%), 0 16px 16px rgb(0 0 0 / 8%); +} + +/* + * Fixed, uniform styling for every card regardless of which font/theme the + * fetched template itself uses — the editor (not these decorative previews) + * is where a different font/colour actually applies. Sizes to its own text + * content (no fixed height/line-clamp) since DECO_QUOTE_CHAR_LIMIT already + * caps how much text it can ever hold — see truncateQuote in + * mini-editor-widget.js — so .me-deco-card-inner (a flex column) simply + * grows to fit instead of a second, independent CSS-level clamp fighting + * that already-bounded text. + */ +/* p.me-deco-quote (not just .me-deco-quote): .mini-editor-header p — these + cards are descendants of the header, see the block's init comment on why — + is otherwise equally or more specific and, being a

, matches these too. */ +p.me-deco-quote { + /* The one flexible item in .me-deco-card-inner — grows/shrinks with the + available space and centers its own (possibly multi-line) text within + it, so a short quote still sits vertically centered in the card, not + glued to the top. padding-bottom reserves room for p.me-deco-author, + which is absolutely positioned below and so isn't otherwise accounted + for in this flex column's own height. */ + display: flex; + flex: 1 0 auto; + align-items: center; + justify-content: center; + width: 100%; + margin: 0; + padding-bottom: 20px; + color: var(--Alias-content-typography-Heading); + text-align: center; + font-family: var(--body-font-family); + font-size: var(--Global-Typography-Size-Label-Label-S); + font-style: normal; + font-weight: var(--heading-font-weight-regular); + line-height: 1.25; + word-break: break-word; +} + +/* Absolutely positioned at a fixed offset from the card's bottom edge, per + Figma node 54:7695/54:7699 (author: bottom: 18.46px on a 149px-tall + card) — same pattern as .me-author/.me-arc-author elsewhere in this + file. Anchors against .me-deco-card-inner (position: relative above), + independent of the quote's own height, so it stays put whether the card + grows taller for a longer quote or not. */ +p.me-deco-author { + position: absolute; + left: 50%; + bottom: 12px; + transform: translateX(-50%); + margin: 0; + width: 100%; + max-width: calc(100% - 22px); + overflow: hidden; + color: var(--Alias-content-typography-Heading); + text-align: center; + font-family: var(--body-font-family); + font-size: 9px; + font-style: normal; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; +} + +.me-deco-actions { + /* A percentage now correctly resolves against .me-deco-card's own actual + height — unlike the old fixed px offset this replaced, which only + matched the card's previous fixed height and broke as soon as + .me-deco-card-inner (and so .me-deco-card, which wraps it at 100% + width/auto height) could grow taller for a longer quote. .me-deco-card + is a plain unclipped box (no padding/border of its own), so `top: 100%` + lands exactly at .me-deco-card-inner's bottom edge with no + padding-box double-counting to correct for. */ + position: absolute; + top: calc(100% + var(--spacing-100)); + left: 50%; + transform: translateX(-50%); + display: flex; + gap: var(--spacing-100); + align-items: flex-start; + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; +} + +.me-deco:hover .me-deco-actions, +.me-deco:focus-within .me-deco-actions { + opacity: 1; + pointer-events: auto; +} + +.me-deco-use, +.me-deco-copy { + height: 32px; + box-sizing: border-box; + background: var(--Palette-gray-100); + border: none; + border-radius: var(--Corner-radius-corner-radius-100); + color: var(--Alias-content-neutral-default); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.me-deco-use { + display: inline-flex; + align-items: center; + padding: 7px 12px; + font-size: var(--Global-Typography-Size-Label-Label-M); + white-space: nowrap; +} + +.me-deco-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + padding: 7px; +} + +/* sp-icon-copy (see buildDecoCard) sizes/colours itself via these CSS + custom property hooks — same pattern as .me-action-icon above, no + Spectrum theme/design tokens required. */ +.me-deco-copy-icon { + --mod-icon-size: 18px; + --mod-icon-color: var(--Alias-content-neutral-default); +} + +.me-deco-use:hover, +.me-deco-copy:hover { + background: #dcdcdc; +} + +/* No dedicated focus variant for these two in Figma (the "Quote Hero Card" + component only defines Default/Hover) — added for keyboard-focus + visibility, consistent with the ring treatment used on every other + interactive element in this file. */ +.me-deco-use:focus-visible, +.me-deco-copy:focus-visible { + outline: 2px solid var(--color-focus-ring-strong); + outline-offset: 2px; +} + +.me-deco-copy.is-copied { + background: #bfe9cb; +} + +@media (width >= 1200px) { + .mini-editor-decorations { + display: block; + } +} + +/* + * Tablet/mobile carousel -------------------------------------------------- + * Below 1200px the desktop decorative cards and the fixed widget give way + * to exactly 3 slots: prev / centre / next (buildArcCarousel in + * mini-editor-widget.js re-renders each slot's content on every arrow click + * — it does not keep the full 8/9-card deck in the DOM, so nothing + * off-screen is ever clickable). The container spans the full viewport width + * so the side cards can show as much of themselves as possible with no empty + * gutter on either edge — how much actually shows is however much fits + * outside the centre card within that width, not a fixed reveal amount. + */ +.me-arc { + /* Sizing variables (--me-arc-card-w etc.) are defined on .mini-editor, + not here — see that rule for why. */ + position: relative; + display: none; + align-items: flex-start; + justify-content: center; + width: 100vw; + margin: 0 calc(50% - 50vw) var(--me-arc-gap); + height: var(--me-arc-card-h); + clip-path: inset(calc(-1 * var(--me-arc-extra-h)) 0); +} + +/* ARIA-only grouping for the 3 option cards + ghost (see buildArcCarousel) — + display: contents keeps it out of layout entirely, so each .me-arc-card's + position: absolute below still resolves against .me-arc, not this + wrapper, exactly as if the wrapper weren't there. */ +.me-arc-listbox { + display: contents; +} + +.me-carousel-mode .mini-editor-decorations { + display: none; +} + +.me-carousel-mode .me-card { + display: none; +} + +.me-carousel-mode .me-arc { + display: flex; +} + +/* Touch devices have no hover, so the action bar stays always-visible here + instead of hidden-until-hover — .me-carousel-mode already encodes this + file's one definition of "tablet or mobile" (see syncViewportMode in + mini-editor-widget.js), so this reuses it rather than a second, possibly + divergent breakpoint check. */ +.me-carousel-mode .me-actions { + opacity: 1; + pointer-events: auto; +} + +/* The widget's own max-width (542px, matching the tablet card size) only + happens to equal --me-arc-card-w on tablet — on mobile the card shrinks + to 327px but the widget didn't, so .me-controls/.me-panel below the + carousel were rendering wider than the visible editor card above them. + Tying the widget's width to the same variable keeps them in lockstep at + every breakpoint instead of matching by coincidence at just one. */ +.me-carousel-mode .mini-editor-widget { + max-width: var(--me-arc-card-w, 542px); +} + +.me-arc-card { + position: absolute; + top: 0; + left: calc(50% - (var(--me-arc-card-w) / 2)); + width: var(--me-arc-card-w); + height: var(--me-arc-card-h); + box-sizing: border-box; + padding: var(--spacing-350); + border: 0.5px solid var(--Palette-transparent-white-700); + border-radius: var(--Radius-corner-radius-200); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-shadow: 0 5px 6px rgb(0 0 0 / 12%), 0 16px 16px rgb(0 0 0 / 8%); + /* transform-origin sits far below the card, on the shared carousel + circle's own centre (--me-arc-origin-y below this card's vertical + centre) — fixed for every role, never changing between roles, so each + role's transform is a single rotate() with nothing else to interpolate. + Rotating about that one distant point is what keeps every card's + bottom edge on the circle throughout a transition, not just at rest — + see --me-arc-origin-y in mini-editor.css. */ + transform-origin: 50% calc(50% + var(--me-arc-origin-y)); + transition: transform 1s ease-in-out, opacity 1s ease-in-out; + -webkit-user-select: none; + user-select: none; +} + +.me-arc-card--center { + z-index: 3; + cursor: default; +} + +.me-arc-card--prev, +.me-arc-card--next { + z-index: 1; + cursor: pointer; +} + +.me-arc-card--prev { + transform: rotate(-8deg); +} + +.me-arc-card--next { + transform: rotate(8deg); +} + +/* Off-screen staging position for a recycled card (see mini-editor-widget.js + setRole's `instant` path) — parked further out than the resting + prev/next spot, same rotation direction but twice the swing, so the very + next transition (turning off `instant`) animates it rotating in from + beyond the edge instead of popping directly into its final slot. Since + every role rotates about the same distant --me-arc-origin-y point (see + .me-arc-card), doubling the *centre-point* displacement to 2x the + resting prev/next distance means doubling sin(angle), not the angle + itself: asin(2 * sin(8deg)) ≈ 16.1615deg. */ +.me-arc-card--stage-prev { + transform: rotate(-16.1615deg); +} + +.me-arc-card--stage-next { + transform: rotate(16.1615deg); +} + +/* Ghost: a 4th, non-interactive card (see buildArcGhost in + mini-editor-widget.js) that plays the outgoing card's visible exit — + continuing further out on its own side while fading — so the recycled real + card's old content doesn't just vanish when it's silently restaged to enter + from the opposite side. Sits behind the 3 real cards (z-index 0) and starts + invisible; JS toggles it to the plain --prev/--next resting class first + (so it appears exactly where the real card just was) then immediately + to --exit-prev/--exit-next, which is what's actually transitioned. */ +.me-arc-ghost { + z-index: 0; + pointer-events: none; + opacity: 0; + cursor: default; +} + +.me-arc-ghost.me-arc-ghost--visible { + opacity: 1; +} + +.me-arc-card--exit-prev { + transform: rotate(-16.1615deg); +} + +.me-arc-card--exit-next { + transform: rotate(16.1615deg); +} + +.me-arc-ghost.me-arc-ghost--visible.me-arc-card--exit-prev, +.me-arc-ghost.me-arc-ghost--visible.me-arc-card--exit-next { + opacity: 0; +} + +.me-arc-quote { + margin: 0; + font-size: var(--Global-Typography-Size-Label-Label-L); + line-height: 1.2; + text-align: center; + word-break: break-word; + color: var(--Alias-content-typography-Heading); +} + +.me-arc-author { + position: absolute; + left: 50%; + bottom: var(--spacing-200); + transform: translateX(-50%); + margin: 0; + font-size: var(--Global-Typography-Size-Label-Label-S); + white-space: nowrap; + color: var(--Alias-content-typography-Body); +} + +.me-arc-nav { + position: absolute; + top: 50%; + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + padding: 0; + transform: translateY(-50%); + background: rgb(0 0 0 / 22%); + border: 1px solid rgb(255 255 255 / 20%); + border-radius: var(--Corner-radius-corner-radius-500); + cursor: pointer; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + transition: background-color 0.15s ease; +} + +/* No dedicated hover/focus variant in Figma's "drag-arrows" component + (Default only, since these primarily serve touch devices) — darkened + hover + a focus-visible ring added for mouse/keyboard use, consistent + with every other interactive element in this file. */ +.me-arc-nav:hover { + background: rgb(0 0 0 / 32%); +} + +.me-arc-nav:focus-visible { + outline: 2px solid var(--color-focus-ring-strong); + outline-offset: 2px; +} + +.me-arc-nav img { + width: 20px; + height: 20px; +} + +/* Nudged past the centre card's edge (not flush on it) per Figma's + drag-arrows frame, where each button sits centred slightly outside the + card boundary rather than straddling it evenly. */ +.me-arc-nav--prev { + left: calc(50% - (var(--me-arc-card-w) / 2) - var(--spacing-400)); +} + +.me-arc-nav--next { + right: calc(50% - (var(--me-arc-card-w) / 2) - var(--spacing-400)); +} + +/* + * Mobile bottom sheet ----------------------------------------------------- + * <=767px only: the font/colour pickers become a slide-up sheet instead of + * the inline expanding row above, per Figma frames 0-18589/0-18658. Driven + * by the same data-me-panel attribute as the inline row — see .me-row + * above for the tablet/desktop behaviour, hidden below at this breakpoint. + */ +.me-sheet-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: none; +} + +.me-sheet { + position: absolute; + inset-inline: 0; + inset-block-end: 0; + max-height: 80vh; + overflow-y: auto; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--spacing-200); + background: var(--color-white); + border-radius: 24px 24px 0 0; + padding: var(--spacing-100) var(--spacing-300) var(--spacing-400); + transform: translateY(100%); + transition: transform 0.34s cubic-bezier(0.22, 1, 0.36, 1); +} + +.me-sheet-overlay.is-open .me-sheet { + transform: translateY(0); +} + +.me-sheet-handle { + width: 50px; + height: 4px; + margin: var(--spacing-100) auto 0; + background: rgb(0 0 0 / 15%); + border-radius: 999px; + flex-shrink: 0; +} + +.me-sheet-title { + margin: 0; + font-weight: var(--heading-font-weight-medium); + font-size: var(--Global-Typography-Size-Label-Label-L); + text-align: center; +} + +.me-sheet-grid { + display: grid; + gap: var(--spacing-200); +} + +.me-sheet-grid--fonts { + grid-template-columns: repeat(2, 1fr); +} + +.me-sheet-grid--fonts .me-font { + width: 100%; + height: 52px; +} + +.me-sheet-grid--colour { + grid-auto-flow: column; + grid-template-rows: repeat(2, 46px); + grid-auto-columns: 46px; + overflow-x: auto; + padding-bottom: var(--spacing-100); + scrollbar-width: none; +} + +.me-sheet-grid--colour::-webkit-scrollbar { + display: none; +} + +.me-sheet-grid--colour .me-swatch-btn { + width: 32px; + height: 32px; + align-self: center; + justify-self: center; +} + +@media (width <= 767px) { + /* "Background colour" control label reads "Background" only at this + width — narrower control column has less room for the full label. */ + .me-control-label-suffix { + display: none; + } + + .me-quote { + font-size: var(--Global-Typography-Size-Label-Label-L); + } + + .me-arc-quote { + font-size: var(--Global-Typography-Size-Label-Label-M); + } + + .me-arc-nav { + width: 40px; + height: 40px; + } + + .me-controls { + gap: var(--spacing-250); + } + + .me-panel { + display: none; + } + + .me-sheet-overlay { + display: block; + } +} diff --git a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js new file mode 100644 index 000000000..59ff811b1 --- /dev/null +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js @@ -0,0 +1,1089 @@ +/** + * Mini Editor Widget + * + * A configurable in-page quote-editing surface. It renders one editing stage + * that adapts across breakpoints from the same config — the desktop widget + * card + zig-zag decorative previews, and the tablet/mobile 3-card arc + * carousel — plus the shared font/background controls (inline expanding rows + * on tablet/desktop, bottom sheets on mobile). The caller supplies the data + * (content header, font options, background cards, quotes); the widget owns + * all rendering, interaction, and animation, so a block only has to fetch + * data and mount the result. Pass `decorations: false` to skip the + * decorative cards / arc carousel entirely and render just the centre editor + * card, e.g. for a host that shows the widget inside a modal. + * + * UI/UX is intentionally identical to the original in-block implementation — + * this widget is an extraction of that surface, not a redesign. It is plain + * vanilla DOM with one exception: the top-right action bar (`topActions`, + * Figma node 1099-5050) renders real Spectrum Web Components icons + * (`sp-icon-*`), lazily loaded from `../spectrum/dist/icons-workflow.js` + * only when `topActions` is non-empty — no ``/Spectrum design + * tokens are required, since the icons are sized/coloured directly via their + * own `--mod-icon-size`/`--mod-icon-color` CSS hooks (see mini-editor-widget.css). + * + * Usage: + * import createMiniEditorWidget from + * '../../scripts/widgets/mini-editor-widget/mini-editor-widget.js'; + * + * const editor = await createMiniEditorWidget({ + * root: block, // element the widget sets state attrs/vars on + * content: headerEl, // authored heading/subcopy/CTA lockup + * topActions: [ // top-right hover action bar (Figma 1099-5050) + * { type: 'edit', onClick: onEdit }, + * { type: 'share', onClick: onShare }, + * { type: 'download', onClick: onDownload }, + * ], + * fontOptions: [...], // { label, font, italic, weight } + * backgrounds: { // our fetched card set + quotes + * cardSet: [{ card: { id, bg }, quote, author }, ...], + * decoCount: 8, + * }, + * }); + * stageParent.append(editor.stage); + * headerEl.append(editor.decorations); + */ + +let createTag; +let getIconElementDeprecated; + +const DECO_CARD_COUNT = 8; +const DECO_QUOTE_CHAR_LIMIT = 216; +const EDITOR_QUOTE_CHAR_LIMIT = 248; + +/** + * Truncates display text at a whole-word boundary within `limit` characters, + * appending "…" — never mid-word. Display-only: callers keep the original, + * untruncated string for copy-to-clipboard and accessible names, so nothing + * a user actually acts on is ever silently shortened. + */ +function truncateQuote(quote, limit) { + if (quote.length <= limit) return quote; + const cut = quote.slice(0, limit); + const lastSpace = cut.lastIndexOf(' '); + const trimmed = lastSpace > 0 ? cut.slice(0, lastSpace) : cut; + return `${trimmed}…`; +} + +/** + * True at the same <=767px width mini-editor-widget.css switches the inline + * font/colour row for the mobile bottom sheet. Checked live (not cached) at + * each click site that needs it, since panelMode itself is a static string + * baked in at widget creation and can't otherwise react to a resize/rotation + * while a host (e.g. the modal) stays open across it. + */ +function isMobileSheetWidth() { + return window.matchMedia('(width <= 767px)').matches; +} + +/** + * Builds one font-option button. Used to populate both the tablet/desktop + * inline row and the mobile bottom-sheet grid from the same fontOptions + * list, so a single `selectFont` closure can keep both in sync regardless + * of which one is visible at the current breakpoint. + */ +function buildFontButton(opt, index, onPick) { + const style = [ + `font-family:${opt.font};`, + opt.italic ? 'font-style:italic;' : '', + opt.weight ? `font-weight:${opt.weight};` : '', + ].join(''); + const btn = createTag('button', { + type: 'button', + class: `me-font${index === 0 ? ' is-selected' : ''}`, + style, + 'data-font': opt.font, + role: 'option', + 'aria-selected': index === 0 ? 'true' : 'false', + }); + btn.textContent = opt.label; + btn.addEventListener('click', () => onPick(opt)); + return btn; +} + +function buildFontControl(root, fontOptions, onSelect, panelMode) { + const control = createTag('button', { + type: 'button', + class: 'me-control me-control--font', + 'aria-expanded': 'false', + }); + const pill = createTag('span', { class: 'me-pill' }); + pill.textContent = fontOptions[0].label; + const label = createTag('span', { class: 'me-control-label' }); + label.textContent = 'Font style'; + control.append(pill, label); + + const panel = createTag('div', { + class: 'me-row me-row--fonts', + role: 'listbox', + 'aria-label': 'Font style', + }); + const sheetGrid = createTag('div', { + class: 'me-sheet-grid me-sheet-grid--fonts', + role: 'listbox', + 'aria-label': 'Font style', + }); + + function selectFont(opt) { + root.style.setProperty('--me-quote-font', opt.font); + root.style.setProperty('--me-quote-font-style', opt.italic ? 'italic' : 'normal'); + root.style.setProperty('--me-quote-font-weight', opt.weight || 'normal'); + pill.textContent = opt.label; + pill.style.fontFamily = opt.font; + pill.style.fontStyle = opt.italic ? 'italic' : 'normal'; + pill.style.fontWeight = opt.weight || 'normal'; + [panel, sheetGrid].forEach((container) => { + container.querySelectorAll('.me-font').forEach((f) => { + const isMatch = f.dataset.font === opt.font; + f.classList.toggle('is-selected', isMatch); + f.setAttribute('aria-selected', String(isMatch)); + }); + }); + } + + const onPick = (opt) => { + selectFont(opt); + onSelect?.(opt); + }; + fontOptions.forEach((opt, index) => { + panel.append(buildFontButton(opt, index, onPick)); + sheetGrid.append(buildFontButton(opt, index, onPick)); + }); + + // Applies fontOptions[0] as --me-quote-font immediately, instead of + // leaving the block on its CSS default (--body-font-family) until the + // user's first click — the first .me-font button already renders + // is-selected, so the quote itself should match on load, not just the + // control's own affordances. + selectFont(fontOptions[0]); + + control.addEventListener('click', () => { + const isOpen = root.getAttribute('data-me-panel') === 'fonts'; + // In always-open-inline mode (the modal, tablet/desktop only — mobile + // falls back to the normal bottom-sheet toggle below), one panel must + // always stay open — clicking the already-open control's own trigger is + // a no-op instead of collapsing to 'none', since there is no "both + // closed" state for this host to fall back to. + if (panelMode === 'always-open-inline' && isOpen && !isMobileSheetWidth()) return; + root.setAttribute('data-me-panel', isOpen ? 'none' : 'fonts'); + control.setAttribute('aria-expanded', String(!isOpen)); + }); + + return { + control, panel, sheetGrid, selectFont, + }; +} + +/** + * Builds one background-colour swatch button. Shared between the inline + * row and the mobile bottom-sheet grid, same rationale as buildFontButton. + */ +function buildSwatchButton(card, index, onPick) { + const btn = createTag('button', { + type: 'button', + class: `me-swatch-btn${index === 0 ? ' is-selected' : ''}`, + 'data-bg': card.bg, + role: 'option', + 'aria-selected': index === 0 ? 'true' : 'false', + 'aria-label': `Background ${index + 1}`, + }); + const fill = createTag('span', { + class: 'me-swatch-fill', + style: `background-image:url("${card.bg}")`, + }); + btn.append(fill); + btn.addEventListener('click', () => onPick(card)); + return btn; +} + +function buildColorControl(root, cards, onSelect, panelMode) { + const control = createTag('button', { + type: 'button', + class: 'me-control me-control--colour', + 'aria-expanded': 'false', + }); + const swatch = createTag('span', { class: 'me-swatch' }); + // "colour" drops on mobile (label reads "Background" only there) — kept + // as a separate span hidden via CSS rather than swapping textContent, so + // there's no JS branching on viewport width for what's purely a label fit. + const label = createTag('span', { class: 'me-control-label' }, [ + 'Background', + createTag('span', { class: 'me-control-label-suffix' }, [' colour']), + ]); + control.append(swatch, label); + + const panel = createTag('div', { + class: 'me-row me-row--colour', + role: 'listbox', + 'aria-label': 'Background colour', + }); + // All fetched backgrounds (not just the desktop decoration subset), same + // as the desktop inline row — the sheet's grid scrolls to fit them all. + const sheetGrid = createTag('div', { + class: 'me-sheet-grid me-sheet-grid--colour', + role: 'listbox', + 'aria-label': 'Background colour', + }); + + function selectSwatch(bg) { + root.style.setProperty('--me-card-bg', `url("${bg}")`); + swatch.style.backgroundImage = `url("${bg}")`; + [panel, sheetGrid].forEach((container) => { + container.querySelectorAll('.me-swatch-btn').forEach((s) => { + const isMatch = s.dataset.bg === bg; + s.classList.toggle('is-selected', isMatch); + s.setAttribute('aria-selected', String(isMatch)); + }); + }); + } + + const onPick = (card) => { + selectSwatch(card.bg); + onSelect?.(card); + }; + cards.forEach((card, index) => { + panel.append(buildSwatchButton(card, index, onPick)); + sheetGrid.append(buildSwatchButton(card, index, onPick)); + }); + + if (cards[0]) swatch.style.backgroundImage = `url("${cards[0].bg}")`; + + control.addEventListener('click', () => { + const isOpen = root.getAttribute('data-me-panel') === 'colour'; + // See buildFontControl's identical guard for always-open-inline mode. + if (panelMode === 'always-open-inline' && isOpen && !isMobileSheetWidth()) return; + root.setAttribute('data-me-panel', isOpen ? 'none' : 'colour'); + control.setAttribute('aria-expanded', String(!isOpen)); + }); + + return { + control, panel, sheetGrid, selectSwatch, + }; +} + +/** + * Mobile-only bottom sheet (<=767px) for the font/colour pickers, per Figma + * frames 0-18589/0-18658. Reuses the same open/close/focus-trap/scroll-lock + * pattern as font-generator's panel.js. Driven by the same `data-me-panel` + * attribute the tablet/desktop inline row already uses — CSS alone decides + * whether that attribute shows the inline row or this sheet at a given + * breakpoint, so there's no JS branching on viewport width here. + */ +function buildBottomSheet(root, a11y, kind, title, contentEl) { + const { + trapFocus, handleEscapeClose, disableBackgroundScroll, restoreBackgroundScroll, + } = a11y; + const overlay = createTag('div', { class: 'me-sheet-overlay', 'aria-hidden': 'true', inert: '' }); + const sheet = createTag('div', { + class: 'me-sheet', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': title, + tabindex: '-1', + }); + const handle = createTag('div', { class: 'me-sheet-handle', 'aria-hidden': 'true' }); + const titleEl = createTag('p', { class: 'me-sheet-title' }); + titleEl.textContent = title; + sheet.append(handle, titleEl, contentEl); + overlay.append(sheet); + + let focusTrap = null; + let escapeRelease = null; + let previouslyFocused = null; + + function close() { + if (root.getAttribute('data-me-panel') !== kind) return; + root.setAttribute('data-me-panel', 'none'); + } + + function onPanelChange() { + const isOpen = root.getAttribute('data-me-panel') === kind; + overlay.classList.toggle('is-open', isOpen); + overlay.setAttribute('aria-hidden', String(!isOpen)); + if (isOpen) { + overlay.removeAttribute('inert'); + previouslyFocused = document.activeElement; + disableBackgroundScroll(); + sheet.focus(); + focusTrap = trapFocus(sheet); + escapeRelease = handleEscapeClose(sheet, close); + } else { + overlay.setAttribute('inert', ''); + restoreBackgroundScroll(); + focusTrap?.release(); + focusTrap = null; + escapeRelease?.release(); + escapeRelease = null; + previouslyFocused?.focus(); + previouslyFocused = null; + } + } + + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + + return { overlay, onPanelChange }; +} + +// Fixed per Figma node 1099-5050 — topActions only ever picks which of these +// 3 to show and supplies their handler, it doesn't define new icons/labels. +// Tag names are real Spectrum Web Components icons (see the dynamic import +// in createMiniEditorWidget) — all 3 are already part of the curated +// icons-workflow bundle (scripts/widgets/spectrum/build.mjs). +const TOP_ACTION_DEFS = { + edit: { label: 'Edit', icon: 'sp-icon-edit' }, + share: { label: 'Share', icon: 'sp-icon-share-android' }, + download: { label: 'Download', icon: 'sp-icon-download' }, +}; + +/** + * Top-right hover action bar, per Figma node 1099-5050. Callback props (not + * events) — matches this file's existing intra-widget wiring + * (onSelect/onFontOrColourChange) rather than the CustomEvent used for + * mini-editor:use-quote, which exists only to decouple separate blocks. + * `topActions` is `[{ type: 'edit'|'share'|'download', onClick }, ...]` — + * only the types actually supplied are rendered, in the given order. + * Icon elements must be Spectrum custom elements already registered by the + * time this runs — see the icons-workflow.js dynamic import in + * createMiniEditorWidget, which awaits before calling buildWidget. + */ +function buildMiniEditorActions(topActions = []) { + const bar = createTag('div', { class: 'me-actions' }); + topActions.forEach(({ type, onClick }) => { + const def = TOP_ACTION_DEFS[type]; + if (!def) return; + const icon = createTag(def.icon, { class: 'me-action-icon', 'aria-hidden': 'true' }); + const btn = createTag('button', { + type: 'button', + class: `me-action me-action--${type}`, + 'aria-label': def.label, + }, [icon]); + btn.addEventListener('click', () => onClick?.()); + bar.append(btn); + }); + return bar; +} + +function buildWidget(root, a11y, cardSet, fontOptions, topActions, panelMode) { + const widget = createTag('div', { class: 'mini-editor-widget' }); + const card = createTag('div', { class: 'me-card' }); + const first = cardSet[0] || { quote: '', author: '' }; + let contentModel = { + quote: first.quote, + author: first.author || '', + backgroundUrl: first.card?.bg || '', + font: { + family: fontOptions[0]?.font || 'sans-serif', + style: fontOptions[0]?.italic ? 'italic' : 'normal', + weight: fontOptions[0]?.weight || 'normal', + }, + }; + + const updateContentModel = (patch) => { + contentModel = { + ...contentModel, + ...patch, + font: patch.font ? { ...contentModel.font, ...patch.font } : contentModel.font, + }; + }; + + const quoteWrap = createTag('div', { + class: 'me-quote-wrap', + role: 'button', + tabindex: '0', + 'aria-describedby': 'me-quote-wrap-hint', + }); + const quoteEl = createTag('div', { class: 'me-quote' }); + // The full, untruncated quote — kept separate from quoteEl's own display + // text (which truncates at EDITOR_QUOTE_CHAR_LIMIT) so copy-to-clipboard + // and the accessible name below always use the complete text, never the + // "…"-shortened version sighted users see on a long quote. + let currentQuote = first.quote; + const renderQuote = (quote) => { + currentQuote = quote; + const truncated = truncateQuote(quote, EDITOR_QUOTE_CHAR_LIMIT); + quoteEl.textContent = truncated; + // Only needed once the display text is actually shortened — leaving + // this off otherwise keeps aria-describedby (below) as the sole + // accessible-name influence, same as before, for the common case. + if (truncated === quote) quoteWrap.removeAttribute('aria-label'); + else quoteWrap.setAttribute('aria-label', quote); + }; + + // aria-describedby (not aria-label) so the accessible name stays the + // visible quote text itself — an aria-label here would replace it + // entirely, leaving screen reader users with "Copy quote to clipboard, + // button" and no indication of which quote (see label-content-name-mismatch). + // Overridden with an explicit aria-label (see renderQuote above) only + // when the visible text is truncated, so the accessible name is always + // the full quote in that case instead of the shortened text it would + // otherwise default to. + const hint = createTag('span', { id: 'me-quote-wrap-hint', class: 'sr-only' }, ['Copy quote to clipboard']); + const tip = createTag('span', { class: 'me-tip', 'aria-hidden': 'true' }, [ + createTag('span', { class: 'me-tip-box' }, ['Click to copy quote']), + ]); + quoteWrap.append(quoteEl, hint, tip); + renderQuote(first.quote); + + const authorEl = createTag('div', { class: 'me-author' }); + authorEl.textContent = first.author; + authorEl.style.display = first.author ? '' : 'none'; + + card.append(quoteWrap, authorEl); + widget.append(card); + + // Sibling of .me-card/.me-arc, not nested inside .me-card, so the same + // element and top-right CSS anchor (against .mini-editor-widget) work + // unchanged whether the desktop card or the tablet/mobile arc carousel is + // the one currently visible. + widget.append(buildMiniEditorActions(topActions)); + + const doCopy = async () => { + // currentQuote (not quoteEl.textContent) — the full quote, even when + // the visible text is truncated (see renderQuote). + const ok = await a11y.copyQuoteToClipboard(currentQuote, authorEl.textContent); + if (ok) { + quoteWrap.classList.add('is-copied'); + setTimeout(() => quoteWrap.classList.remove('is-copied'), 1200); + } + }; + quoteWrap.addEventListener('click', doCopy); + quoteWrap.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + doCopy(); + } + }); + + if (first.card) { + root.style.setProperty('--me-card-bg', `url("${first.card.bg}")`); + } + + // Set by the widget factory once the arc carousel exists, so picking a + // font/colour here also updates the carousel's centre card on tablet/mobile + // — not just the desktop widget's own .me-card (which the CSS vars above + // already cover regardless of listener wiring). + let onFontOrColourPick = () => {}; + + const controls = createTag('div', { class: 'me-controls' }); + const { + control: fontControl, + panel: fontPanel, + sheetGrid: fontSheetGrid, + selectFont, + } = buildFontControl(root, fontOptions, (font) => onFontOrColourPick({ font }), panelMode); + const { + control: colourControl, + panel: colourPanel, + sheetGrid: colourSheetGrid, + selectSwatch, + } = buildColorControl( + root, + cardSet.map((c) => c.card), + (bgCard) => onFontOrColourPick({ card: bgCard }), + panelMode, + ); + controls.append(fontControl, colourControl); + + const panelWrap = createTag('div', { class: 'me-panel' }); + panelWrap.append(fontPanel, colourPanel); + + const fontSheet = buildBottomSheet(root, a11y, 'fonts', 'Choose a font style', fontSheetGrid); + const colourSheet = buildBottomSheet(root, a11y, 'colour', 'Choose a background colour', colourSheetGrid); + + widget.append(controls, panelWrap, fontSheet.overlay, colourSheet.overlay); + + // Single MutationObserver on data-me-panel drives both sheets (mobile) and + // the aria-expanded state on both trigger buttons (all breakpoints) — the + // inline row's own visibility is pure CSS (`[data-me-panel='fonts']`). + const panelObserver = new MutationObserver(() => { + const openPanel = root.getAttribute('data-me-panel'); + fontControl.setAttribute('aria-expanded', String(openPanel === 'fonts')); + colourControl.setAttribute('aria-expanded', String(openPanel === 'colour')); + fontSheet.onPanelChange(); + colourSheet.onPanelChange(); + }); + panelObserver.observe(root, { attributes: true, attributeFilter: ['data-me-panel'] }); + + const onDocClick = (e) => { + // always-open-inline (the modal) on tablet/desktop: one of font/colour + // always stays open — including against the very "Create a design" + // click that opens the modal in the first place, which document-click- + // bubbles here same as any other outside click. Mobile falls back to + // the normal bottom-sheet behaviour (close on outside click), same as + // the inline block. + if (panelMode === 'always-open-inline' && !isMobileSheetWidth()) return; + if (!widget.contains(e.target)) { + root.setAttribute('data-me-panel', 'none'); + } + }; + document.addEventListener('click', onDocClick); + + return { + widget, + useQuote: ({ + quote, author, card: bgCard, font, + }) => { + updateContentModel({ + quote, + author: author || '', + ...(bgCard ? { backgroundUrl: bgCard.bg } : {}), + ...(font ? { + font: { + family: font.font, + style: font.italic ? 'italic' : 'normal', + weight: font.weight || 'normal', + }, + } : {}), + }); + renderQuote(quote); + authorEl.textContent = author || ''; + authorEl.style.display = author ? '' : 'none'; + if (bgCard) selectSwatch(bgCard.bg); + if (font) selectFont(font); + }, + getContentModel: () => ({ ...contentModel, font: { ...contentModel.font } }), + onFontOrColourChange: (listener) => { + onFontOrColourPick = (patch) => { + if (patch.font) { + updateContentModel({ + font: { + family: patch.font.font, + style: patch.font.italic ? 'italic' : 'normal', + weight: patch.font.weight || 'normal', + }, + }); + } + if (patch.card) updateContentModel({ backgroundUrl: patch.card.bg }); + listener(patch); + }; + }, + destroy: () => { + panelObserver.disconnect(); + document.removeEventListener('click', onDocClick); + }, + }; +} + +function buildDecoCard(a11y, entry, useQuote) { + const { card, quote, author } = entry; + const deco = createTag('div', { class: 'me-deco', tabindex: '-1' }); + const cardWrap = createTag('div', { class: 'me-deco-card-wrap' }); + // .me-deco-card is the outer sizing/rotation box and anchors the actions + // row below it (`top: 100%`, see CSS) — it must stay unclipped for that, + // so the background image + rounded-corner clipping live one level in, + // on .me-deco-card-inner, instead of on this element directly like before + // (when the card's fixed height made the two concerns interchangeable). + const inner = createTag('div', { class: 'me-deco-card' }); + const clipped = createTag('div', { + class: 'me-deco-card-inner', + style: `background-image:url("${card.bg}")`, + }); + inner.append(clipped); + // Fixed font/style for every card — see .me-deco-quote in CSS — so no + // per-instance font styling here; only the editor's own selection varies. + // Display text only truncates at DECO_QUOTE_CHAR_LIMIT (buildCardSet in + // mini-editor.js already prefers quotes under this limit for these cards, + // so this is a rarely-hit fallback) — `quote` itself stays untruncated + // below, for useQuote/copy/aria so nothing a user acts on is ever + // silently shortened. + const quoteP = createTag('p', { class: 'me-deco-quote' }); + quoteP.textContent = truncateQuote(quote, DECO_QUOTE_CHAR_LIMIT); + clipped.append(quoteP); + if (author) { + const authorP = createTag('p', { class: 'me-deco-author' }); + authorP.textContent = author; + clipped.append(authorP); + } + + const actions = createTag('div', { class: 'me-deco-actions' }); + const attribution = author ? `"${quote}" — ${author}` : `"${quote}"`; + const useBtn = createTag('button', { + type: 'button', + class: 'me-deco-use', + 'aria-label': `Use this quote: ${attribution}`, + }); + useBtn.textContent = 'Use this quote'; + useBtn.addEventListener('click', () => useQuote(entry)); + + const copyBtn = createTag('button', { + type: 'button', + class: 'me-deco-copy', + 'aria-label': `Copy quote: ${attribution}`, + }, [createTag('sp-icon-copy', { class: 'me-deco-copy-icon', 'aria-hidden': 'true' })]); + copyBtn.addEventListener('click', async () => { + const ok = await a11y.copyQuoteToClipboard(quote, author); + if (ok) { + copyBtn.classList.add('is-copied'); + setTimeout(() => copyBtn.classList.remove('is-copied'), 1200); + } + }); + + actions.append(useBtn, copyBtn); + // Child of .me-deco-card (not a sibling in cardWrap) so its `top: 100%` + // (see CSS) resolves against the card's own actual height — which now + // varies with quote length (see .me-deco-card's min-height) — instead of + // a fixed pixel guess that only ever matched the card's old fixed height. + inner.append(actions); + cardWrap.append(inner); + deco.append(cardWrap); + return deco; +} + +// Cards 1-8 (see buildDecoCards) group into 4 vertical columns of 2, each +// its own flex container (see .me-deco-col--* in CSS) so a column's cards +// space apart via `gap` — which adapts as a card's own height varies with +// its quote length — rather than the fixed-pixel absolute positions this +// replaced. Column membership mirrors the original zig-zag exactly: cards +// 1/3 and 5/7 were always the "far" columns (bigger inter-card gap), 2/4 +// and 6/8 the "near" columns (smaller gap) — see the far/near CSS classes. +const DECO_COLUMNS = [ + { cardIndexes: [0, 2], className: 'me-deco-col--far-left' }, + { cardIndexes: [1, 3], className: 'me-deco-col--near-left' }, + { cardIndexes: [4, 6], className: 'me-deco-col--far-right' }, + { cardIndexes: [5, 7], className: 'me-deco-col--near-right' }, +]; + +function buildDecoCards(a11y, cardSet, useQuote) { + // Not aria-hidden: unlike a purely decorative background image, each card + // here holds two real, focusable actions ("Use this quote" / "Copy quote") + // — hiding the wrapper from assistive tech would leave those buttons in + // the tab order but silently unannounced (see aria-hidden-focus). + const wrap = createTag('div', { class: 'mini-editor-decorations' }); + // cardSet[0] powers the main widget; decorative cards use the rest. + const decoEntries = cardSet.slice(1, 1 + DECO_CARD_COUNT); + const decos = decoEntries.map((entry, i) => { + const deco = buildDecoCard(a11y, entry, useQuote); + deco.classList.add(`me-deco--${i + 1}`); + return deco; + }); + DECO_COLUMNS.forEach(({ cardIndexes, className }) => { + const col = createTag('div', { class: `me-deco-col ${className}` }); + cardIndexes.forEach((idx) => { if (decos[idx]) col.append(decos[idx]); }); + if (col.children.length) wrap.append(col); + }); + return wrap; +} + +/** + * Builds one of the three carousel cards. Unlike a fixed prev/centre/next + * slot (which would only ever swap content, never actually move — nothing + * to transition), each of these 3 elements keeps its own content across a + * navigation and is reassigned to a *different role's position* — that's + * what makes the 1s transform transition on .me-arc-card actually animate + * a visible slide/rotate between roles instead of an instant content pop. + */ +const ROLE_CLASSES = ['me-arc-card--prev', 'me-arc-card--center', 'me-arc-card--next', 'me-arc-card--stage-prev', 'me-arc-card--stage-next']; + +function buildArcCard(onActivate) { + const el = createTag('div', { + class: 'me-arc-card', + role: 'option', + 'aria-selected': 'false', + tabindex: '-1', + }); + const quoteP = createTag('p', { class: 'me-arc-quote' }); + const authorP = createTag('p', { class: 'me-arc-author' }); + el.append(quoteP, authorP); + el.addEventListener('click', () => onActivate(el)); + + function render(entry) { + el.style.backgroundImage = `url("${entry.card.bg}")`; + // Display text truncates at EDITOR_QUOTE_CHAR_LIMIT (same limit as the + // main widget's own quote — see renderQuote in buildWidget), applied + // uniformly regardless of this card's current role (prev/centre/next + // share one render path). role="option"'s accessible name defaults to + // this same text content, so an explicit aria-label carries the full + // quote whenever it's actually shortened — never just the truncated + // text — matching the same full-text-preserved rule as everywhere else. + const truncated = truncateQuote(entry.quote, EDITOR_QUOTE_CHAR_LIMIT); + quoteP.textContent = truncated; + if (truncated === entry.quote) el.removeAttribute('aria-label'); + else el.setAttribute('aria-label', entry.author ? `${entry.quote} — ${entry.author}` : entry.quote); + // entry.font is the carousel-wide selected font (see buildArcCarousel's + // selectedFont/withFont) when one has been picked — applies to every + // role (prev/centre/next), not just centre. Falls back to the fixed + // default (CSS) font, same as the decorative cards, until then. + quoteP.style.fontFamily = entry.font?.font || ''; + quoteP.style.fontStyle = entry.font?.italic ? 'italic' : ''; + quoteP.style.fontWeight = entry.font?.weight || ''; + authorP.textContent = entry.author || ''; + authorP.style.display = entry.author ? '' : 'none'; + } + + function setInteractivity(role) { + el.setAttribute('aria-selected', String(role === 'center')); + el.setAttribute('tabindex', role === 'center' ? '0' : '-1'); + el.style.cursor = role === 'center' ? 'default' : 'pointer'; + el.style.pointerEvents = role === 'center' ? 'none' : 'auto'; + } + + // Recycling a card (see goNext/goPrev) is a two-step move so it animates + // rotating in from beyond the edge instead of popping straight into its + // final prev/next slot: first jump instantly (no transition) to a + // further-out "stage" position with the new content, then — once that + // jump has committed — hand off to a normal, transitioned setRole() to + // the real prev/next class, which now has somewhere real to animate from. + function stageAt(stageRole) { + el.style.transition = 'none'; + el.classList.remove(...ROLE_CLASSES); + el.classList.add(`me-arc-card--${stageRole}`); + el.getBoundingClientRect(); // force reflow so the instant jump commits + el.style.transition = ''; + } + + function setRole(role) { + el.classList.remove(...ROLE_CLASSES); + el.classList.add(`me-arc-card--${role}`); + setInteractivity(role); + } + + return { + el, render, setRole, stageAt, + }; +} + +/** + * The non-interactive 4th card used purely to show the outgoing card's + * exit: when a card is recycled from prev to next (or vice versa), its OLD + * content would otherwise just vanish (the same DOM element is instantly + * staged off-screen with new content — see stageAt). This ghost briefly + * takes over that old content and role position, then transitions further + * outward while fading out, so the exit reads as one continuous circular + * motion alongside the other two cards' moves instead of a hard cut. + * aria-hidden + pointer-events: none — it's decorative only, never one of + * the 3 clickable/tabbable cards. + */ +function buildArcGhost() { + const el = createTag('div', { class: 'me-arc-card me-arc-ghost', 'aria-hidden': 'true' }); + const quoteP = createTag('p', { class: 'me-arc-quote' }); + const authorP = createTag('p', { class: 'me-arc-author' }); + el.append(quoteP, authorP); + + function playExit(entry, fromRole) { + el.style.backgroundImage = `url("${entry.card.bg}")`; + // Same display truncation as the 3 real cards (see buildArcCard's + // render) — purely cosmetic here since this ghost is aria-hidden and + // never one of the tabbable/clickable cards, but its fixed-size card + // shouldn't overflow with a long quote mid-exit either. + quoteP.textContent = truncateQuote(entry.quote, EDITOR_QUOTE_CHAR_LIMIT); + // entry.font carries the carousel-wide selected font here too (see + // buildArcCarousel's withFont), so the outgoing ghost matches whatever + // font the other 3 cards are currently showing. + quoteP.style.fontFamily = entry.font?.font || ''; + quoteP.style.fontStyle = entry.font?.italic ? 'italic' : ''; + quoteP.style.fontWeight = entry.font?.weight || ''; + authorP.textContent = entry.author || ''; + authorP.style.display = entry.author ? '' : 'none'; + + el.style.transition = 'none'; + el.classList.remove('me-arc-card--exit-prev', 'me-arc-card--exit-next', 'me-arc-ghost--visible'); + el.classList.add(`me-arc-card--${fromRole}`, 'me-arc-ghost--visible'); + el.getBoundingClientRect(); // force reflow so the starting position commits + el.style.transition = ''; + requestAnimationFrame(() => requestAnimationFrame(() => { + el.classList.remove(`me-arc-card--${fromRole}`); + el.classList.add(`me-arc-card--exit-${fromRole}`); + })); + } + + return { el, playExit }; +} + +/** + * Tablet/mobile carousel: exactly 3 cards (prev/centre/next) — never more, + * so nothing off-screen is ever clickable. Clicking an arrow rotates which + * *role* (and therefore which fixed CSS position/rotation) each of the 3 + * existing card elements occupies, so every navigation is a real transform + * change on real elements — driven entirely by the 1s CSS transition on + * .me-arc-card, not a JS-animated or instantly-popped content swap. Only + * the card moving furthest (the one leaving `next` on a "next" click, or + * leaving `prev` on a "prev" click) needs its content replaced, since it's + * re-entering the deck one step further round; the other two just carry + * their existing content into their new role. + */ +function buildArcCarousel(cardSet, useQuote, defaultFont) { + const root = createTag('div', { class: 'me-arc' }); + // Each .me-arc-card has role="option" (see buildArcCard), which axe + // requires to sit inside a role="listbox" parent (see + // aria-required-parent) — but that parent may only contain option/group + // children (aria-required-children), and .me-arc itself also holds the + // prev/next nav buttons as direct children. listboxRole wraps just the + // ghost + 3 cards so both rules are satisfied; display: contents keeps it + // out of layout so .me-arc-card's `position: absolute` (in CSS) still + // resolves against .me-arc, not this wrapper. + const listboxRole = createTag('div', { class: 'me-arc-listbox', role: 'listbox', 'aria-label': 'Template' }); + const total = cardSet.length; + let activeIndex = 0; + // The centre card can be patched independently of cardSet (e.g. the + // widget's own colour control, which applies on top of whichever entry is + // currently active) — centreOverride holds that patch and is reset + // whenever navigation moves a *different* entry into the centre. Font is + // deliberately NOT part of this: once picked, it's a carousel-wide choice + // (see selectedFont below), not tied to any one entry/role. + let centreOverride = null; + // Persists across navigation (unlike centreOverride) and applies to every + // card — prev/next/ghost included, not just centre — so picking a font + // once keeps showing on whichever entries rotate into view afterwards. + // Seeded with the first font option so the carousel renders that font on + // load, matching the desktop widget card (which the --me-quote-font CSS + // variable already applies to via buildFontControl's initial selectFont). + let selectedFont = defaultFont || null; + + const withFont = (entry) => (selectedFont ? { ...entry, font: selectedFont } : entry); + + const onActivate = (el) => { + if (el.classList.contains('me-arc-card--prev')) goPrev(); // eslint-disable-line no-use-before-define + else if (el.classList.contains('me-arc-card--next')) goNext(); // eslint-disable-line no-use-before-define + }; + const cardA = buildArcCard(onActivate); + const cardB = buildArcCard(onActivate); + const cardC = buildArcCard(onActivate); + const ghost = buildArcGhost(); + // roles[i] tracks which role each of cardA/B/C currently occupies, so + // navigation can rotate them without re-deriving role from DOM classes. + const cards = [cardA, cardB, cardC]; + let roles = ['prev', 'center', 'next']; + + function applyRoles() { + cards.forEach((card, i) => card.setRole(roles[i])); + } + + function centerEntry() { + return withFont({ ...cardSet[activeIndex], ...centreOverride }); + } + + function renderAll() { + const prevIndex = ((activeIndex - 1) % total + total) % total; + const nextIndex = (activeIndex + 1) % total; + cards[roles.indexOf('prev')].render(withFont(cardSet[prevIndex])); + cards[roles.indexOf('center')].render(centerEntry()); + cards[roles.indexOf('next')].render(withFont(cardSet[nextIndex])); + } + + function goNext() { + const prevIndexBefore = ((activeIndex - 1) % total + total) % total; + activeIndex = (activeIndex + 1) % total; + centreOverride = null; + // The card that was centre slides to prev; the card that was next + // slides into centre (both keep their existing content — that's what + // the 1s CSS transition actually animates). The card that was prev is + // recycled to become the new next — but its OLD content doesn't just + // vanish: the ghost plays a visible exit (continuing further left, + // fading out) with that old content, while the real card is silently + // restaged with new content to enter from the right. Both read as one + // continuous circular motion since they run concurrently. + ghost.playExit(withFont(cardSet[prevIndexBefore]), 'prev'); + const recycled = cards[roles.indexOf('prev')]; + cards[roles.indexOf('center')].setRole('prev'); + cards[roles.indexOf('next')].setRole('center'); + roles = roles.map((role) => ({ center: 'prev', next: 'center', prev: 'next' }[role])); + const newNextIndex = (activeIndex + 1) % total; + recycled.render(withFont(cardSet[newNextIndex])); + recycled.stageAt('stage-next'); + // Double rAF: the stage jump needs an actual painted frame before the + // transitioned move starts, or the browser can coalesce both class + // changes into one paint and skip the animation entirely. + requestAnimationFrame(() => requestAnimationFrame(() => recycled.setRole('next'))); + cards[roles.indexOf('center')].render(centerEntry()); + useQuote(cardSet[activeIndex]); + } + + function goPrev() { + const nextIndexBefore = (activeIndex + 1) % total; + activeIndex = ((activeIndex - 1) % total + total) % total; + centreOverride = null; + // Mirror of goNext: centre slides to next, prev slides into centre, + // and the card that was next is recycled — staged further out, then + // transitioned in — to become the new prev, while the ghost plays its + // old content exiting further right. + ghost.playExit(withFont(cardSet[nextIndexBefore]), 'next'); + const recycled = cards[roles.indexOf('next')]; + cards[roles.indexOf('center')].setRole('next'); + cards[roles.indexOf('prev')].setRole('center'); + roles = roles.map((role) => ({ center: 'next', prev: 'center', next: 'prev' }[role])); + const newPrevIndex = ((activeIndex - 1) % total + total) % total; + recycled.render(withFont(cardSet[newPrevIndex])); + recycled.stageAt('stage-prev'); + requestAnimationFrame(() => requestAnimationFrame(() => recycled.setRole('prev'))); + cards[roles.indexOf('center')].render(centerEntry()); + useQuote(cardSet[activeIndex]); + } + + // Applied from the widget's font/colour pickers (see buildWidget) so + // selecting a font or background there updates the arc carousel exactly + // as it already updates the desktop widget's own .me-card. A font patch + // is carousel-wide (re-renders all 3 visible cards, see renderFont + // below); a colour/quote/author patch stays centre-only via + // centreOverride, same as before. + function renderFont() { + cards[roles.indexOf('prev')].render(withFont(cardSet[((activeIndex - 1) % total + total) % total])); + cards[roles.indexOf('center')].render(centerEntry()); + cards[roles.indexOf('next')].render(withFont(cardSet[(activeIndex + 1) % total])); + } + + function updateCentre(patch) { + if (patch.font) { + selectedFont = patch.font; + renderFont(); + return; + } + centreOverride = { ...centreOverride, ...patch }; + cards[roles.indexOf('center')].render(centerEntry()); + } + + applyRoles(); + renderAll(); + listboxRole.append(ghost.el, cardA.el, cardB.el, cardC.el); + root.append(listboxRole); + + const prevBtn = createTag('button', { + type: 'button', + class: 'me-arc-nav me-arc-nav--prev', + 'aria-label': 'Previous template', + }, [getIconElementDeprecated('arc-nav-left')]); + const nextBtn = createTag('button', { + type: 'button', + class: 'me-arc-nav me-arc-nav--next', + 'aria-label': 'Next template', + }, [getIconElementDeprecated('arc-nav-right')]); + root.append(prevBtn, nextBtn); + + prevBtn.addEventListener('click', goPrev); + nextBtn.addEventListener('click', goNext); + + return { root, updateCentre }; +} + +/** + * @param {Object} config + * @param {HTMLElement} config.root — element the widget sets state attributes, + * CSS custom properties, and the `me-carousel-mode` class on (the block). + * @param {Array} [config.topActions=[]] — top-right hover action bar (Figma + * node 1099-5050): `[{ type: 'edit'|'share'|'download', onClick }, ...]`. + * Only the types supplied are rendered, in the given order. + * @param {Array} config.fontOptions — `{ label, font, italic, weight }` list. + * @param {Object} config.backgrounds — `{ cardSet, decoCount }` where cardSet + * is `[{ card: { id, bg }, quote, author }, ...]`. + * @param {Object} config.a11y — shared helpers the widget needs but does not + * own: `{ trapFocus, handleEscapeClose, disableBackgroundScroll, + * restoreBackgroundScroll, copyQuoteToClipboard }`. + * @param {Object} [config.deps] — `{ createTag, getIconElementDeprecated }`. + * @param {boolean} [config.decorations=true] — when `false`, the desktop + * zig-zag decorative cards and the tablet/mobile arc carousel are never + * built at all (not built-then-hidden) — only the centre editor card + * renders, at every breakpoint. For a host that only ever shows the + * widget in isolation (e.g. a modal), so it never pays for DOM/listeners + * it will never display. + * @param {'always-open-inline'} [config.panelMode] — when set, the font/ + * colour controls behave differently from the default (collapsible, + * bottom-sheet-on-mobile) inline row: one of the two starts open (font) + * and stays open at every breakpoint/width — clicking its own trigger + * again is a no-op instead of collapsing to neither. The CSS-driven + * mobile bottom sheet must be suppressed by the host's own stylesheet + * (see mini-editor-modal.css) since it's still built either way. Used by + * the "Create a design" modal, where the empty space below the card + * exists only to host this panel. + * @returns {Promise<{ stage, decorations, useQuote, updateCentre, getContentModel, + * syncViewportMode, destroy }>} + */ +export default async function createMiniEditorWidget(config = {}) { + const { + root, + topActions = [], + fontOptions, + backgrounds, + a11y, + deps, + decorations: decorationsEnabled = true, + panelMode, + } = config; + + ({ createTag, getIconElementDeprecated } = deps); + + // topActions' icons and the decorative cards' "Copy quote" icon + // (sp-icon-copy, see buildDecoCard) are real Spectrum Web Components + // custom elements — only loaded when actually used, so a caller with no + // topActions and decorations: false doesn't pay for the Spectrum bundle. + if (topActions.length || decorationsEnabled) { + await import('../spectrum/dist/icons-workflow.js'); + } + + const { cardSet } = backgrounds; + const decoCount = backgrounds.decoCount ?? DECO_CARD_COUNT; + + // always-open-inline (the modal) starts with the font panel open and + // keeps one of font/colour open at all times — see buildFontControl / + // buildColorControl's matching click-guard. Not on mobile widths, where + // this host falls back to the normal bottom sheet (nothing open until + // tapped), same as everywhere else this flag doesn't apply. + const startsOpen = panelMode === 'always-open-inline' && !isMobileSheetWidth(); + root.setAttribute('data-me-panel', startsOpen ? 'fonts' : 'none'); + + const stage = createTag('div', { class: 'mini-editor-stage' }); + const { + widget, useQuote, getContentModel, onFontOrColourChange, destroy: destroyWidget, + } = buildWidget(root, a11y, cardSet, fontOptions, topActions, panelMode); + stage.append(widget); + + let decorations; + let updateCentre = () => {}; + let syncViewportMode = () => {}; + let removeResizeListener = () => {}; + + if (decorationsEnabled) { + // Same entries (the widget's own + the desktop decorations) power the + // tablet/mobile arc carousel, so it cycles through the identical set of + // quote/background/font combinations as the desktop zig-zag. + const arcCardSet = [cardSet[0], ...cardSet.slice(1, 1 + decoCount)]; + decorations = buildDecoCards(a11y, cardSet, useQuote); + const { root: arcCarousel, updateCentre: updateArcCentre } = buildArcCarousel( + arcCardSet, + useQuote, + fontOptions[0], + ); + updateCentre = updateArcCentre; + onFontOrColourChange(updateCentre); + + // The arc carousel is inserted inside the widget, in the same flow slot + // as .me-card (which .me-carousel-mode hides), rather than as a sibling + // of the widget in the stage — the stage's flex row would otherwise + // squeeze both side by side instead of the arc taking .me-card's place. + widget.querySelector('.me-card').after(arcCarousel); + + // On a touch/coarse-pointer device (tablet, phone), use the shorter of + // width/height rather than window.innerWidth alone — a physical device's + // short axis is orientation-independent, so this keeps the same tablet + // from flipping into the desktop zig-zag layout just because rotating to + // landscape made innerWidth exceed the breakpoint. Plain mouse/desktop + // windows don't have a fixed physical "short side" (resizing changes both + // dimensions independently), so they keep the simple width-only check — + // otherwise a short-but-wide desktop browser window would wrongly be + // treated as a tablet. + const TABLET_BREAKPOINT = 1199; + const isTouchDevice = window.matchMedia('(pointer: coarse)').matches; + const isSmallViewport = () => { + const size = isTouchDevice + ? Math.min(window.innerWidth, window.innerHeight) + : window.innerWidth; + return size <= TABLET_BREAKPOINT; + }; + syncViewportMode = () => { + root.classList.toggle('me-carousel-mode', isSmallViewport()); + }; + syncViewportMode(); + window.addEventListener('resize', syncViewportMode); + removeResizeListener = () => window.removeEventListener('resize', syncViewportMode); + } + + return { + stage, + decorations, + useQuote, + updateCentre, + getContentModel, + syncViewportMode, + destroy: () => { + destroyWidget(); + removeResizeListener(); + }, + }; +} diff --git a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.md b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.md new file mode 100644 index 000000000..ad2022925 --- /dev/null +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.md @@ -0,0 +1,90 @@ +# Mini Editor Widget + +A configurable in-page quote-editing surface, extracted verbatim from the +`mini-editor` block so the exact same UI/UX can be reused elsewhere. It is a +vanilla-DOM widget (no Spectrum Web Components) that renders one editing stage +which adapts across breakpoints from a single config: + +- **Desktop (≥1200px)** — a centred editor card (`.me-card`) with the live + quote, flanked by a two-column zig-zag of decorative preview cards + (`.mini-editor-decorations`). Each preview offers "Use this quote" / "Copy". +- **Tablet/mobile (<1200px)** — a 3-card arc carousel (prev / centre / next) + with a rotating slide animation and a fading "ghost" exit card. +- **Shared controls** — a Font-style toggle and a Background-colour swatch + row. On tablet/desktop they expand as inline rows; on mobile (≤767px) they + open as slide-up bottom sheets. Both are driven by one `data-me-panel` + attribute on the root, so which surface shows is pure CSS per breakpoint. + +The caller supplies the data (fonts, background cards, quotes) and the shared +a11y/clipboard helpers; the widget owns all rendering, interaction, animation, +and the cross-block `mini-editor:use-quote` event wiring. + +> **Note on the API shape.** This mirrors the config-driven shape of the +> Spectrum-based `mini-editor-widget` proposed in adobecom/da-express-milo#680 +> (`content`, `topActions`, `fontOptions`, `backgrounds`, …) but keeps this +> project's existing pixel-identical vanilla UI rather than the Spectrum +> rendering. `topActions` now renders the top-right hover action bar (Figma +> node 1099-5050) — see the Config table below. + +## Usage + +```js +import createMiniEditorWidget from '../../scripts/widgets/mini-editor-widget/mini-editor-widget.js'; + +const editor = await createMiniEditorWidget({ + root: block, // element the widget sets state attrs / CSS vars / mode class on + topActions: [ // top-right hover action bar (Figma node 1099-5050) + { type: 'edit', onClick: onEdit }, + { type: 'share', onClick: onShare }, + { type: 'download', onClick: onDownload }, + ], + fontOptions, // [{ label, font, italic, weight }] + backgrounds: { // our fetched card set + paired quotes + cardSet: [{ card: { id, bg }, quote, author }, ...], + decoCount: 8, // how many of cardSet[1..] become desktop decorations / arc cards + }, + a11y: { // shared helpers the widget uses but does not own + trapFocus, + handleEscapeClose, + disableBackgroundScroll, + restoreBackgroundScroll, + copyQuoteToClipboard, // async (quote, author) => boolean + }, + deps: { createTag, getIconElementDeprecated }, +}); + +header.append(editor.decorations); // decorations anchor to the header, not the stage +block.append(editor.stage); +``` + +The host block is responsible for loading the widget stylesheet: + +```js +loadStyle(`${getConfig().codeRoot}/scripts/widgets/mini-editor-widget/mini-editor-widget.css`); +``` + +## Config + +| Key | Type | Notes | +|---------------|---------------|-------| +| `root` | `HTMLElement` | Element the widget sets `data-me-panel`, `--me-*` custom properties, and the `me-carousel-mode` class on. In the block this is the `.mini-editor` block element (which also defines the `--me-*` layout tokens in `mini-editor.css`). | +| `topActions` | `Array` | Top-right hover action bar (Figma node 1099-5050): `[{ type: 'edit'\|'share'\|'download', onClick }, ...]`. Only the types supplied are rendered, in the given order — pass `[]` (or omit) to render none. Always visible on tablet/mobile (no hover); fades in on hover/focus-within on desktop. | +| `fontOptions` | `Array` | `{ label, font, italic, weight }`. First entry is applied on load (selected). Single-select. | +| `backgrounds` | `Object` | `{ cardSet, decoCount }`. `cardSet` is `[{ card: { id, bg }, quote, author }, ...]`; `cardSet[0]` powers the main widget and the rest (up to `decoCount`, default 8) power the decorations / arc. | +| `a11y` | `Object` | `{ trapFocus, handleEscapeClose, disableBackgroundScroll, restoreBackgroundScroll, copyQuoteToClipboard }`. | +| `deps` | `Object` | `{ createTag, getIconElementDeprecated }` from the host's utils. | +| `decorations` | `boolean` | Default `true`. Pass `false` to skip building the desktop zig-zag decorative cards and the tablet/mobile arc carousel entirely — only the centre editor card renders, at every breakpoint. `decorations`/`updateCentre`/`syncViewportMode` on the returned object are then a no-op/`undefined`. Used by the "Create a design" modal. | +| `panelMode` | `'always-open-inline'` | Optional. When set, the font/colour controls stay as an inline row and one of the two is always open (font by default) — no collapsing to neither, no mobile bottom sheet. The host's own stylesheet must still hide the bottom-sheet CSS itself (see mini-editor-modal.css); this only changes the JS toggle behaviour and initial `data-me-panel` value. Used by the "Create a design" modal. | + +## Returns + +`Promise<{ stage, decorations, useQuote, updateCentre, syncViewportMode, destroy }>` + +- `stage` — the editing surface root (`.mini-editor-stage`); append it under the block. +- `decorations` — the desktop decorative-card layer; append it to the header. +- `useQuote({ quote, author, card, font })` — swap the active quote/author (and + optionally background/font) into the live editor. +- `updateCentre(patch)` — patch the arc carousel's centre card (`{ quote, author }`, + `{ card }`, or carousel-wide `{ font }`). +- `syncViewportMode()` — re-evaluate the desktop/carousel breakpoint (also runs on resize). +- `destroy()` — remove listeners (resize, outside-click) and the panel MutationObserver. diff --git a/express/code/scripts/widgets/spectrum/build.mjs b/express/code/scripts/widgets/spectrum/build.mjs index d689157f5..880d020f2 100644 --- a/express/code/scripts/widgets/spectrum/build.mjs +++ b/express/code/scripts/widgets/spectrum/build.mjs @@ -283,7 +283,7 @@ const newComponents = [ "import '@spectrum-web-components/icons-workflow/icons/sp-icon-switch-vertical.js';", "import '@spectrum-web-components/icons-workflow/icons/sp-icon-close.js';", "import '@spectrum-web-components/icons-workflow/icons/sp-icon-accessibility.js';", - "import '@spectrum-web-components/icons-workflow/icons/sp-icon-checkmark-circle.js';", + "import '@spectrum-web-components/icons-workflow/icons/sp-icon-checkmark-circle-outline.js';", "import '@spectrum-web-components/icons-workflow/icons/sp-icon-image.js';", "import '@spectrum-web-components/icons-workflow/icons/sp-icon-lock.js';", "import '@spectrum-web-components/icons-workflow/icons/sp-icon-lock-open.js';", diff --git a/express/code/scripts/widgets/spectrum/dist/icons-workflow.js b/express/code/scripts/widgets/spectrum/dist/icons-workflow.js index ad812981c..dcd4d2a7b 100644 --- a/express/code/scripts/widgets/spectrum/dist/icons-workflow.js +++ b/express/code/scripts/widgets/spectrum/dist/icons-workflow.js @@ -1,11 +1,11 @@ /* eslint-disable */ /* Generated by da-express-milo Spectrum Web Components bundler */ -import{html as Gt}from"./base.js";import{html as jt,SpectrumElement as Pt}from"./base.js";import{SystemResolutionController as Nt,systemResolverUpdatedSymbol as Ut}from"./reactive-controllers.js";import{property as N,state as qt}from"./base.js";import{css as Dt}from"./base.js";var Ot=Dt` +import{html as Kt}from"./base.js";import{html as Nt,SpectrumElement as Ut}from"./base.js";import{SystemResolutionController as qt,systemResolverUpdatedSymbol as Gt}from"./reactive-controllers.js";import{property as U,state as Jt}from"./base.js";import{css as Ft}from"./base.js";var Rt=Ft` :host{--spectrum-icon-inline-size:var(--mod-icon-inline-size,var(--mod-icon-size,var(--spectrum-icon-size)));--spectrum-icon-block-size:var(--mod-icon-block-size,var(--mod-icon-size,var(--spectrum-icon-size)));inline-size:var(--spectrum-icon-inline-size);block-size:var(--spectrum-icon-block-size);color:var(--mod-icon-color,inherit);fill:currentColor;pointer-events:none;display:inline-block}@media (forced-colors:active){:host{forced-color-adjust:auto}}:host{--spectrum-icon-size:var(--spectrum-workflow-icon-size-100)}:host([size=xxs]){--spectrum-icon-size:var(--spectrum-workflow-icon-size-xxs)}:host([size=xs]){--spectrum-icon-size:var(--spectrum-workflow-icon-size-50)}:host([size=s]){--spectrum-icon-size:var(--spectrum-workflow-icon-size-75)}:host([size=l]){--spectrum-icon-size:var(--spectrum-workflow-icon-size-200)}:host([size=xl]){--spectrum-icon-size:var(--spectrum-workflow-icon-size-300)}:host([size=xxl]){--spectrum-icon-size:var(--spectrum-workflow-icon-size-xxl)}#container{height:100%}img,svg,::slotted(*){vertical-align:top;width:100%;height:100%;color:inherit}@media (forced-colors:active){img,svg,::slotted(*){forced-color-adjust:auto}}:host(:not(:root)){overflow:hidden} -`,P=Ot;var Ft=Object.defineProperty,Rt=Object.getOwnPropertyDescriptor,R=(t,e,r,i)=>{for(var a=i>1?void 0:i?Rt(e,r):e,O=t.length-1,F;O>=0;O--)(F=t[O])&&(a=(i?F(e,r,a):F(a))||a);return i&&a&&Ft(e,r,a),a},s=class extends Pt{constructor(){super(...arguments),this.unsubscribeSystemContext=null,this.spectrumVersion=1,this.label="",this.systemResolver=new Nt(this)}static get styles(){return[P]}connectedCallback(){super.connectedCallback()}disconnectedCallback(){super.disconnectedCallback(),this.unsubscribeSystemContext&&(this.unsubscribeSystemContext(),this.unsubscribeSystemContext=null)}update(e){e.has("label")&&(this.label?this.removeAttribute("aria-hidden"):this.setAttribute("aria-hidden","true")),e.has(Ut)&&(this.spectrumVersion=this.systemResolver.system==="spectrum-two"?2:1),super.update(e)}render(){return jt` +`,N=Rt;var jt=Object.defineProperty,Pt=Object.getOwnPropertyDescriptor,j=(t,e,r,i)=>{for(var a=i>1?void 0:i?Pt(e,r):e,F=t.length-1,R;F>=0;F--)(R=t[F])&&(a=(i?R(e,r,a):R(a))||a);return i&&a&&jt(e,r,a),a},s=class extends Ut{constructor(){super(...arguments),this.unsubscribeSystemContext=null,this.spectrumVersion=1,this.label="",this.systemResolver=new qt(this)}static get styles(){return[N]}connectedCallback(){super.connectedCallback()}disconnectedCallback(){super.disconnectedCallback(),this.unsubscribeSystemContext&&(this.unsubscribeSystemContext(),this.unsubscribeSystemContext=null)}update(e){e.has("label")&&(this.label?this.removeAttribute("aria-hidden"):this.setAttribute("aria-hidden","true")),e.has(Gt)&&(this.spectrumVersion=this.systemResolver.system==="spectrum-two"?2:1),super.update(e)}render(){return Nt` - `}};R([qt()],s.prototype,"spectrumVersion",2),R([N({reflect:!0})],s.prototype,"label",2),R([N({reflect:!0})],s.prototype,"size",2);var j,o=function(t,...e){return j?j(t,...e):e.reduce((r,i,a)=>r+i+t[a+1],t[0])},l=t=>{j=t};var h=({width:t=24,height:e=24,hidden:r=!1,title:i="Alert"}={})=>o`r+i+t[a+1],t[0])},l=t=>{P=t};var h=({width:t=24,height:e=24,hidden:r=!1,title:i="Alert"}={})=>o` - `;var n=class extends s{render(){return l(Gt),this.spectrumVersion===1?h({hidden:!this.label,title:this.label}):c({hidden:!this.label,title:this.label})}};import{defineElement as Jt}from"./base.js";Jt("sp-icon-alert",n);import{html as Kt}from"./base.js";var p=class extends s{render(){return l(Kt),this.spectrumVersion===2?c({hidden:!this.label,title:this.label}):h({hidden:!this.label,title:this.label})}};import{defineElement as Qt}from"./base.js";Qt("sp-icon-alert-triangle",p);import{html as Wt}from"./base.js";var U=({width:t=24,height:e=24,hidden:r=!1,title:i="Circle"}={})=>o``;var n=class extends s{render(){return l(Kt),this.spectrumVersion===1?h({hidden:!this.label,title:this.label}):c({hidden:!this.label,title:this.label})}};import{defineElement as Qt}from"./base.js";Qt("sp-icon-alert",n);import{html as Wt}from"./base.js";var p=class extends s{render(){return l(Wt),this.spectrumVersion===2?c({hidden:!this.label,title:this.label}):h({hidden:!this.label,title:this.label})}};import{defineElement as Xt}from"./base.js";Xt("sp-icon-alert-triangle",p);import{html as Yt}from"./base.js";var q=({width:t=24,height:e=24,hidden:r=!1,title:i="Circle"}={})=>o` - `;var q=({width:t=24,height:e=24,hidden:r=!1,title:i="Circle Filled"}={})=>o``;var G=({width:t=24,height:e=24,hidden:r=!1,title:i="Circle Filled"}={})=>o` - `;var d=class extends s{render(){return l(Wt),this.spectrumVersion===2?U({hidden:!this.label,title:this.label}):q({hidden:!this.label,title:this.label})}};import{defineElement as Xt}from"./base.js";Xt("sp-icon-circle",d);import{html as Yt}from"./base.js";var G=({width:t=24,height:e=24,hidden:r=!1,title:i="Copy"}={})=>o``;var d=class extends s{render(){return l(Yt),this.spectrumVersion===2?q({hidden:!this.label,title:this.label}):G({hidden:!this.label,title:this.label})}};import{defineElement as _t}from"./base.js";_t("sp-icon-circle",d);import{html as t1}from"./base.js";var J=({width:t=24,height:e=24,hidden:r=!1,title:i="Copy"}={})=>o` - `;var J=({width:t=24,height:e=24,hidden:r=!1,title:i="Copy"}={})=>o``;var K=({width:t=24,height:e=24,hidden:r=!1,title:i="Copy"}={})=>o` - `;var g=class extends s{render(){return l(Yt),this.spectrumVersion===2?G({hidden:!this.label,title:this.label}):J({hidden:!this.label,title:this.label})}};import{defineElement as _t}from"./base.js";_t("sp-icon-copy",g);import{html as t2}from"./base.js";var K=({width:t=24,height:e=24,hidden:r=!1,title:i="Delete"}={})=>o``;var g=class extends s{render(){return l(t1),this.spectrumVersion===2?J({hidden:!this.label,title:this.label}):K({hidden:!this.label,title:this.label})}};import{defineElement as e1}from"./base.js";e1("sp-icon-copy",g);import{html as r1}from"./base.js";var Q=({width:t=24,height:e=24,hidden:r=!1,title:i="Delete"}={})=>o` - `;var Q=({width:t=24,height:e=24,hidden:r=!1,title:i="Delete"}={})=>o``;var W=({width:t=24,height:e=24,hidden:r=!1,title:i="Delete"}={})=>o` - `;var f=class extends s{render(){return l(t2),this.spectrumVersion===2?K({hidden:!this.label,title:this.label}):Q({hidden:!this.label,title:this.label})}};import{defineElement as e2}from"./base.js";e2("sp-icon-delete",f);import{html as r2}from"./base.js";var W=({width:t=24,height:e=24,hidden:r=!1,title:i="Edit"}={})=>o``;var f=class extends s{render(){return l(r1),this.spectrumVersion===2?Q({hidden:!this.label,title:this.label}):W({hidden:!this.label,title:this.label})}};import{defineElement as i1}from"./base.js";i1("sp-icon-delete",f);import{html as o1}from"./base.js";var X=({width:t=24,height:e=24,hidden:r=!1,title:i="Edit"}={})=>o` - `;var X=({width:t=24,height:e=24,hidden:r=!1,title:i="Edit"}={})=>o``;var Y=({width:t=24,height:e=24,hidden:r=!1,title:i="Edit"}={})=>o` - `;var u=class extends s{render(){return l(r2),this.spectrumVersion===2?W({hidden:!this.label,title:this.label}):X({hidden:!this.label,title:this.label})}};import{defineElement as i2}from"./base.js";i2("sp-icon-edit",u);import{html as o2}from"./base.js";var Y=({width:t=24,height:e=24,hidden:r=!1,title:i="Heart"}={})=>o``;var u=class extends s{render(){return l(o1),this.spectrumVersion===2?X({hidden:!this.label,title:this.label}):Y({hidden:!this.label,title:this.label})}};import{defineElement as s1}from"./base.js";s1("sp-icon-edit",u);import{html as l1}from"./base.js";var _=({width:t=24,height:e=24,hidden:r=!1,title:i="Heart"}={})=>o` - `;var _=({width:t=24,height:e=24,hidden:r=!1,title:i="Heart"}={})=>o``;var tt=({width:t=24,height:e=24,hidden:r=!1,title:i="Heart"}={})=>o` - `;var w=class extends s{render(){return l(o2),this.spectrumVersion===2?Y({hidden:!this.label,title:this.label}):_({hidden:!this.label,title:this.label})}};import{defineElement as s2}from"./base.js";s2("sp-icon-heart",w);import{html as l2}from"./base.js";var tt=({width:t=24,height:e=24,hidden:r=!1,title:i="Heart Filled"}={})=>o``;var w=class extends s{render(){return l(l1),this.spectrumVersion===2?_({hidden:!this.label,title:this.label}):tt({hidden:!this.label,title:this.label})}};import{defineElement as a1}from"./base.js";a1("sp-icon-heart",w);import{html as m1}from"./base.js";var et=({width:t=24,height:e=24,hidden:r=!1,title:i="Heart Filled"}={})=>o` - `;var v=class extends s{render(){return l(l2),this.spectrumVersion===2?tt({hidden:!this.label,title:this.label}):m({hidden:!this.label,title:this.label})}};import{defineElement as a2}from"./base.js";a2("sp-icon-heart-filled",v);import{html as m2}from"./base.js";var et=({width:t=24,height:e=24,hidden:r=!1,title:i="Open In"}={})=>o`o` - `;var rt=({width:t=24,height:e=24,hidden:r=!1,title:i="Open In"}={})=>o``;var it=({width:t=24,height:e=24,hidden:r=!1,title:i="Open In"}={})=>o` - `;var x=class extends s{render(){return l(m2),this.spectrumVersion===2?et({hidden:!this.label,title:this.label}):rt({hidden:!this.label,title:this.label})}};import{defineElement as h2}from"./base.js";h2("sp-icon-open-in",x);import{html as c2}from"./base.js";var it=({width:t=24,height:e=24,hidden:r=!1,title:i="Share Android"}={})=>o``;var x=class extends s{render(){return l(c1),this.spectrumVersion===2?rt({hidden:!this.label,title:this.label}):it({hidden:!this.label,title:this.label})}};import{defineElement as n1}from"./base.js";n1("sp-icon-open-in",x);import{html as p1}from"./base.js";var ot=({width:t=24,height:e=24,hidden:r=!1,title:i="Share Android"}={})=>o` - `;var ot=({width:t=24,height:e=24,hidden:r=!1,title:i="Share Android"}={})=>o``;var st=({width:t=24,height:e=24,hidden:r=!1,title:i="Share Android"}={})=>o` - `;var C=class extends s{render(){return l(c2),this.spectrumVersion===2?it({hidden:!this.label,title:this.label}):ot({hidden:!this.label,title:this.label})}};import{defineElement as n2}from"./base.js";n2("sp-icon-share-android",C);import{html as p2}from"./base.js";var st=({width:t=24,height:e=24,hidden:r=!1,title:i="Target"}={})=>o``;var C=class extends s{render(){return l(p1),this.spectrumVersion===2?ot({hidden:!this.label,title:this.label}):st({hidden:!this.label,title:this.label})}};import{defineElement as d1}from"./base.js";d1("sp-icon-share-android",C);import{html as g1}from"./base.js";var lt=({width:t=24,height:e=24,hidden:r=!1,title:i="Target"}={})=>o` - `;var lt=({width:t=24,height:e=24,hidden:r=!1,title:i="Target"}={})=>o``;var at=({width:t=24,height:e=24,hidden:r=!1,title:i="Target"}={})=>o` - `;var $=class extends s{render(){return l(p2),this.spectrumVersion===2?st({hidden:!this.label,title:this.label}):lt({hidden:!this.label,title:this.label})}};import{defineElement as d2}from"./base.js";d2("sp-icon-target",$);import{html as g2}from"./base.js";var at=({width:t=24,height:e=24,hidden:r=!1,title:i="Download"}={})=>o``;var $=class extends s{render(){return l(g1),this.spectrumVersion===2?lt({hidden:!this.label,title:this.label}):at({hidden:!this.label,title:this.label})}};import{defineElement as f1}from"./base.js";f1("sp-icon-target",$);import{html as u1}from"./base.js";var mt=({width:t=24,height:e=24,hidden:r=!1,title:i="Download"}={})=>o` - `;var mt=({width:t=24,height:e=24,hidden:r=!1,title:i="Save To"}={})=>o``;var ht=({width:t=24,height:e=24,hidden:r=!1,title:i="Save To"}={})=>o` - `;var b=class extends s{render(){return l(g2),this.spectrumVersion===2?at({hidden:!this.label,title:this.label}):mt({hidden:!this.label,title:this.label})}};import{defineElement as f2}from"./base.js";f2("sp-icon-download",b);import{html as u2}from"./base.js";var ht=({width:t=24,height:e=24,hidden:r=!1,title:i="CCLibrary"}={})=>o``;var b=class extends s{render(){return l(u1),this.spectrumVersion===2?mt({hidden:!this.label,title:this.label}):ht({hidden:!this.label,title:this.label})}};import{defineElement as w1}from"./base.js";w1("sp-icon-download",b);import{html as v1}from"./base.js";var ct=({width:t=24,height:e=24,hidden:r=!1,title:i="CCLibrary"}={})=>o` - `;var ct=({width:t=24,height:e=24,hidden:r=!1,title:i="CCLibrary"}={})=>o``;var nt=({width:t=24,height:e=24,hidden:r=!1,title:i="CCLibrary"}={})=>o` - `;var T=class extends s{render(){return l(u2),this.spectrumVersion===2?ht({hidden:!this.label,title:this.label}):ct({hidden:!this.label,title:this.label})}};import{defineElement as w2}from"./base.js";w2("sp-icon-cclibrary",T);import{html as v2}from"./base.js";var nt=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Down"}={})=>o``;var T=class extends s{render(){return l(v1),this.spectrumVersion===2?ct({hidden:!this.label,title:this.label}):nt({hidden:!this.label,title:this.label})}};import{defineElement as x1}from"./base.js";x1("sp-icon-cclibrary",T);import{html as C1}from"./base.js";var pt=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Down"}={})=>o` - `;var pt=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Down"}={})=>o``;var dt=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Down"}={})=>o` - `;var I=class extends s{render(){return l(v2),this.spectrumVersion===2?nt({hidden:!this.label,title:this.label}):pt({hidden:!this.label,title:this.label})}};import{defineElement as x2}from"./base.js";x2("sp-icon-chevron-down",I);import{html as C2}from"./base.js";var dt=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Left"}={})=>o``;var I=class extends s{render(){return l(C1),this.spectrumVersion===2?pt({hidden:!this.label,title:this.label}):dt({hidden:!this.label,title:this.label})}};import{defineElement as $1}from"./base.js";$1("sp-icon-chevron-down",I);import{html as b1}from"./base.js";var gt=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Left"}={})=>o` - `;var gt=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Left"}={})=>o``;var ft=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Left"}={})=>o` - `;var L=class extends s{render(){return l(C2),this.spectrumVersion===2?dt({hidden:!this.label,title:this.label}):gt({hidden:!this.label,title:this.label})}};import{defineElement as $2}from"./base.js";$2("sp-icon-chevron-left",L);import{html as b2}from"./base.js";var ft=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Right"}={})=>o``;var L=class extends s{render(){return l(b1),this.spectrumVersion===2?gt({hidden:!this.label,title:this.label}):ft({hidden:!this.label,title:this.label})}};import{defineElement as T1}from"./base.js";T1("sp-icon-chevron-left",L);import{html as I1}from"./base.js";var ut=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Right"}={})=>o` - `;var ut=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Right"}={})=>o``;var wt=({width:t=24,height:e=24,hidden:r=!1,title:i="Chevron Right"}={})=>o` - `;var Z=class extends s{render(){return l(b2),this.spectrumVersion===2?ft({hidden:!this.label,title:this.label}):ut({hidden:!this.label,title:this.label})}};import{defineElement as T2}from"./base.js";T2("sp-icon-chevron-right",Z);import{html as I2}from"./base.js";var wt=({width:t=24,height:e=24,hidden:r=!1,title:i="Checkmark Circle"}={})=>o``;var Z=class extends s{render(){return l(I1),this.spectrumVersion===2?ut({hidden:!this.label,title:this.label}):wt({hidden:!this.label,title:this.label})}};import{defineElement as L1}from"./base.js";L1("sp-icon-chevron-right",Z);import{html as Z1}from"./base.js";var vt=({width:t=24,height:e=24,hidden:r=!1,title:i="Checkmark Circle"}={})=>o` - `;var vt=({width:t=24,height:e=24,hidden:r=!1,title:i="Checkmark Circle"}={})=>o``;var xt=({width:t=24,height:e=24,hidden:r=!1,title:i="Checkmark Circle"}={})=>o` - `;var y=class extends s{render(){return l(I2),this.spectrumVersion===2?wt({hidden:!this.label,title:this.label}):vt({hidden:!this.label,title:this.label})}};import{defineElement as L2}from"./base.js";L2("sp-icon-checkmark-circle",y);import{html as Z2}from"./base.js";var xt=({width:t=24,height:e=24,hidden:r=!1,title:i="Close Circle"}={})=>o``;var y=class extends s{render(){return l(Z1),this.spectrumVersion===2?vt({hidden:!this.label,title:this.label}):xt({hidden:!this.label,title:this.label})}};import{defineElement as y1}from"./base.js";y1("sp-icon-checkmark-circle",y);import{html as B1}from"./base.js";var Ct=({width:t=24,height:e=24,hidden:r=!1,title:i="Close Circle"}={})=>o` - `;var Ct=({width:t=24,height:e=24,hidden:r=!1,title:i="Close Circle"}={})=>o``;var $t=({width:t=24,height:e=24,hidden:r=!1,title:i="Close Circle"}={})=>o` - `;var B=class extends s{render(){return l(Z2),this.spectrumVersion===2?xt({hidden:!this.label,title:this.label}):Ct({hidden:!this.label,title:this.label})}};import{defineElement as y2}from"./base.js";y2("sp-icon-close-circle",B);import{html as B2}from"./base.js";var $t=({width:t=24,height:e=24,hidden:r=!1,title:i="Switch"}={})=>o``;var B=class extends s{render(){return l(B1),this.spectrumVersion===2?Ct({hidden:!this.label,title:this.label}):$t({hidden:!this.label,title:this.label})}};import{defineElement as V1}from"./base.js";V1("sp-icon-close-circle",B);import{html as k1}from"./base.js";var bt=({width:t=24,height:e=24,hidden:r=!1,title:i="Switch"}={})=>o` - `;var bt=({width:t=24,height:e=24,hidden:r=!1,title:i="Switch"}={})=>o``;var Tt=({width:t=24,height:e=24,hidden:r=!1,title:i="Switch"}={})=>o` - `;var V=class extends s{render(){return l(B2),this.spectrumVersion===2?$t({hidden:!this.label,title:this.label}):bt({hidden:!this.label,title:this.label})}};import{defineElement as V2}from"./base.js";V2("sp-icon-switch",V);import{html as M2}from"./base.js";var Tt=({width:t=24,height:e=24,hidden:r=!1,title:i="Add"}={})=>o``;var V=class extends s{render(){return l(k1),this.spectrumVersion===2?bt({hidden:!this.label,title:this.label}):Tt({hidden:!this.label,title:this.label})}};import{defineElement as M1}from"./base.js";M1("sp-icon-switch",V);import{html as H1}from"./base.js";var It=({width:t=24,height:e=24,hidden:r=!1,title:i="Add"}={})=>o` - `;var It=({width:t=24,height:e=24,hidden:r=!1,title:i="Add"}={})=>o``;var Lt=({width:t=24,height:e=24,hidden:r=!1,title:i="Add"}={})=>o` - `;var M=class extends s{render(){return l(M2),this.spectrumVersion===2?Tt({hidden:!this.label,title:this.label}):It({hidden:!this.label,title:this.label})}};import{defineElement as k2}from"./base.js";k2("sp-icon-add",M);import{html as H2}from"./base.js";var Lt=({width:t=24,height:e=24,hidden:r=!1,title:i="Filter"}={})=>o``;var k=class extends s{render(){return l(H1),this.spectrumVersion===2?It({hidden:!this.label,title:this.label}):Lt({hidden:!this.label,title:this.label})}};import{defineElement as A1}from"./base.js";A1("sp-icon-add",k);import{html as E1}from"./base.js";var Zt=({width:t=24,height:e=24,hidden:r=!1,title:i="Filter"}={})=>o` - `;var Zt=({width:t=24,height:e=24,hidden:r=!1,title:i="Filter"}={})=>o``;var yt=({width:t=24,height:e=24,hidden:r=!1,title:i="Filter"}={})=>o` - `;var k=class extends s{render(){return l(H2),this.spectrumVersion===2?Lt({hidden:!this.label,title:this.label}):Zt({hidden:!this.label,title:this.label})}};import{defineElement as A2}from"./base.js";A2("sp-icon-filter",k);import{html as E2}from"./base.js";var yt=({width:t=24,height:e=24,hidden:r=!1,title:i="Switch Vertical"}={})=>o``;var M=class extends s{render(){return l(E1),this.spectrumVersion===2?Zt({hidden:!this.label,title:this.label}):yt({hidden:!this.label,title:this.label})}};import{defineElement as z1}from"./base.js";z1("sp-icon-filter",M);import{html as S1}from"./base.js";var Bt=({width:t=24,height:e=24,hidden:r=!1,title:i="Switch Vertical"}={})=>o` - `;var H=class extends s{render(){return l(E2),this.spectrumVersion===2?yt({hidden:!this.label,title:this.label}):m({hidden:!this.label,title:this.label})}};import{defineElement as z2}from"./base.js";z2("sp-icon-switch-vertical",H);import{html as S2}from"./base.js";var Bt=({width:t=24,height:e=24,hidden:r=!1,title:i="Close"}={})=>o``;var H=class extends s{render(){return l(S1),this.spectrumVersion===2?Bt({hidden:!this.label,title:this.label}):m({hidden:!this.label,title:this.label})}};import{defineElement as D1}from"./base.js";D1("sp-icon-switch-vertical",H);import{html as O1}from"./base.js";var Vt=({width:t=24,height:e=24,hidden:r=!1,title:i="Close"}={})=>o` - `;var Vt=({width:t=24,height:e=24,hidden:r=!1,title:i="Close"}={})=>o``;var kt=({width:t=24,height:e=24,hidden:r=!1,title:i="Close"}={})=>o` - `;var A=class extends s{render(){return l(S2),this.spectrumVersion===2?Bt({hidden:!this.label,title:this.label}):Vt({hidden:!this.label,title:this.label})}};import{defineElement as D2}from"./base.js";D2("sp-icon-close",A);import{html as O2}from"./base.js";var Mt=({width:t=24,height:e=24,hidden:r=!1,title:i="Accessibility"}={})=>o``;var A=class extends s{render(){return l(O1),this.spectrumVersion===2?Vt({hidden:!this.label,title:this.label}):kt({hidden:!this.label,title:this.label})}};import{defineElement as F1}from"./base.js";F1("sp-icon-close",A);import{html as R1}from"./base.js";var Mt=({width:t=24,height:e=24,hidden:r=!1,title:i="Accessibility"}={})=>o` - `;var E=class extends s{render(){return l(O2),this.spectrumVersion===2?Mt({hidden:!this.label,title:this.label}):m({hidden:!this.label,title:this.label})}};import{defineElement as F2}from"./base.js";F2("sp-icon-accessibility",E);import{html as R2}from"./base.js";var kt=({width:t=24,height:e=24,hidden:r=!1,title:i="Image"}={})=>o``;var E=class extends s{render(){return l(R1),this.spectrumVersion===2?Mt({hidden:!this.label,title:this.label}):m({hidden:!this.label,title:this.label})}};import{defineElement as j1}from"./base.js";j1("sp-icon-accessibility",E);import{html as P1}from"./base.js";var Ht=({width:t=24,height:e=24,hidden:r=!1,title:i="Checkmark Circle Outline"}={})=>o` + + `;var z=class extends s{render(){return l(P1),this.spectrumVersion===1?Ht({hidden:!this.label,title:this.label}):m({hidden:!this.label,title:this.label})}};import{defineElement as N1}from"./base.js";N1("sp-icon-checkmark-circle-outline",z);import{html as U1}from"./base.js";var At=({width:t=24,height:e=24,hidden:r=!1,title:i="Image"}={})=>o` - `;var Ht=({width:t=24,height:e=24,hidden:r=!1,title:i="Image"}={})=>o``;var Et=({width:t=24,height:e=24,hidden:r=!1,title:i="Image"}={})=>o` - `;var z=class extends s{render(){return l(R2),this.spectrumVersion===2?kt({hidden:!this.label,title:this.label}):Ht({hidden:!this.label,title:this.label})}};import{defineElement as j2}from"./base.js";j2("sp-icon-image",z);import{html as P2}from"./base.js";var At=({width:t=24,height:e=24,hidden:r=!1,title:i="Lock"}={})=>o``;var S=class extends s{render(){return l(U1),this.spectrumVersion===2?At({hidden:!this.label,title:this.label}):Et({hidden:!this.label,title:this.label})}};import{defineElement as q1}from"./base.js";q1("sp-icon-image",S);import{html as G1}from"./base.js";var zt=({width:t=24,height:e=24,hidden:r=!1,title:i="Lock"}={})=>o` - `;var Et=({width:t=24,height:e=24,hidden:r=!1,title:i="Lock Closed"}={})=>o``;var St=({width:t=24,height:e=24,hidden:r=!1,title:i="Lock Closed"}={})=>o` - `;var S=class extends s{render(){return l(P2),this.spectrumVersion===2?At({hidden:!this.label,title:this.label}):Et({hidden:!this.label,title:this.label})}};import{defineElement as N2}from"./base.js";N2("sp-icon-lock",S);import{html as U2}from"./base.js";var zt=({width:t=24,height:e=24,hidden:r=!1,title:i="Lock Open"}={})=>o``;var D=class extends s{render(){return l(G1),this.spectrumVersion===2?zt({hidden:!this.label,title:this.label}):St({hidden:!this.label,title:this.label})}};import{defineElement as J1}from"./base.js";J1("sp-icon-lock",D);import{html as K1}from"./base.js";var Dt=({width:t=24,height:e=24,hidden:r=!1,title:i="Lock Open"}={})=>o` - `;var St=({width:t=24,height:e=24,hidden:r=!1,title:i="Lock Open"}={})=>o``;var Ot=({width:t=24,height:e=24,hidden:r=!1,title:i="Lock Open"}={})=>o` - `;var D=class extends s{render(){return l(U2),this.spectrumVersion===2?zt({hidden:!this.label,title:this.label}):St({hidden:!this.label,title:this.label})}};import{defineElement as q2}from"./base.js";q2("sp-icon-lock-open",D); + `;var O=class extends s{render(){return l(K1),this.spectrumVersion===2?Dt({hidden:!this.label,title:this.label}):Ot({hidden:!this.label,title:this.label})}};import{defineElement as Q1}from"./base.js";Q1("sp-icon-lock-open",O); diff --git a/express/code/styles/styles.css b/express/code/styles/styles.css index fb6b597d7..67627e2a6 100644 --- a/express/code/styles/styles.css +++ b/express/code/styles/styles.css @@ -260,6 +260,7 @@ --color-default-font: #131313; --color-light-gray-2: #e1e1e1; + --border-radius-8: 8px; --border-radius-10: 10px; --border-radius-max: 999px; @@ -375,6 +376,18 @@ --Radius-corner-radius-75: 4px; --corner-radius-80: 6px; + /* S2AC transparent palette tokens */ + --S2AC-Palette-transparent-black-50: rgb(0 0 0 / 3%); + --S2AC-Palette-gray-75: var(--color-gray-150); + --S2AC-Palette-gray-300: var(--color-gray-300-variant); + --Palette-transparent-white-700: rgb(255 255 255 / 66%); + --Palette-transparent-white-800: rgb(255 255 255 / 85%); + --S2AC-Palette-transparent-white-100: rgb(255 255 255 / 11%); + + /* Drop shadow effect tokens */ + --Drop-shadow-emphasized-default: 0 0 0.5px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04), 0 2px 4px rgba(0, 0, 0, 0.08); + --Drop-shadow-emphasized-hover: 0 0 2px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.08); + /* Background/button semantic tokens */ --Background-accent-default: var(--color-background-accent-default); --Background-Primary-Default: var(--Palette-gray-800); diff --git a/package-lock.json b/package-lock.json index 84b4f04a2..fd2303abd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@spectrum-web-components/accordion": "^1.12.1", "@spectrum-web-components/action-button": "^1.11.0", - "@spectrum-web-components/alert-dialog": "^1.12.1", "@spectrum-web-components/tags": "^1.11.0", "@spectrum-web-components/toast": "^1.11.0" }, @@ -22,7 +21,8 @@ "@esm-bundle/chai": "4.3.4-fix.0", "@octokit/rest": "^20.0.2", "@playwright/test": "^1.52.0", - "@spectrum-web-components/badge": "^1.11.2", + "@spectrum-web-components/action-button": "^1.11.2", + "@spectrum-web-components/alert-dialog": "^1.11.2", "@spectrum-web-components/base": "^1.11.2", "@spectrum-web-components/button": "^1.11.2", "@spectrum-web-components/color-area": "^1.11.2", @@ -63,6 +63,7 @@ "eslint-plugin-compat": "^4.0.2", "eslint-plugin-ecmalist": "^1.0.8", "eslint-plugin-import": "2.25.4", + "html2canvas": "^1.4.1", "jsdom": "^22.1.0", "lit": "^3.3.2", "sinon": "13.0.1", @@ -1654,6 +1655,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@spectrum-web-components/action-button/-/action-button-1.11.2.tgz", "integrity": "sha512-c3f0T3WKmTWGMmDQa5wyt0t9MEbx9p8w5VC++q1Cw8gGdh5P6zJR958gP/vOKFyPQm/opbAe1nKrSqwVRcs8NQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.11.2", @@ -1667,6 +1669,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/alert-dialog/-/alert-dialog-1.12.1.tgz", "integrity": "sha512-dFNsgv0RJCu0+T/w5Di1fj3ecMqXg/PpcESzv7jlq/j6b9/a2MGecYj4o9NqY5eh2sBlGFqIfFOoHLT/Eb1NHw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@lit-labs/observers": "2.0.2", @@ -1682,6 +1685,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/base/-/base-1.12.1.tgz", "integrity": "sha512-/RYwUI/kI5Gxbp4yn7xi9UTtmx9vLdA6i786cig4QpRJflEszykURIOg1VZO24/vE2etuLhyS3ZPJmp4fB7Wgw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "lit": "^2.5.0 || ^3.1.3" @@ -1691,6 +1695,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/button/-/button-1.12.1.tgz", "integrity": "sha512-+SXo3bnGZsSCNA77mS2+Xfu26KQQTJD1jjcbNGkPwqVFKTLzKSfpGINY4gEiefVSIDotH1m/W+5Hb+t5ahdW9w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.12.1", @@ -1707,6 +1712,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/button-group/-/button-group-1.12.1.tgz", "integrity": "sha512-KH14vRLm1Aa0LCfDYVN86E/ap6bSDagleZsHW/18WFwQKTfBnkVgY5bypMivjtWsE1D79poSO+eo64kpSeukjA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.12.1", @@ -1717,6 +1723,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/clear-button/-/clear-button-1.12.1.tgz", "integrity": "sha512-1WqHjfyWaGYY2hqSdkilVYxvnstcIYEnIPw7P+0LW+vfKMrMCVcYWcAPiszgVOBAA3IQqXIjlD+wOAWWpJpY1w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.12.1" @@ -1726,6 +1733,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/close-button/-/close-button-1.12.1.tgz", "integrity": "sha512-TjUWXrWA9Ewfu2XXaWmBAuyRiyPz5gz4Gw+4B6vyxJJUyMj9JFRhTn0HgD5wJfPd1nx/9PxwktgSkipbjH1MVQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.12.1" @@ -1735,6 +1743,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/divider/-/divider-1.12.1.tgz", "integrity": "sha512-h//ZlDNYLvhgAXn7HT5z6ZDxdv1zBumMiLC6+733y1JrU70oBadneBtB8DjsOTEyEY/mMW54G9T0r/rRtgGbew==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.12.1" @@ -1744,6 +1753,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/icon/-/icon-1.12.1.tgz", "integrity": "sha512-HsejJl/69L4eINRkuTUd/x9MeYKQN88UEXc3qBvgbfW4K0SUxZsu2dXVVfKVJUAqBnLZE6x4tIuXY8i2sT8JaA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.12.1", @@ -1755,6 +1765,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/icons-ui/-/icons-ui-1.12.1.tgz", "integrity": "sha512-3j/hzzl4uWrs80oseGBCCijCBO/3sMKcdlLe1ObET4A0vd1+HIeI/9NZ3qBR9Bhsu1IrCReU2GpydcNab+ok7A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.12.1", @@ -1766,6 +1777,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/icons-workflow/-/icons-workflow-1.12.1.tgz", "integrity": "sha512-VyDUKFnvgawwvKy+9WWjkJNzXEC9pxAbqnDdEMM4dkByiTWhX1QXkTAZaNZlX+vZagnJ9n0oyh+AywFtyUtE/Q==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.12.1", @@ -1776,6 +1788,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/iconset/-/iconset-1.12.1.tgz", "integrity": "sha512-z8Ja7CEHrJM1VQF3JiFpB9i7zXLCYQqgm+xCR32GSFCCqhWZIqkTqiFlVAdt1e+gEpkv09KVHGaKWvzMBznhcg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.12.1" @@ -1785,6 +1798,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/progress-circle/-/progress-circle-1.12.1.tgz", "integrity": "sha512-VS9KaexgkM8ZGTrOsR41FFIhtNtB+TzfrxbcGIh4yDAV1KhqhFyo0BnHNtIZFdqaLfjL4recNCRd9g7jqfEaTA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.12.1", @@ -1796,6 +1810,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/reactive-controllers/-/reactive-controllers-1.12.1.tgz", "integrity": "sha512-yhyhl9A8qYpSxlGHrzTRPMhIC1mybtleNaRMbe1V4kldVRAT2d7iSptKX4Qe04QuXzLrx0s4vf/XeGOSXdRHNQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/progress-circle": "1.12.1", @@ -1807,6 +1822,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@spectrum-web-components/shared/-/shared-1.12.1.tgz", "integrity": "sha512-eikz+xVMXKBlLPG7ImoClvwgWCtqpqBnFSRfM0hXJ1oHt9x3axOG17Oh1stZnyRAfFAwCeMPuXg8lMCSQM//Iw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@lit-labs/observers": "2.0.2", @@ -1814,22 +1830,11 @@ "focus-visible": "5.2.1" } }, - "node_modules/@spectrum-web-components/badge": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@spectrum-web-components/badge/-/badge-1.11.2.tgz", - "integrity": "sha512-mWG8ewcr7qg+kIjxAOYjkLQ9qcXD6TwE6Wx+cDNeaBESb7THHEaMYyQdoUCPbo2HbTVPQ4CcXX/8qGHmAgA1Jg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@spectrum-web-components/base": "1.11.2", - "@spectrum-web-components/core": "0.0.4", - "@spectrum-web-components/shared": "1.11.2" - } - }, "node_modules/@spectrum-web-components/base": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@spectrum-web-components/base/-/base-1.11.2.tgz", "integrity": "sha512-clE3qfZt2M/wLjbGMrpaUplIEDrrRVTA6SbxddXflNaIfmp+QCyWAj36oudPeAfnXVLG1/oyrc0wQwHEJ/gI3Q==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/core": "0.0.4", @@ -1840,6 +1845,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@spectrum-web-components/button/-/button-1.11.2.tgz", "integrity": "sha512-dQy+PcCqTu+eDZ9l9/x5gtGgLuHq/FDNqWut2lwNSg6s57JO1UzvUGux+Ga+e444j8OsPUXpA8C/FdR7C7KHGw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.11.2", @@ -1867,6 +1873,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@spectrum-web-components/clear-button/-/clear-button-1.11.2.tgz", "integrity": "sha512-OMTLWYosACH2Y+Eqmugo4/Z9Ckpir9RJIcXwiEayV9f72TNn3zEZax1o62gw44ipV23zgxcPBzsS75f/jKXqHw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.11.2" @@ -1876,6 +1883,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@spectrum-web-components/close-button/-/close-button-1.11.2.tgz", "integrity": "sha512-BF9DtBNzrsrQBZXSmgkU2xPwsHUyrlgV9uuExB3f2r/XwK2sT4sa2Xy5C0J6BozrYUj8cfDwyLxtS52aetyH1Q==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.11.2" @@ -1936,6 +1944,7 @@ "version": "0.0.4", "resolved": "https://registry.npmjs.org/@spectrum-web-components/core/-/core-0.0.4.tgz", "integrity": "sha512-EPJBQwJWQuabDPfKMqb5Q3++/xePXR9toTvKQ12bkBR3DG/bX6/O7sEzx+X8ZAdx+72PZwZ9/bWq9D33qv0gQA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@lit-labs/observers": "2.0.2", @@ -2017,6 +2026,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@spectrum-web-components/icon/-/icon-1.11.2.tgz", "integrity": "sha512-+moQY0OMlOqYLPF6UI28rJUAPAevbynFxdb6wTaCINV0u2NCFn7hWhnTulY9IqpeISNaMwRcZje4AlTlinz6VA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.11.2", @@ -2028,6 +2038,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@spectrum-web-components/icons-ui/-/icons-ui-1.11.2.tgz", "integrity": "sha512-88TWoYApEf/cg/cWnPJBcx9ipt4IS5J5SQPgccXU8kpkqwStYFnphzukLy+mGzqRq0k+ER3ducso2EyYa45Gog==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.11.2", @@ -2050,6 +2061,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@spectrum-web-components/iconset/-/iconset-1.11.2.tgz", "integrity": "sha512-pYhFmjLI+Fx0VWs1y9eEJahOzOYk1mcwnNkGm4L2wyJPGfhaG308QKjTE3v/jje3HvXqliZjz/1IxRBF6q0RUQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.11.2" @@ -2177,6 +2189,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@spectrum-web-components/progress-circle/-/progress-circle-1.11.2.tgz", "integrity": "sha512-xy4LDU2UpXaAi4I+kxj+HQiioq+GVTjEGMlcMD3Dy+6+1ER2HUNzGKaxnEQYbzKDaRMduS/rX9K+t3l23rjoKg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/base": "1.11.2", @@ -2188,6 +2201,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@spectrum-web-components/reactive-controllers/-/reactive-controllers-1.11.2.tgz", "integrity": "sha512-zd39hBX/LrmZOqVCH/AaiEhAhfAoWllMa+K6hee+gps6jVV2it12VhWpBwxIDOwgPB49dbp/BYAPhtlch72fhA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@spectrum-web-components/progress-circle": "1.11.2", @@ -2213,6 +2227,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@spectrum-web-components/shared/-/shared-1.11.2.tgz", "integrity": "sha512-FGId3nCk/gie3WM4TzBJ0+Ox2u/LHw3F7Jlx/Ur3FiBwoGUEwbp0h5kyIjq32Awx3nOj7HPEDiNco0qREbT5XQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@lit-labs/observers": "2.0.2", @@ -3379,6 +3394,16 @@ } } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4027,6 +4052,16 @@ "node": ">=12 || >=16" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -6031,6 +6066,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", @@ -9723,6 +9772,16 @@ "b4a": "^1.6.4" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10079,6 +10138,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/v8-compile-cache": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", diff --git a/package.json b/package.json index 340fe42a8..2972bc482 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "eslint-plugin-compat": "^4.0.2", "eslint-plugin-ecmalist": "^1.0.8", "eslint-plugin-import": "2.25.4", + "html2canvas": "^1.4.1", "jsdom": "^22.1.0", "lit": "^3.3.2", "sinon": "13.0.1", diff --git a/test/blocks/collapsible-rows/collapsible-rows-quote-actions.test.js b/test/blocks/collapsible-rows/collapsible-rows-quote-actions.test.js new file mode 100644 index 000000000..3c14499c7 --- /dev/null +++ b/test/blocks/collapsible-rows/collapsible-rows-quote-actions.test.js @@ -0,0 +1,87 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { setLibs } from '../../../express/code/scripts/utils.js'; +import decorate from '../../../express/code/blocks/collapsible-rows/collapsible-rows.js'; +import { waitFor } from '../../helpers/waitfor.js'; + +setLibs('/test/mocks/libs', { hostname: 'prod.example.com', search: '' }); + +describe('collapsible-rows quote actions ("Copy quote" / "Create a design")', () => { + let clipboardStub; + + beforeEach(() => { + clipboardStub = sinon.stub(navigator.clipboard, 'writeText').resolves(); + }); + + afterEach(() => { + clipboardStub.restore(); + document.body.innerHTML = ''; + }); + + it('does not render quote actions when no mini-editor block is present on the page', async () => { + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + const block = document.querySelector('.collapsible-rows'); + await decorate(block); + expect(block.querySelector('.collapsible-row-actions')).to.not.exist; + }); + + describe('with a mini-editor block present', () => { + let block; + + beforeEach(async () => { + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + const miniEditor = document.createElement('div'); + miniEditor.className = 'mini-editor'; + document.body.append(miniEditor); + block = document.querySelector('.collapsible-rows'); + await decorate(block); + }); + + it('renders a Copy quote and Create a design button per row', () => { + const rows = block.querySelectorAll('.collapsible-row-actions'); + expect(rows).to.have.length(3); + rows.forEach((actions) => { + expect(actions.querySelector('.collapsible-row-action--copy')).to.exist; + expect(actions.querySelector('.collapsible-row-action--design')).to.exist; + }); + }); + + it('copies "quote — author" to the clipboard and shows the shared toast', async () => { + const [firstActions] = block.querySelectorAll('.collapsible-row-actions'); + firstActions.querySelector('.collapsible-row-action--copy').click(); + await waitFor(() => !!document.querySelector('.copy-toast-message')); + expect(clipboardStub.calledOnceWith('"Patience is bitter, but its fruit is sweet." — Jean-Jacques Rousseau')).to.be.true; + expect(document.querySelector('.copy-toast-message').textContent).to.equal('Quote copied to clipboard'); + }); + + it('copies the quote alone when the row has no author', async () => { + const actionsRows = block.querySelectorAll('.collapsible-row-actions'); + const lastActions = actionsRows[actionsRows.length - 1]; + lastActions.querySelector('.collapsible-row-action--copy').click(); + await waitFor(() => clipboardStub.called); + expect(clipboardStub.calledOnceWith('"No author quote here."')).to.be.true; + }); + + it('dispatches mini-editor:use-quote with the quote and author when "Create a design" is clicked', () => { + const listener = sinon.spy(); + document.addEventListener('mini-editor:use-quote', listener); + const [firstActions] = block.querySelectorAll('.collapsible-row-actions'); + firstActions.querySelector('.collapsible-row-action--design').click(); + document.removeEventListener('mini-editor:use-quote', listener); + expect(listener.calledOnce).to.be.true; + expect(listener.firstCall.args[0].detail).to.deep.equal({ + quote: '"Patience is bitter, but its fruit is sweet."', + author: 'Jean-Jacques Rousseau', + }); + }); + + it('does not throw and shows no toast when the clipboard write is rejected', async () => { + clipboardStub.rejects(new Error('denied')); + const [firstActions] = block.querySelectorAll('.collapsible-row-actions'); + expect(() => firstActions.querySelector('.collapsible-row-action--copy').click()).to.not.throw(); + await clipboardStub.returnValues[0].catch(() => {}); + expect(document.querySelector('.copy-toast')).to.not.exist; + }); + }); +}); diff --git a/test/blocks/collapsible-rows/mocks/body.html b/test/blocks/collapsible-rows/mocks/body.html new file mode 100644 index 000000000..e189f6938 --- /dev/null +++ b/test/blocks/collapsible-rows/mocks/body.html @@ -0,0 +1,14 @@ +

diff --git a/test/blocks/mini-editor/mini-editor-background-loader.test.js b/test/blocks/mini-editor/mini-editor-background-loader.test.js new file mode 100644 index 000000000..b3bc1cbe1 --- /dev/null +++ b/test/blocks/mini-editor/mini-editor-background-loader.test.js @@ -0,0 +1,88 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import getCardBackgrounds from '../../../express/code/blocks/mini-editor/mini-editor-background-loader.js'; + +function validTemplate(id, renditionHref, componentHref) { + return { + id, + status: 'approved', + customLinks: { branchUrl: 'https://example.com' }, + behaviors: ['still'], + pages: [{ rendition: { image: { thumbnail: { componentId: 'abc' } } } }], + _links: { + 'http://ns.adobe.com/adobecloud/rel/rendition': { href: renditionHref }, + 'http://ns.adobe.com/adobecloud/rel/component': { href: componentHref }, + }, + }; +} + +describe('mini-editor-background-loader', () => { + let fetchStub; + + afterEach(() => { + fetchStub?.restore(); + }); + + it('fetches templates and maps them to { id, bg } using the thumbnail helper', async () => { + const items = [ + validTemplate('urn:1', 'https://cdn/rendition/1', 'https://cdn/component/1'), + validTemplate('urn:2', 'https://cdn/rendition/2', 'https://cdn/component/2'), + ]; + fetchStub = sinon.stub(window, 'fetch').resolves({ json: async () => ({ items }) }); + const cards = await getCardBackgrounds({ limit: 8, collectionId: 'urn:collection:1' }); + expect(fetchStub.calledOnce).to.be.true; + const [calledUrl] = fetchStub.firstCall.args; + expect(calledUrl).to.contain('collectionId=urn:collection:1'); + expect(cards).to.deep.equal([ + { id: 'urn:1', bg: 'https://cdn/rendition/1' }, + { id: 'urn:2', bg: 'https://cdn/rendition/2' }, + ]); + }); + + it('filters out templates that fail validity checks', async () => { + const items = [ + validTemplate('urn:1', 'https://cdn/rendition/1', 'https://cdn/component/1'), + { id: 'urn:bad', status: 'pending' }, + ]; + fetchStub = sinon.stub(window, 'fetch').resolves({ json: async () => ({ items }) }); + const cards = await getCardBackgrounds({ limit: 8, collectionId: 'urn:collection:1' }); + expect(cards).to.have.length(1); + expect(cards[0].id).to.equal('urn:1'); + }); + + it('respects limit even when more valid items are returned', async () => { + const items = [ + validTemplate('urn:1', 'https://cdn/rendition/1', 'https://cdn/component/1'), + validTemplate('urn:2', 'https://cdn/rendition/2', 'https://cdn/component/2'), + validTemplate('urn:3', 'https://cdn/rendition/3', 'https://cdn/component/3'), + ]; + fetchStub = sinon.stub(window, 'fetch').resolves({ json: async () => ({ items }) }); + const cards = await getCardBackgrounds({ limit: 2, collectionId: 'urn:collection:1' }); + expect(cards).to.have.length(2); + }); + + it('returns an empty array when the API returns no items', async () => { + fetchStub = sinon.stub(window, 'fetch').resolves({ json: async () => ({ items: [] }) }); + const cards = await getCardBackgrounds({ limit: 8, collectionId: 'urn:collection:1' }); + expect(cards).to.deep.equal([]); + }); + + it('returns an empty array when the API response has no items field', async () => { + fetchStub = sinon.stub(window, 'fetch').resolves({ json: async () => ({}) }); + const cards = await getCardBackgrounds({ limit: 8, collectionId: 'urn:collection:1' }); + expect(cards).to.deep.equal([]); + }); + + it('includes topics in the request when provided', async () => { + fetchStub = sinon.stub(window, 'fetch').resolves({ json: async () => ({ items: [] }) }); + await getCardBackgrounds({ limit: 8, collectionId: 'urn:collection:1', topics: 'nature' }); + const [calledUrl] = fetchStub.firstCall.args; + expect(calledUrl).to.contain('filters=topics==nature'); + }); + + it('still fetches (falling back to the default collection) when no collectionId is authored', async () => { + fetchStub = sinon.stub(window, 'fetch').resolves({ json: async () => ({ items: [] }) }); + await getCardBackgrounds({ limit: 8 }); + expect(fetchStub.calledOnce).to.be.true; + }); +}); diff --git a/test/blocks/mini-editor/mini-editor-fonts-loader.test.js b/test/blocks/mini-editor/mini-editor-fonts-loader.test.js new file mode 100644 index 000000000..0896a651b --- /dev/null +++ b/test/blocks/mini-editor/mini-editor-fonts-loader.test.js @@ -0,0 +1,88 @@ +import { expect } from '@esm-bundle/chai'; +import getFontOptions from '../../../express/code/blocks/mini-editor/mini-editor-fonts-loader.js'; + +const FALLBACK_LABELS = ['Sans', 'Serif', 'Script', 'Bold', 'Serious']; + +describe('mini-editor-fonts-loader', () => { + afterEach(() => { + delete window.Typekit; + document.querySelectorAll('script[src*="use.typekit.net"]').forEach((s) => s.remove()); + }); + + it('falls back to the bundled font options when Typekit exposes no fonts', async () => { + window.Typekit = { load: ({ active }) => active?.() }; + const options = await getFontOptions(); + expect(options.map((o) => o.label)).to.deep.equal(FALLBACK_LABELS); + }); + + it('falls back when Typekit reports inactive (e.g. blocked kit)', async () => { + window.Typekit = { load: ({ inactive }) => inactive?.() }; + const options = await getFontOptions(); + expect(options.map((o) => o.label)).to.deep.equal(FALLBACK_LABELS); + }); + + it('falls back when Typekit.load throws synchronously', async () => { + window.Typekit = { load: () => { throw new Error('boom'); } }; + const options = await getFontOptions(); + expect(options.map((o) => o.label)).to.deep.equal(FALLBACK_LABELS); + }); + + it('builds options from the fonts Typekit actually exposes, humanizing the slug', async () => { + window.Typekit = { + load: ({ active }) => active?.(), + fonts: { + fonts: [ + { family: 'gothic-a1', weight: '400', style: 'normal' }, + { family: 'source-han-sans-japanese', weight: '700', style: 'normal' }, + ], + }, + }; + const options = await getFontOptions(); + expect(options).to.deep.equal([ + { label: 'Gothic A1', font: '"gothic-a1", var(--body-font-family, sans-serif)' }, + { + label: 'Source Han Sans Japanese', + font: '"source-han-sans-japanese", var(--body-font-family, sans-serif)', + weight: '700', + }, + ]); + }); + + it('collapses multiple variants of the same family into one option with italic/bold flags', async () => { + window.Typekit = { + load: ({ active }) => active?.(), + fonts: { + fonts: [ + { family: 'noto-sans', weight: '400', style: 'normal' }, + { family: 'noto-sans', weight: '400', style: 'italic' }, + { family: 'noto-sans', weight: '700', style: 'normal' }, + ], + }, + }; + const options = await getFontOptions(); + expect(options).to.have.length(1); + expect(options[0]).to.deep.equal({ + label: 'Noto Sans', + font: '"noto-sans", var(--body-font-family, sans-serif)', + italic: true, + weight: '700', + }); + }); + + it('falls back when Typekit.fonts.fonts is present but empty', async () => { + window.Typekit = { load: ({ active }) => active?.(), fonts: { fonts: [] } }; + const options = await getFontOptions(); + expect(options.map((o) => o.label)).to.deep.equal(FALLBACK_LABELS); + }); + + it('skips entries with no family', async () => { + window.Typekit = { + load: ({ active }) => active?.(), + fonts: { fonts: [{ family: '', weight: '400' }, { family: 'gothic-a1', weight: '400' }] }, + }; + const options = await getFontOptions(); + expect(options).to.deep.equal([ + { label: 'Gothic A1', font: '"gothic-a1", var(--body-font-family, sans-serif)' }, + ]); + }); +}); diff --git a/test/blocks/mini-editor/mini-editor.test.js b/test/blocks/mini-editor/mini-editor.test.js new file mode 100644 index 000000000..3dbc92b28 --- /dev/null +++ b/test/blocks/mini-editor/mini-editor.test.js @@ -0,0 +1,203 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { setLibs } from '../../../express/code/scripts/utils.js'; +import init from '../../../express/code/blocks/mini-editor/mini-editor.js'; +import MiniEditorCardExporter from '../../../express/code/scripts/utils/mini-editor-card-export.js'; +import { waitFor } from '../../helpers/waitfor.js'; + +setLibs('/test/mocks/libs', { hostname: 'prod.example.com', search: '' }); + +function validTemplate(id) { + return { + id, + status: 'approved', + customLinks: { branchUrl: 'https://example.com' }, + behaviors: ['still'], + pages: [{ rendition: { image: { thumbnail: { componentId: 'abc' } } } }], + _links: { + 'http://ns.adobe.com/adobecloud/rel/rendition': { href: `https://cdn/rendition/${id}` }, + 'http://ns.adobe.com/adobecloud/rel/component': { href: `https://cdn/component/${id}` }, + }, + }; +} + +// getCardBackgrounds always fetches (see mini-editor-background-loader.js) — +// this default response gives every test a non-empty card set to mount the +// widget with, unless a test overrides fetchStub for its own scenario. +const defaultTemplateItems = Array.from({ length: 8 }, (_, i) => validTemplate(`urn:${i}`)); + +describe('mini-editor', () => { + let fetchStub; + + beforeEach(() => { + window.Typekit = { load: ({ active }) => active?.() }; + fetchStub = sinon.stub(window, 'fetch').resolves({ json: async () => ({ items: defaultTemplateItems }) }); + sinon.stub(window, 'requestAnimationFrame').callsFake((callback) => { + callback(performance.now()); + return 1; + }); + }); + + afterEach(() => { + delete window.Typekit; + delete window.lana; + delete window.placeholders; + sinon.restore(); + document.body.innerHTML = ''; + }); + + async function decorateWithBody() { + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + const block = document.querySelector('.mini-editor'); + await init(block); + return block; + } + + it('builds the header from the authored content row and clears the raw authored markup', async () => { + const block = await decorateWithBody(); + const header = block.querySelector('.mini-editor-header'); + expect(header.querySelector('h2').textContent).to.equal('Trust quotes to help build stronger relationships.'); + expect(header.querySelector('p').textContent).to.contain('Explore quotes'); + }); + + it('styles the authored CTA link as an accent button', async () => { + const block = await decorateWithBody(); + const cta = block.querySelector('.mini-editor-header a'); + expect(cta.classList.contains('button')).to.be.true; + expect(cta.classList.contains('accent')).to.be.true; + }); + + it('prepends a logo lockup to the header', async () => { + const block = await decorateWithBody(); + expect(block.querySelector('.mini-editor-header .mini-editor-logo')).to.exist; + }); + + it('mounts the widget stage and desktop decorations once cards and quotes resolve', async () => { + const block = await decorateWithBody(); + await waitFor(() => !!block.querySelector('.mini-editor-stage')); + expect(block.querySelector('.mini-editor-stage')).to.exist; + expect(block.querySelector('.mini-editor-header .mini-editor-decorations')).to.exist; + }); + + it('seeds the widget with quotes read from the page\'s collapsible-rows block', async () => { + const block = await decorateWithBody(); + await waitFor(() => !!block.querySelector('.me-quote')); + expect(block.querySelector('.me-quote').textContent).to.equal('"Patience is bitter, but its fruit is sweet."'); + expect(block.querySelector('.me-author').textContent).to.equal('Jean-Jacques Rousseau'); + }); + + it('downloads the content model once after rapid clicks', async () => { + const block = await decorateWithBody(); + const downloadStub = sinon.stub(MiniEditorCardExporter, 'download').resolves(); + + const downloadButton = block.querySelector('.me-action--download'); + downloadButton.click(); + downloadButton.click(); + await waitFor(() => downloadStub.calledOnce); + + expect(downloadStub.calledOnce).to.be.true; + expect(downloadStub.firstCall.args[0]).to.deep.include({ + quote: '"Patience is bitter, but its fruit is sweet."', + author: 'Jean-Jacques Rousseau', + }); + expect(downloadStub.firstCall.args[0].backgroundUrl).to.equal('https://cdn/rendition/urn:0'); + expect(downloadButton.disabled).to.be.false; + expect(downloadButton.hasAttribute('aria-busy')).to.be.false; + }); + + it('downloads the latest model after carousel navigation', async () => { + const block = await decorateWithBody(); + const downloadStub = sinon.stub(MiniEditorCardExporter, 'download').resolves(); + + block.querySelector('.me-arc-nav--next').click(); + block.querySelector('.me-action--download').click(); + await waitFor(() => downloadStub.calledOnce); + + expect(downloadStub.firstCall.args[0]).to.deep.include({ + quote: '"Adopt the pace of nature: her secret is patience."', + author: 'Ralph Waldo Emerson', + }); + expect(downloadStub.firstCall.args[0].backgroundUrl).to.equal('https://cdn/rendition/urn:1'); + }); + + it('logs and shows a localized negative toast when download fails', async () => { + const block = await decorateWithBody(); + window.placeholders = { 'screenshot-download-failed': 'Unable to download this design.' }; + window.lana = { log: sinon.spy() }; + sinon.stub(MiniEditorCardExporter, 'download').rejects(new Error('render failed')); + + block.querySelector('.me-action--download').click(); + await waitFor(() => !!document.querySelector('sp-toast')); + + const toast = document.querySelector('sp-toast'); + expect(toast.textContent).to.equal('Unable to download this design.'); + expect(toast.getAttribute('variant')).to.equal('negative'); + expect(window.lana.log.calledWithMatch('Mini-editor download failed: render failed')).to.be.true; + expect(document.body.contains(block)).to.be.true; + }); + + it('removes the whole section when no quotes are authored on the page', async () => { + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + document.querySelector('.collapsible-rows').remove(); + const section = document.createElement('div'); + section.className = 'section'; + const block = document.querySelector('.mini-editor'); + block.replaceWith(section); + section.append(block); + await init(block); + expect(document.body.contains(section)).to.be.false; + }); + + it('parses authored collection id, limit, and topics rows', async () => { + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + const block = document.querySelector('.mini-editor'); + const collectionRow = document.createElement('div'); + collectionRow.innerHTML = '
collection id
urn:aaid:sc:VA6C2:test
'; + const limitRow = document.createElement('div'); + limitRow.innerHTML = '
limit
4
'; + block.append(collectionRow, limitRow); + + fetchStub.resolves({ json: async () => ({ items: [] }) }); + await init(block); + + expect(fetchStub.calledOnce).to.be.true; + const [calledUrl] = fetchStub.firstCall.args; + expect(calledUrl).to.contain('collectionId=urn:aaid:sc:VA6C2:test'); + expect(calledUrl).to.contain('limit=4'); + }); + + it('ignores an authored limit of 0 or a non-numeric value and keeps the default', async () => { + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + const block = document.querySelector('.mini-editor'); + const limitRow = document.createElement('div'); + limitRow.innerHTML = '
limit
not-a-number
'; + block.append(limitRow); + await init(block); + await waitFor(() => !!block.querySelector('.mini-editor-decorations')); + // Default TEMPLATE_LIMIT (8) fetched cards minus the one powering the main + // widget leaves 7 decorative cards, since the invalid limit was ignored. + expect(block.querySelectorAll('.mini-editor-decorations .me-deco')).to.have.length(7); + }); + + it('removes the section and logs to lana when a background/font loader rejects', async () => { + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + const block = document.querySelector('.mini-editor'); + const section = document.createElement('div'); + section.className = 'section'; + block.replaceWith(section); + section.append(block); + window.lana = { log: sinon.spy() }; + fetchStub.restore(); + fetchStub = sinon.stub(window, 'fetch').rejects(new Error('network down')); + const collectionRow = document.createElement('div'); + collectionRow.innerHTML = '
collection id
urn:aaid:sc:VA6C2:test
'; + block.append(collectionRow); + + await init(block); + + expect(document.body.contains(section)).to.be.false; + expect(window.lana.log.calledOnce).to.be.true; + delete window.lana; + }); +}); diff --git a/test/blocks/mini-editor/mocks/body.html b/test/blocks/mini-editor/mocks/body.html new file mode 100644 index 000000000..2191e69f5 --- /dev/null +++ b/test/blocks/mini-editor/mocks/body.html @@ -0,0 +1,21 @@ +
+ +
+
+
"Patience is bitter, but its fruit is sweet."
+
Jean-Jacques Rousseau
+
+
+
"Adopt the pace of nature: her secret is patience."
+
Ralph Waldo Emerson
+
+
+
diff --git a/test/scripts/utils/copy-toast.test.js b/test/scripts/utils/copy-toast.test.js new file mode 100644 index 000000000..28dde3e62 --- /dev/null +++ b/test/scripts/utils/copy-toast.test.js @@ -0,0 +1,73 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { setLibs } from '../../../express/code/scripts/utils.js'; +import showCopyToast from '../../../express/code/scripts/utils/copy-toast.js'; + +// copy-toast lazily imports `${getLibs()}/utils/utils.js` for createTag/loadStyle/ +// getConfig on first call — point it at the lightweight test mock instead of a +// real libs origin. +setLibs('/test/mocks/libs', { hostname: 'prod.example.com', search: '' }); + +describe('copy-toast', () => { + let clock; + + beforeEach(() => { + clock = sinon.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + clock.restore(); + // The module keeps its container singleton for the page's lifetime (see + // copy-toast.js's module-level `container`) — only clear its contents + // between tests, not the container itself, to match that real behaviour. + document.querySelectorAll('.copy-toast').forEach((el) => el.remove()); + }); + + it('appends a single, reusable toast container to the body', async () => { + await showCopyToast('First message'); + await showCopyToast('Second message'); + expect(document.querySelectorAll('.copy-toast-container')).to.have.length(1); + }); + + it('shows the message text and marks the toast visible on the next frame', async () => { + await showCopyToast('Quote copied to clipboard'); + const toast = document.querySelector('.copy-toast'); + expect(toast.querySelector('.copy-toast-message').textContent).to.equal('Quote copied to clipboard'); + await new Promise((resolve) => { requestAnimationFrame(() => resolve()); }); + expect(toast.classList.contains('is-visible')).to.be.true; + }); + + it('removes any previous toast before showing a new one', async () => { + await showCopyToast('First'); + const first = document.querySelector('.copy-toast'); + await showCopyToast('Second'); + expect(document.body.contains(first)).to.be.false; + expect(document.querySelectorAll('.copy-toast')).to.have.length(1); + expect(document.querySelector('.copy-toast-message').textContent).to.equal('Second'); + }); + + it('auto-dismisses after 5 seconds', async () => { + await showCopyToast('Auto dismiss me'); + const toast = document.querySelector('.copy-toast'); + await clock.tickAsync(5000); + expect(toast.classList.contains('is-visible')).to.be.false; + toast.dispatchEvent(new Event('transitionend')); + expect(document.body.contains(toast)).to.be.false; + }); + + it('the close button removes the toast without waiting for the timeout', async () => { + await showCopyToast('Dismiss me now'); + const toast = document.querySelector('.copy-toast'); + toast.querySelector('.copy-toast-close').click(); + expect(toast.classList.contains('is-visible')).to.be.false; + toast.dispatchEvent(new Event('transitionend')); + expect(document.body.contains(toast)).to.be.false; + }); + + it('exposes an accessible status container with polite live region semantics', async () => { + await showCopyToast('Accessible message'); + const container = document.querySelector('.copy-toast-container'); + expect(container.getAttribute('role')).to.equal('status'); + expect(container.getAttribute('aria-live')).to.equal('polite'); + }); +}); diff --git a/test/scripts/utils/download-utils.test.js b/test/scripts/utils/download-utils.test.js new file mode 100644 index 000000000..c663ed20a --- /dev/null +++ b/test/scripts/utils/download-utils.test.js @@ -0,0 +1,184 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; + +import { + captureElementAsImage, + downloadElementAsImage, + Html2CanvasLoader, +} from '../../../express/code/scripts/utils/download-utils.js'; + +function createFakeCanvas() { + const canvas = document.createElement('canvas'); + canvas.width = 10; + canvas.height = 10; + return canvas; +} + +function attachedElement() { + const el = document.createElement('div'); + el.style.width = '20px'; + el.style.height = '20px'; + document.body.append(el); + return el; +} + +async function expectRejection(promise, messageMatch) { + try { + await promise; + expect.fail('expected promise to reject'); + } catch (error) { + expect(error.message).to.match(messageMatch); + } +} + +describe('download-utils', () => { + afterEach(() => { + sinon.restore(); + document.body.innerHTML = ''; + }); + + describe('validation', () => { + it('throws TypeError synchronously for a non-HTMLElement', () => { + expect(() => captureElementAsImage('not-an-element')).to.throw(TypeError, /must be an HTMLElement/); + }); + + it('throws for a disconnected element', () => { + const el = document.createElement('div'); + expect(() => captureElementAsImage(el)).to.throw(/must be attached to the document/); + }); + + it('throws for a zero-size element', () => { + const el = document.createElement('div'); + el.style.display = 'none'; + document.body.append(el); + expect(() => captureElementAsImage(el)).to.throw(/zero rendered size/); + }); + }); + + describe('captureElementAsImage happy path', () => { + it('calls html2canvas with foreignObjectRendering forced false and returns a png Blob', async () => { + const el = attachedElement(); + const html2canvasStub = sinon.stub().resolves(createFakeCanvas()); + sinon.stub(Html2CanvasLoader, 'load').resolves(html2canvasStub); + + const blob = await captureElementAsImage(el); + + expect(html2canvasStub.calledOnce).to.equal(true); + const [target, opts] = html2canvasStub.firstCall.args; + expect(target).to.equal(el); + expect(opts.foreignObjectRendering).to.equal(false); + expect(opts.useCORS).to.equal(true); + expect(opts.backgroundColor).to.equal(null); + expect(blob.type).to.equal('image/png'); + expect(blob.size).to.be.greaterThan(0); + }); + + it('defaults backgroundColor to white and uses jpeg mime type when format is jpeg', async () => { + const el = attachedElement(); + const html2canvasStub = sinon.stub().resolves(createFakeCanvas()); + sinon.stub(Html2CanvasLoader, 'load').resolves(html2canvasStub); + + const blob = await captureElementAsImage(el, { format: 'jpeg' }); + + const [, opts] = html2canvasStub.firstCall.args; + expect(opts.backgroundColor).to.equal('#ffffff'); + expect(blob.type).to.equal('image/jpeg'); + }); + + it('rejects for an unsupported format before loading html2canvas', async () => { + const el = attachedElement(); + const loadSpy = sinon.stub(Html2CanvasLoader, 'load').resolves(sinon.stub()); + + await expectRejection(captureElementAsImage(el, { format: 'gif' }), /unsupported format/); + expect(loadSpy.called).to.equal(false); + }); + }); + + describe('loader failure and retry', () => { + it('rejects when the loader fails, and retries on the next call', async () => { + const el = attachedElement(); + const loadStub = sinon.stub(Html2CanvasLoader, 'load'); + loadStub.onCall(0).rejects(new Error('network down')); + loadStub.onCall(1).resolves(sinon.stub().resolves(createFakeCanvas())); + + await expectRejection(captureElementAsImage(el), /network down/); + const blob = await captureElementAsImage(el); + expect(blob.size).to.be.greaterThan(0); + }); + }); + + describe('tainted canvas', () => { + it('rejects with CORS guidance when canvas.toBlob throws SecurityError', async () => { + const el = attachedElement(); + const canvas = createFakeCanvas(); + canvas.toBlob = () => { + const err = new Error('tainted'); + err.name = 'SecurityError'; + throw err; + }; + sinon.stub(Html2CanvasLoader, 'load').resolves(sinon.stub().resolves(canvas)); + + await expectRejection(captureElementAsImage(el), /tainted by a cross-origin image/); + }); + }); + + describe('isolate option', () => { + it('renders a clone rather than the live element, and cleans up afterwards', async () => { + const el = attachedElement(); + const html2canvasStub = sinon.stub().resolves(createFakeCanvas()); + sinon.stub(Html2CanvasLoader, 'load').resolves(html2canvasStub); + + await captureElementAsImage(el, { isolate: true }); + + const [target] = html2canvasStub.firstCall.args; + expect(target).to.not.equal(el); + expect(target.isConnected).to.equal(false); + }); + + it('cleans up the offscreen clone even when html2canvas rejects', async () => { + const el = attachedElement(); + sinon.stub(Html2CanvasLoader, 'load').resolves(sinon.stub().rejects(new Error('render failed'))); + const bodyChildrenBefore = document.body.children.length; + + await expectRejection(captureElementAsImage(el, { isolate: true }), /render failed/); + + expect(document.body.children.length).to.equal(bodyChildrenBefore); + }); + }); + + describe('downloadElementAsImage', () => { + it('triggers a Blob download with a default timestamped filename', async () => { + const el = attachedElement(); + sinon.stub(Html2CanvasLoader, 'load').resolves(sinon.stub().resolves(createFakeCanvas())); + const createObjectURLSpy = sinon.spy(URL, 'createObjectURL'); + const revokeObjectURLSpy = sinon.spy(URL, 'revokeObjectURL'); + + const bodyChildrenBefore = document.body.children.length; + const { filename } = await downloadElementAsImage(el); + + expect(filename).to.match(/^screenshot-\d+\.png$/); + expect(createObjectURLSpy.calledOnce).to.equal(true); + expect(revokeObjectURLSpy.calledOnce).to.equal(true); + expect(document.body.children.length).to.equal(bodyChildrenBefore); + }); + + it('appends the correct extension for a custom filename without one, and respects an existing extension', async () => { + const el = attachedElement(); + sinon.stub(Html2CanvasLoader, 'load').resolves(sinon.stub().resolves(createFakeCanvas())); + + const withoutExt = await downloadElementAsImage(el, { filename: 'my-card' }); + expect(withoutExt.filename).to.equal('my-card.png'); + + const withExt = await downloadElementAsImage(el, { filename: 'my-card.png' }); + expect(withExt.filename).to.equal('my-card.png'); + }); + + it('uses a .jpg extension when format is jpeg', async () => { + const el = attachedElement(); + sinon.stub(Html2CanvasLoader, 'load').resolves(sinon.stub().resolves(createFakeCanvas())); + + const { filename } = await downloadElementAsImage(el, { format: 'jpeg' }); + expect(filename).to.match(/\.jpg$/); + }); + }); +}); diff --git a/test/scripts/utils/mini-editor-card-export.test.js b/test/scripts/utils/mini-editor-card-export.test.js new file mode 100644 index 000000000..90d3a34df --- /dev/null +++ b/test/scripts/utils/mini-editor-card-export.test.js @@ -0,0 +1,58 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import MiniEditorCardExporter from '../../../express/code/scripts/utils/mini-editor-card-export.js'; + +function createBackgroundUrl() { + const canvas = document.createElement('canvas'); + canvas.width = 20; + canvas.height = 10; + const context = canvas.getContext('2d'); + context.fillStyle = '#00ff00'; + context.fillRect(0, 0, canvas.width, canvas.height); + return canvas.toDataURL('image/png'); +} + +function createModel() { + return { + quote: 'Rendered from the content model', + author: 'Test Author', + backgroundUrl: createBackgroundUrl(), + font: { family: 'sans-serif', style: 'normal', weight: 'normal' }, + }; +} + +async function readBlobDimensions(blob) { + const bitmap = await createImageBitmap(blob); + const dimensions = { width: bitmap.width, height: bitmap.height }; + bitmap.close(); + return dimensions; +} + +describe('mini-editor card export', () => { + afterEach(() => sinon.restore()); + + it('renders a 1084x700 PNG through the worker path', async () => { + if (!MiniEditorCardExporter.supportsWorkerRendering()) return; + const blob = await MiniEditorCardExporter.createCardBlob(createModel()); + expect(blob.type).to.equal('image/png'); + expect(await readBlobDimensions(blob)).to.deep.equal({ width: 1084, height: 700 }); + }); + + it('renders the same PNG dimensions through the direct Canvas fallback', async () => { + sinon.stub(MiniEditorCardExporter, 'supportsWorkerRendering').returns(false); + const blob = await MiniEditorCardExporter.createCardBlob(createModel()); + expect(blob.type).to.equal('image/png'); + expect(await readBlobDimensions(blob)).to.deep.equal({ width: 1084, height: 700 }); + }); + + it('downloads with a timestamped PNG filename', async () => { + sinon.stub(MiniEditorCardExporter, 'supportsWorkerRendering').returns(false); + let filename; + sinon.stub(HTMLAnchorElement.prototype, 'click').callsFake(function click() { + filename = this.download; + }); + const result = await MiniEditorCardExporter.download(createModel()); + expect(result.filename).to.equal(filename); + expect(filename).to.match(/^screenshot-\d+\.png$/); + }); +}); diff --git a/test/scripts/utils/mini-editor-card-renderer.test.js b/test/scripts/utils/mini-editor-card-renderer.test.js new file mode 100644 index 000000000..e3a006b5c --- /dev/null +++ b/test/scripts/utils/mini-editor-card-renderer.test.js @@ -0,0 +1,51 @@ +import { expect } from '@esm-bundle/chai'; +import { + calculateCoverCrop, + drawMiniEditorCard, + MINI_EDITOR_EXPORT_HEIGHT, + MINI_EDITOR_EXPORT_WIDTH, + wrapCanvasText, +} from '../../../express/code/scripts/utils/mini-editor-card-renderer.js'; + +const model = { + quote: 'A short quote', + author: '', + font: { family: 'sans-serif', style: 'normal', weight: 'normal' }, +}; + +describe('mini-editor card renderer', () => { + it('calculates a centered cover crop', () => { + expect(calculateCoverCrop(200, 100, 100, 100)).to.deep.equal({ + sourceX: 50, + sourceY: 0, + sourceWidth: 100, + sourceHeight: 100, + }); + }); + + it('wraps text and splits words wider than the available line', () => { + const context = { measureText: (text) => ({ width: text.length * 10 }) }; + expect(wrapCanvasText(context, 'one two three', 70)).to.deep.equal(['one two', 'three']); + expect(wrapCanvasText(context, 'abcdefgh', 30)).to.deep.equal(['abc', 'def', 'gh']); + }); + + it('renders an opaque fixed-size rectangle with square corners', () => { + const background = document.createElement('canvas'); + background.width = 20; + background.height = 20; + const backgroundContext = background.getContext('2d'); + backgroundContext.fillStyle = '#ff0000'; + backgroundContext.fillRect(0, 0, 20, 20); + + const canvas = document.createElement('canvas'); + canvas.width = MINI_EDITOR_EXPORT_WIDTH; + canvas.height = MINI_EDITOR_EXPORT_HEIGHT; + const context = canvas.getContext('2d'); + drawMiniEditorCard(context, background, model); + + expect(canvas.width).to.equal(1084); + expect(canvas.height).to.equal(700); + expect(Array.from(context.getImageData(0, 0, 1, 1).data)).to.deep.equal([255, 0, 0, 255]); + expect(Array.from(context.getImageData(1083, 699, 1, 1).data)).to.deep.equal([255, 0, 0, 255]); + }); +}); diff --git a/test/scripts/widgets/mini-editor-widget.test.js b/test/scripts/widgets/mini-editor-widget.test.js new file mode 100644 index 000000000..99b8444d4 --- /dev/null +++ b/test/scripts/widgets/mini-editor-widget.test.js @@ -0,0 +1,327 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { createTag, getIconElementDeprecated } from '../../../express/code/scripts/utils.js'; +import createMiniEditorWidget from '../../../express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js'; + +const noop = () => {}; +const a11y = { + trapFocus: () => ({ release: noop }), + handleEscapeClose: () => ({ release: noop }), + disableBackgroundScroll: noop, + restoreBackgroundScroll: noop, + copyQuoteToClipboard: async () => true, +}; + +const fontOptions = [ + { label: 'Sans', font: '"Cal Sans", sans-serif' }, + { label: 'Serif', font: 'Georgia, serif', italic: true }, +]; + +function buildCardSet(count = 9) { + return Array.from({ length: count }, (_, i) => ({ + card: { id: `urn:${i}`, bg: `/img/image${i}.jpg` }, + quote: `Quote number ${i}`, + author: i % 2 === 0 ? `Author ${i}` : '', + })); +} + +async function mount(overrides = {}) { + const root = document.createElement('div'); + root.className = 'mini-editor'; + document.body.append(root); + const editor = await createMiniEditorWidget({ + root, + topActions: [], + fontOptions, + backgrounds: { cardSet: buildCardSet(), decoCount: 8 }, + a11y, + deps: { createTag, getIconElementDeprecated }, + ...overrides, + }); + root.append(editor.decorations, editor.stage); + return { root, editor }; +} + +describe('mini-editor-widget', () => { + let clock; + + beforeEach(() => { + clock = sinon.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + clock.restore(); + document.body.innerHTML = ''; + }); + + it('returns the stage, decorations, and control API', async () => { + const { editor } = await mount(); + expect(editor.stage).to.be.instanceOf(HTMLElement); + expect(editor.decorations).to.be.instanceOf(HTMLElement); + expect(editor.useQuote).to.be.a('function'); + expect(editor.updateCentre).to.be.a('function'); + expect(editor.getContentModel).to.be.a('function'); + expect(editor.syncViewportMode).to.be.a('function'); + expect(editor.destroy).to.be.a('function'); + }); + + it('exposes a defensive snapshot of the initial edited content', async () => { + const { editor } = await mount(); + const model = editor.getContentModel(); + expect(model).to.deep.equal({ + quote: 'Quote number 0', + author: 'Author 0', + backgroundUrl: '/img/image0.jpg', + font: { family: fontOptions[0].font, style: 'normal', weight: 'normal' }, + }); + + model.quote = 'Changed outside'; + model.font.family = 'Changed outside'; + expect(editor.getContentModel().quote).to.equal('Quote number 0'); + expect(editor.getContentModel().font.family).to.equal(fontOptions[0].font); + }); + + it('renders the first card set entry into the main widget card', async () => { + const { root } = await mount(); + const quote = root.querySelector('.me-quote'); + const author = root.querySelector('.me-author'); + expect(quote.textContent).to.equal('Quote number 0'); + expect(author.textContent).to.equal('Author 0'); + expect(root.style.getPropertyValue('--me-card-bg')).to.contain('/img/image0.jpg'); + }); + + it('builds one decorative card per entry after the first, up to decoCount', async () => { + const { root } = await mount(); + const decos = root.querySelectorAll('.mini-editor-decorations .me-deco'); + expect(decos.length).to.equal(8); + }); + + it('omits the author line on a decorative card when the entry has no author', async () => { + const { root } = await mount(); + // cardSet[1] (author '') powers the first decorative card (me-deco--1). + const deco = root.querySelector('.me-deco--1'); + expect(deco.querySelector('.me-deco-author')).to.not.exist; + }); + + it('selects the first font option on load and applies it as a CSS variable', async () => { + const { root } = await mount(); + expect(root.style.getPropertyValue('--me-quote-font')).to.equal(fontOptions[0].font); + const selected = root.querySelector('.me-row--fonts .me-font.is-selected'); + expect(selected.textContent).to.equal('Sans'); + }); + + it('applies a picked font to the CSS variables and marks it selected', async () => { + const { root, editor } = await mount(); + const serifBtn = Array.from(root.querySelectorAll('.me-row--fonts .me-font')) + .find((b) => b.textContent === 'Serif'); + serifBtn.click(); + expect(root.style.getPropertyValue('--me-quote-font')).to.equal(fontOptions[1].font); + expect(root.style.getPropertyValue('--me-quote-font-style')).to.equal('italic'); + expect(serifBtn.classList.contains('is-selected')).to.be.true; + expect(editor.getContentModel().font).to.deep.equal({ + family: fontOptions[1].font, + style: 'italic', + weight: 'normal', + }); + }); + + it('updates the model when a background is picked', async () => { + const { root, editor } = await mount(); + root.querySelectorAll('.me-row--colour .me-swatch-btn')[2].click(); + expect(editor.getContentModel().backgroundUrl).to.equal('/img/image2.jpg'); + }); + + it('opens the matching panel and toggles aria-expanded when a control is clicked', async () => { + const { root } = await mount(); + const fontControl = root.querySelector('.me-control--font'); + fontControl.click(); + expect(root.getAttribute('data-me-panel')).to.equal('fonts'); + expect(fontControl.getAttribute('aria-expanded')).to.equal('true'); + fontControl.click(); + expect(root.getAttribute('data-me-panel')).to.equal('none'); + expect(fontControl.getAttribute('aria-expanded')).to.equal('false'); + }); + + it('closes the open panel when clicking outside the widget', async () => { + const { root } = await mount(); + root.querySelector('.me-control--font').click(); + expect(root.getAttribute('data-me-panel')).to.equal('fonts'); + document.body.click(); + expect(root.getAttribute('data-me-panel')).to.equal('none'); + }); + + it('useQuote swaps the quote/author shown in the main widget card', async () => { + const { root, editor } = await mount(); + editor.useQuote({ quote: 'Swapped in', author: 'Someone Else' }); + expect(root.querySelector('.me-quote').textContent).to.equal('Swapped in'); + expect(root.querySelector('.me-author').textContent).to.equal('Someone Else'); + expect(root.querySelector('.me-author').style.display).to.equal(''); + expect(editor.getContentModel()).to.include({ + quote: 'Swapped in', + author: 'Someone Else', + }); + }); + + it('useQuote hides the author line when no author is given', async () => { + const { root, editor } = await mount(); + editor.useQuote({ quote: 'No author here' }); + expect(root.querySelector('.me-author').style.display).to.equal('none'); + }); + + it('copying the main quote shows the is-copied affordance and then clears it', async () => { + const { root } = await mount(); + const quoteWrap = root.querySelector('.me-quote-wrap'); + quoteWrap.click(); + await clock.tickAsync(0); + expect(quoteWrap.classList.contains('is-copied')).to.be.true; + await clock.tickAsync(1200); + expect(quoteWrap.classList.contains('is-copied')).to.be.false; + }); + + it('dispatching mini-editor:use-quote updates the widget and scrolls it into view', async () => { + const { root } = await mount(); + root.scrollIntoView = sinon.spy(); + document.dispatchEvent(new CustomEvent('mini-editor:use-quote', { + detail: { quote: 'From collapsible-rows', author: 'Some Author' }, + })); + expect(root.querySelector('.me-quote').textContent).to.equal('From collapsible-rows'); + expect(root.scrollIntoView.calledOnce).to.be.true; + }); + + it('destroy removes the outside-click and use-quote listeners', async () => { + const { root, editor } = await mount(); + editor.destroy(); + root.scrollIntoView = sinon.spy(); + document.dispatchEvent(new CustomEvent('mini-editor:use-quote', { + detail: { quote: 'Should not apply', author: '' }, + })); + expect(root.querySelector('.me-quote').textContent).to.not.equal('Should not apply'); + expect(root.scrollIntoView.called).to.be.false; + }); + + describe('arc carousel (tablet/mobile)', () => { + it('navigating next updates the centre card and fires useQuote for the new active entry', async () => { + const { root, editor } = await mount(); + const nextBtn = root.querySelector('.me-arc-nav--next'); + const centerBefore = root.querySelector('.me-arc-card--center .me-arc-quote').textContent; + nextBtn.click(); + const centerAfter = root.querySelector('.me-arc-card--center .me-arc-quote').textContent; + expect(centerAfter).to.not.equal(centerBefore); + expect(editor.getContentModel()).to.include({ + quote: 'Quote number 1', + author: '', + backgroundUrl: '/img/image1.jpg', + }); + }); + + it('navigating prev then next returns to the original centre entry', async () => { + const { root } = await mount(); + const original = root.querySelector('.me-arc-card--center .me-arc-quote').textContent; + root.querySelector('.me-arc-nav--next').click(); + root.querySelector('.me-arc-nav--prev').click(); + expect(root.querySelector('.me-arc-card--center .me-arc-quote').textContent).to.equal(original); + }); + + it('clicking the prev-role card is equivalent to clicking the prev nav button', async () => { + const { root } = await mount(); + const before = root.querySelector('.me-arc-card--center .me-arc-quote').textContent; + root.querySelector('.me-arc-nav--next').click(); + const afterNext = root.querySelector('.me-arc-card--center .me-arc-quote').textContent; + expect(afterNext).to.not.equal(before); + // :not(.me-arc-ghost) — the outgoing ghost card is also briefly staged + // to the --prev class while it plays its exit (see buildArcGhost), so + // a plain .me-arc-card--prev query can match it instead of the real, + // clickable prev card. + root.querySelector('.me-arc-card--prev:not(.me-arc-ghost)').click(); + expect(root.querySelector('.me-arc-card--center .me-arc-quote').textContent).to.equal(before); + }); + + it('picking a font while the carousel is active re-renders all three visible cards', async () => { + const { root } = await mount(); + const serifBtn = Array.from(root.querySelectorAll('.me-row--fonts .me-font')) + .find((b) => b.textContent === 'Serif'); + serifBtn.click(); + const centerQuote = root.querySelector('.me-arc-card--center .me-arc-quote'); + expect(centerQuote.style.fontStyle).to.equal('italic'); + }); + }); + + describe('topActions bar (edit/share/download)', () => { + it('renders one button per supplied action, top-right of the widget, in order', async () => { + const { root } = await mount({ + topActions: [ + { type: 'edit', onClick: () => {} }, + { type: 'share', onClick: () => {} }, + { type: 'download', onClick: () => {} }, + ], + }); + const bar = root.querySelector('.mini-editor-widget > .me-actions'); + expect(bar).to.exist; + const buttons = bar.querySelectorAll('.me-action'); + expect(buttons.length).to.equal(3); + expect([...buttons].map((b) => b.className)).to.deep.equal([ + 'me-action me-action--edit', + 'me-action me-action--share', + 'me-action me-action--download', + ]); + }); + + it('renders only the types supplied', async () => { + const { root } = await mount({ topActions: [{ type: 'share', onClick: () => {} }] }); + const bar = root.querySelector('.me-actions'); + expect(bar.querySelectorAll('.me-action').length).to.equal(1); + expect(bar.querySelector('.me-action--share')).to.exist; + expect(bar.querySelector('.me-action--edit')).to.not.exist; + }); + + it('invokes the matching onClick when each action button is clicked', async () => { + const onEdit = sinon.spy(); + const onShare = sinon.spy(); + const onDownload = sinon.spy(); + const { root } = await mount({ + topActions: [ + { type: 'edit', onClick: onEdit }, + { type: 'share', onClick: onShare }, + { type: 'download', onClick: onDownload }, + ], + }); + + root.querySelector('.me-action--edit').click(); + root.querySelector('.me-action--share').click(); + root.querySelector('.me-action--download').click(); + + expect(onEdit.calledOnce).to.be.true; + expect(onShare.calledOnce).to.be.true; + expect(onDownload.calledOnce).to.be.true; + }); + + it('renders an empty bar and does not throw when topActions is omitted', async () => { + const { root } = await mount(); + const bar = root.querySelector('.me-actions'); + expect(bar).to.exist; + expect(bar.querySelectorAll('.me-action').length).to.equal(0); + }); + }); + + describe('syncViewportMode', () => { + it('adds me-carousel-mode when the viewport is at or below the tablet breakpoint', async () => { + const { root, editor } = await mount(); + const original = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { value: 800, configurable: true }); + editor.syncViewportMode(); + expect(root.classList.contains('me-carousel-mode')).to.be.true; + Object.defineProperty(window, 'innerWidth', { value: original, configurable: true }); + editor.syncViewportMode(); + }); + + it('removes me-carousel-mode above the tablet breakpoint on a non-touch device', async () => { + const { root, editor } = await mount(); + const original = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true }); + editor.syncViewportMode(); + expect(root.classList.contains('me-carousel-mode')).to.be.false; + Object.defineProperty(window, 'innerWidth', { value: original, configurable: true }); + editor.syncViewportMode(); + }); + }); +});