From d26680ce02b7b682534e4b807a88e448140203fa Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Wed, 29 Jul 2026 16:51:37 +0530 Subject: [PATCH 01/21] Add quote maker --- .../code/blocks/quote-maker/quote-maker.css | 797 +++++++++++++++++ .../code/blocks/quote-maker/quote-maker.js | 835 ++++++++++++++++++ 2 files changed, 1632 insertions(+) create mode 100644 express/code/blocks/quote-maker/quote-maker.css create mode 100644 express/code/blocks/quote-maker/quote-maker.js diff --git a/express/code/blocks/quote-maker/quote-maker.css b/express/code/blocks/quote-maker/quote-maker.css new file mode 100644 index 000000000..9ec16a02d --- /dev/null +++ b/express/code/blocks/quote-maker/quote-maker.css @@ -0,0 +1,797 @@ +.quote-maker { + --qm-card-bg: none; + --qm-quote-font: 'Cal Sans', var(--body-font-family); + 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); +} + +.quote-maker-header { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-200); + max-width: 668px; + text-align: center; + position: relative; + z-index: 1; +} + +.quote-maker-header p { + margin: 0 !important; +} + +.quote-maker-logo { + display: flex; + align-items: center; + justify-content: center; +} + +.quote-maker-logo img { + height: 20px; + width: auto; +} + +.quote-maker-header h1, +.quote-maker-header h2 { + margin: 0; +} + +.quote-maker-header p { + margin: 0; + color: var(--color-typography-body, #505050); +} + +.quote-maker-stage { + position: relative; + display: flex; + justify-content: center; + width: 100%; +} + +.quote-maker-widget { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 542px; +} + +.qm-card { + position: relative; + width: 100%; + height: 350px; + margin-bottom: var(--spacing-100); + box-sizing: border-box; + padding: var(--spacing-350); + border-radius: 16px; + background-image: var(--qm-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; +} + +.qm-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; +} + +.qm-quote-wrap:hover, +.qm-quote-wrap:focus-visible { + background: rgb(255 255 255 / 20%); + border-color: rgb(255 255 255 / 30%); +} + +.qm-quote { + margin: 0; + font-family: var(--qm-quote-font); + font-size: 20px; + line-height: 1.1; + text-align: center; + word-break: break-word; + color: #131313; +} + +.quote-maker[data-qm-theme='dark'] .qm-quote, +.quote-maker[data-qm-theme='dark'] .qm-author { + color: #fff; +} + +.qm-author { + margin: var(--spacing-200) 0 0; + font-family: var(--qm-quote-font); + font-size: 16px; + text-align: center; + white-space: nowrap; + color: #505050; +} + +.qm-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; +} + +.qm-quote-wrap:hover .qm-tip, +.qm-quote-wrap:focus-visible .qm-tip { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.qm-tip-box { + display: block; + max-width: 160px; + padding: 4px 10px; + border-radius: 7px; + background: #292929; + color: #fff; + font-size: 12px; + line-height: 16px; + text-align: center; + white-space: nowrap; +} + +.qm-quote-wrap.is-copied .qm-tip-box::before { + content: 'Copied!'; +} + +.qm-quote-wrap.is-copied .qm-tip-box { + font-size: 0; +} + +.qm-quote-wrap.is-copied .qm-tip-box::before { + font-size: 12px; +} + +.qm-controls { + display: flex; + align-items: stretch; + gap: var(--spacing-100); + width: 100%; +} + +.qm-control { + flex: 1 1 0; + min-width: 0; + height: 58px; + display: flex; + align-items: center; + gap: var(--spacing-100); + box-sizing: border-box; + background: #f5f5f5; + border: 1px solid rgb(0 0 0 / 3%); + border-radius: 12px; + 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; +} + +.qm-control:hover, +.qm-control[aria-expanded='true'] { + background: #ebebeb; +} + +.qm-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 46px; + height: 46px; + padding: 0 var(--spacing-200); + background: #fff; + border-radius: 10px; + box-shadow: 0 1px 2px rgb(0 0 0 / 4%); + font-size: 16px; + white-space: nowrap; +} + +.qm-swatch { + align-self: center; + width: 46px; + height: 46px; + flex-shrink: 0; + border-radius: 10px; + border: 1px solid rgb(255 255 255 / 85%); + background-size: cover; + background-position: center; + box-shadow: 0 1px 4px rgb(0 0 0 / 4%); +} + +.qm-control-label { + font-weight: var(--heading-font-weight-medium, 700); + font-size: 14px; + color: #292929; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.qm-panel { + position: relative; + width: 100%; + height: 65px; + margin-top: var(--spacing-80); + overflow: hidden; +} + +.qm-row { + position: absolute; + top: 4px; + left: 0; + display: flex; + align-items: center; + width: 100%; + height: 58px; + box-sizing: border-box; + background: #f5f5f5; + border: 1px solid rgb(0 0 0 / 3%); + border-radius: 12px; + transform: translateY(96.5px); + transition: transform 0.3s cubic-bezier(0.65, 0, 0.35, 1); +} + +.quote-maker[data-qm-panel='fonts'] .qm-row--fonts, +.quote-maker[data-qm-panel='colour'] .qm-row--colour { + transform: translateY(0); +} + +.qm-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; +} + +.qm-row--fonts::-webkit-scrollbar { + display: none; +} + +.qm-font { + flex: 0 0 auto; + min-width: 46px; + height: 46px; + padding: 0 var(--spacing-200); + border: none; + background: transparent; + border-radius: 8px; + font-size: 16px; + white-space: nowrap; + cursor: pointer; +} + +.qm-font.is-selected { + background: #fff; + box-shadow: 0 1px 2px rgb(0 0 0 / 4%); +} + +.qm-row--colour { + justify-content: space-between; + gap: var(--spacing-100); + padding: 0 var(--spacing-250); + overflow-x: auto; + scrollbar-width: none; +} + +.qm-row--colour::-webkit-scrollbar { + display: none; +} + +.qm-swatch-btn { + position: relative; + flex: 0 0 auto; + width: 28px; + height: 28px; + padding: 0; + border-radius: 999px; + border: 1px solid rgb(255 255 255 / 10%); + background: none; + cursor: pointer; + box-shadow: 0 1px 4px rgb(0 0 0 / 4%); + transition: transform 0.12s ease; +} + +.qm-swatch-btn:hover { + transform: scale(1.08); +} + +.qm-swatch-btn.is-selected { + border: 2px solid #1473e6; +} + +.qm-swatch-fill { + position: absolute; + inset: 0; + border-radius: 999px; + background-size: cover; + background-position: center; +} + +.quote-maker-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 .quote-maker. */ + 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; +} + +.qm-deco { + position: absolute; + width: 180px; + pointer-events: auto; +} + +.qm-deco-card-wrap { + /* Positioned so .qm-deco-actions's `top: 100%` resolves against the card + itself (not the ancestor). The padding-bottom extends this wrap's own + hoverable box down through the gap and the actions row, 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. */ + position: relative; + padding-bottom: 54px; +} + +/* + * 4 slots per side, desktop only (see the >=1200px block below). Matches the + * Figma reference's two-column 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 + * holds two 180px-wide cards spaced ~230px apart vertically. Positioned + * from the bottom (not the top) since .quote-maker-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. The lowest card in + * each column sits with its own bottom flush at 0 so nothing crosses below + * the editor's bottom edge. + */ +.qm-deco--1 { left: calc(50% - 811px); bottom: 496px; transform: rotate(1.5deg); } +.qm-deco--2 { left: calc(50% - 541px); bottom: 260px; transform: rotate(-1.5deg); } +.qm-deco--3 { left: calc(50% - 811px); bottom: 260px; transform: rotate(2deg); } +.qm-deco--4 { left: calc(50% - 541px); bottom: 0; transform: rotate(-2deg); } +.qm-deco--5 { left: calc(50% + 631px); bottom: 496px; transform: rotate(-1.5deg); } +.qm-deco--6 { left: calc(50% + 361px); bottom: 260px; transform: rotate(1.5deg); } +.qm-deco--7 { left: calc(50% + 631px); bottom: 260px; transform: rotate(-2deg); } +.qm-deco--8 { left: calc(50% + 361px); bottom: 0; transform: rotate(2deg); } + +.qm-deco:hover, +.qm-deco:focus-within { + z-index: 5; +} + +.qm-deco-card { + position: relative; + width: 100%; + height: 148px; + box-sizing: border-box; + padding: 11px; + border: 0.5px solid rgb(255 255 255 / 66%); + border-radius: 20px; + 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%); + -webkit-user-select: none; + user-select: none; +} + +.qm-deco[data-theme='light'] .qm-deco-quote, +.qm-deco[data-theme='light'] .qm-deco-author { + color: #131313; +} + +.qm-deco[data-theme='dark'] .qm-deco-quote, +.qm-deco[data-theme='dark'] .qm-deco-author { + color: #fff; +} + +.qm-deco-quote { + margin: 0; + font-size: 12px; + line-height: 1.25; + text-align: center; + word-break: break-word; +} + +.qm-deco-author { + position: absolute; + left: 50%; + bottom: 12px; + transform: translateX(-50%); + margin: 0; + font-size: 8px; + white-space: nowrap; +} + +.qm-deco-actions { + /* Fixed px offset, not a percentage — .qm-deco-card-wrap's own + padding-bottom (the hover hit-box buffer) would otherwise be counted + twice if this used `top: 100%`, since that percentage resolves against + the wrap's full padding box. 148px card height + 11px*2 padding + (border-box, so included) + 8px gap. */ + position: absolute; + top: 170px; + 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; +} + +.qm-deco:hover .qm-deco-actions, +.qm-deco:focus-within .qm-deco-actions { + opacity: 1; + pointer-events: auto; +} + +.qm-deco-use, +.qm-deco-copy { + height: 32px; + box-sizing: border-box; + background: #e9e9e9; + border: none; + border-radius: 8px; + color: #292929; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.qm-deco-use { + display: inline-flex; + align-items: center; + padding: 7px 12px; + font-size: 14px; + white-space: nowrap; +} + +.qm-deco-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + padding: 7px; +} + +.qm-deco-copy img { + display: block; + width: 18px; + height: 18px; +} + +.qm-deco-use:hover, +.qm-deco-copy:hover { + background: #dcdcdc; +} + +.qm-deco-copy.is-copied { + background: #bfe9cb; +} + +@media (width >= 1200px) { + .quote-maker-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 + * quote-maker.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 side slots are translated by their own full width + * (zero overlap with the centre card, however they're rotated) and the + * container is only wide enough to reveal --qm-arc-side-peek of each — + * smaller on mobile than tablet — clipping the rest. + */ +.qm-arc { + --qm-arc-card-w: 542px; + --qm-arc-card-h: 350px; + --qm-arc-side-peek: 64px; + position: relative; + display: none; + align-items: center; + justify-content: center; + width: calc(var(--qm-arc-card-w) + (2 * var(--qm-arc-side-peek))); + max-width: 100vw; + height: var(--qm-arc-card-h); + margin: 0 auto var(--spacing-100); + /* Full-bleed within the viewport: the widget itself is only + --qm-arc-card-w wide, so this wider, centred clip box needs to escape + the widget's own narrower box to have room to reveal the side peeks. */ + overflow: hidden; +} + +.qm-carousel-mode .quote-maker-decorations { + display: none; +} + +.qm-carousel-mode .qm-card { + display: none; +} + +.qm-carousel-mode .qm-arc { + display: flex; +} + +.qm-arc-card { + position: absolute; + top: 0; + left: calc(50% - (var(--qm-arc-card-w) / 2)); + width: var(--qm-arc-card-w); + height: var(--qm-arc-card-h); + box-sizing: border-box; + padding: var(--spacing-350); + border: 0.5px solid rgb(255 255 255 / 66%); + border-radius: 16px; + 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%); + transition: transform 1s ease-in-out, opacity 1s ease-in-out; + -webkit-user-select: none; + user-select: none; +} + +.qm-arc-card--center { + z-index: 3; + cursor: default; + transform-origin: 50% 50%; +} + +.qm-arc-card--prev, +.qm-arc-card--next { + z-index: 1; + cursor: pointer; +} + +/* Side cards are translated by their own full width, so their near edge + lands exactly at the centre card's edge — zero overlap no matter how + they're rotated, since the pivot corner (nearest the centre) doesn't + move under rotation and the rest of the box only swings further away. + Left card pivots from its bottom-right corner, right card from its + bottom-left corner, so each tilts outward and away from centre. The + container's own width (not this offset) controls how much of each + stays visible — see --qm-arc-side-peek above. */ +.qm-arc-card--prev { + transform-origin: 100% 100%; + transform: translateX(calc(-1 * var(--qm-arc-card-w))) rotate(-8deg); +} + +.qm-arc-card--next { + transform-origin: 0 100%; + transform: translateX(var(--qm-arc-card-w)) rotate(8deg); +} + +.qm-arc-card[data-theme='light'] .qm-arc-quote, +.qm-arc-card[data-theme='light'] .qm-arc-author { + color: #131313; +} + +.qm-arc-card[data-theme='dark'] .qm-arc-quote, +.qm-arc-card[data-theme='dark'] .qm-arc-author { + color: #fff; +} + +.qm-arc-quote { + margin: 0; + font-size: 16px; + line-height: 1.2; + text-align: center; + word-break: break-word; +} + +.qm-arc-author { + position: absolute; + left: 50%; + bottom: var(--spacing-200); + transform: translateX(-50%); + margin: 0; + font-size: 12px; + white-space: nowrap; +} + +.qm-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: 999px; + cursor: pointer; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); +} + +.qm-arc-nav img { + width: 20px; + height: 20px; + filter: brightness(0) invert(1); +} + +.qm-arc-nav--prev { + left: calc(50% - (var(--qm-arc-card-w) / 2)); +} + +.qm-arc-nav--next { + right: calc(50% - (var(--qm-arc-card-w) / 2)); +} + +/* + * 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-qm-panel attribute as the inline row — see .qm-row + * above for the tablet/desktop behaviour, hidden below at this breakpoint. + */ +.qm-sheet-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: none; + background: rgb(0 0 0 / 44%); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + opacity: 0; + transition: opacity 0.2s ease; +} + +.qm-sheet-overlay.is-open { + opacity: 1; +} + +.qm-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: #fff; + 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); +} + +.qm-sheet-overlay.is-open .qm-sheet { + transform: translateY(0); +} + +.qm-sheet-handle { + width: 50px; + height: 4px; + margin: var(--spacing-100) auto 0; + background: rgb(0 0 0 / 15%); + border-radius: 999px; + flex-shrink: 0; +} + +.qm-sheet-title { + margin: 0; + font-weight: var(--heading-font-weight-medium, 700); + font-size: 16px; + text-align: center; +} + +.qm-sheet-grid { + display: grid; + gap: var(--spacing-200); +} + +.qm-sheet-grid--fonts { + grid-template-columns: repeat(2, 1fr); +} + +.qm-sheet-grid--fonts .qm-font { + width: 100%; + height: 52px; +} + +.qm-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; +} + +.qm-sheet-grid--colour::-webkit-scrollbar { + display: none; +} + +.qm-sheet-grid--colour .qm-swatch-btn { + width: 32px; + height: 32px; + align-self: center; + justify-self: center; +} + +@media (width <= 767px) { + .quote-maker { + padding: var(--spacing-500) var(--spacing-300); + } + + .qm-quote { + font-size: 16px; + } + + .qm-arc { + /* Smaller card, and a much thinner side-card sliver than tablet — per + Figma mobile frame 0-18684, only a small edge of the neighbouring + templates should be visible. */ + --qm-arc-card-w: 327px; + --qm-arc-card-h: 270px; + --qm-arc-side-peek: 24px; + } + + .qm-arc-quote { + font-size: 14px; + } + + .qm-arc-nav { + width: 40px; + height: 40px; + } + + .qm-panel { + display: none; + } + + .qm-sheet-overlay { + display: block; + } +} diff --git a/express/code/blocks/quote-maker/quote-maker.js b/express/code/blocks/quote-maker/quote-maker.js new file mode 100644 index 000000000..3f357ea15 --- /dev/null +++ b/express/code/blocks/quote-maker/quote-maker.js @@ -0,0 +1,835 @@ +import { getLibs, getIconElementDeprecated } from '../../scripts/utils.js'; +import { + fetchResults, + isValidTemplate, + getImageThumbnailSrc, +} from '../../scripts/template-utils.js'; +import { + trapFocus, + handleEscapeClose, + disableBackgroundScroll, + restoreBackgroundScroll, +} from '../../scripts/color-shared/spectrum/utils/a11y.js'; + +let createTag; + +const TEMPLATE_LIMIT = 8; +const DECO_CARD_COUNT = 8; +const TABLET_BREAKPOINT = 1199; + +const 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); + }); +} + +async function copyToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +/** + * 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); + } + + const rawRows = main.querySelectorAll('.collapsible-rows > 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. + if (!props.contentRow && cols[0]?.textContent.trim()) { + props.contentRow = cols[0]; + } + }); + + return props; +} + +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(); +} + +async function fetchCardBackgrounds(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, index) => { + 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, + theme: index % 2 === 0 ? 'light' : 'dark', + }; + }) + .filter((card) => !!card.bg); +} + +/** + * Pairs each fetched background card with a quote (and a font, cycling + * through FONT_OPTIONS) so every card/quote/font 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. + */ +function buildCardSet(cards, quotes) { + return cards.map((card, i) => ({ + card, + quote: quotes[i % quotes.length].quote, + author: quotes[i % quotes.length].author, + font: FONT_OPTIONS[i % FONT_OPTIONS.length], + })); +} + +/** + * 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: 'quote-maker-logo', 'aria-hidden': 'true' }, [ + getIconElementDeprecated('adobe-express-logo'), + ]); +} + +function buildContentHeader(props) { + const header = createTag('div', { class: 'quote-maker-header' }); + header.append(buildLogo()); + if (props.contentRow) { + header.append(...props.contentRow.childNodes); + } + return header; +} + +/** + * Builds one font-option button. Used to populate both the tablet/desktop + * inline row and the mobile bottom-sheet grid from the same FONT_OPTIONS + * 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: `qm-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(block, onSelect) { + const control = createTag('button', { + type: 'button', + class: 'qm-control qm-control--font', + 'aria-expanded': 'false', + }); + const pill = createTag('span', { class: 'qm-pill' }); + pill.textContent = FONT_OPTIONS[0].label; + const label = createTag('span', { class: 'qm-control-label' }); + label.textContent = 'Font style'; + control.append(pill, label); + + const panel = createTag('div', { + class: 'qm-row qm-row--fonts', + role: 'listbox', + 'aria-label': 'Font style', + }); + const sheetGrid = createTag('div', { + class: 'qm-sheet-grid qm-sheet-grid--fonts', + role: 'listbox', + 'aria-label': 'Font style', + }); + + function selectFont(opt) { + block.style.setProperty('--qm-quote-font', opt.font); + pill.textContent = opt.label; + pill.style.fontFamily = opt.font; + [panel, sheetGrid].forEach((container) => { + container.querySelectorAll('.qm-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); + }; + FONT_OPTIONS.forEach((opt, index) => { + panel.append(buildFontButton(opt, index, onPick)); + sheetGrid.append(buildFontButton(opt, index, onPick)); + }); + + control.addEventListener('click', () => { + const isOpen = block.getAttribute('data-qm-panel') === 'fonts'; + block.setAttribute('data-qm-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: `qm-swatch-btn${index === 0 ? ' is-selected' : ''}`, + 'data-bg': card.bg, + 'data-theme': card.theme, + role: 'option', + 'aria-selected': index === 0 ? 'true' : 'false', + 'aria-label': `Background ${index + 1}`, + }); + const fill = createTag('span', { + class: 'qm-swatch-fill', + style: `background-image:url("${card.bg}")`, + }); + btn.append(fill); + btn.addEventListener('click', () => onPick(card)); + return btn; +} + +function buildColorControl(block, cards, onSelect) { + const control = createTag('button', { + type: 'button', + class: 'qm-control qm-control--colour', + 'aria-expanded': 'false', + }); + const swatch = createTag('span', { class: 'qm-swatch' }); + const label = createTag('span', { class: 'qm-control-label' }); + label.textContent = 'Background colour'; + control.append(swatch, label); + + const panel = createTag('div', { + class: 'qm-row qm-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: 'qm-sheet-grid qm-sheet-grid--colour', + role: 'listbox', + 'aria-label': 'Background colour', + }); + + function selectSwatch(bg, theme) { + block.style.setProperty('--qm-card-bg', `url("${bg}")`); + block.setAttribute('data-qm-theme', theme); + swatch.style.backgroundImage = `url("${bg}")`; + [panel, sheetGrid].forEach((container) => { + container.querySelectorAll('.qm-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, card.theme); + 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 = block.getAttribute('data-qm-panel') === 'colour'; + block.setAttribute('data-qm-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-qm-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(block, kind, title, contentEl) { + const overlay = createTag('div', { class: 'qm-sheet-overlay', 'aria-hidden': 'true', inert: '' }); + const sheet = createTag('div', { + class: 'qm-sheet', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': title, + tabindex: '-1', + }); + const handle = createTag('div', { class: 'qm-sheet-handle', 'aria-hidden': 'true' }); + const titleEl = createTag('p', { class: 'qm-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 (block.getAttribute('data-qm-panel') !== kind) return; + block.setAttribute('data-qm-panel', 'none'); + } + + function onPanelChange() { + const isOpen = block.getAttribute('data-qm-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 }; +} + +function buildWidget(block, cardSet) { + const widget = createTag('div', { class: 'quote-maker-widget' }); + const card = createTag('div', { class: 'qm-card' }); + + const quoteWrap = createTag('div', { + class: 'qm-quote-wrap', + role: 'button', + tabindex: '0', + 'aria-label': 'Copy quote to clipboard', + }); + const quoteEl = createTag('p', { class: 'qm-quote' }); + const first = cardSet[0] || { quote: '', author: '' }; + quoteEl.textContent = first.quote; + + const tip = createTag('span', { class: 'qm-tip', 'aria-hidden': 'true' }, [ + createTag('span', { class: 'qm-tip-box' }, ['Click to copy quote']), + ]); + quoteWrap.append(quoteEl, tip); + + const authorEl = createTag('p', { class: 'qm-author' }); + authorEl.textContent = first.author; + authorEl.style.display = first.author ? '' : 'none'; + + card.append(quoteWrap, authorEl); + widget.append(card); + + const doCopy = async () => { + const ok = await copyToClipboard(quoteEl.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) { + block.style.setProperty('--qm-card-bg', `url("${first.card.bg}")`); + block.setAttribute('data-qm-theme', first.card.theme); + } + + // Set by init() 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 .qm-card (which the CSS vars above + // already cover regardless of listener wiring). + let onFontOrColourPick = () => {}; + + const controls = createTag('div', { class: 'qm-controls' }); + const { + control: fontControl, + panel: fontPanel, + sheetGrid: fontSheetGrid, + selectFont, + } = buildFontControl(block, (font) => onFontOrColourPick({ font })); + const { + control: colourControl, + panel: colourPanel, + sheetGrid: colourSheetGrid, + selectSwatch, + } = buildColorControl( + block, + cardSet.map((c) => c.card), + (bgCard) => onFontOrColourPick({ card: bgCard }), + ); + controls.append(fontControl, colourControl); + + const panelWrap = createTag('div', { class: 'qm-panel' }); + panelWrap.append(fontPanel, colourPanel); + + const fontSheet = buildBottomSheet(block, 'fonts', 'Choose a font style', fontSheetGrid); + const colourSheet = buildBottomSheet(block, 'colour', 'Choose a background colour', colourSheetGrid); + + widget.append(controls, panelWrap, fontSheet.overlay, colourSheet.overlay); + + // Single MutationObserver on data-qm-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-qm-panel='fonts']`). + const panelObserver = new MutationObserver(() => { + const openPanel = block.getAttribute('data-qm-panel'); + fontControl.setAttribute('aria-expanded', String(openPanel === 'fonts')); + colourControl.setAttribute('aria-expanded', String(openPanel === 'colour')); + fontSheet.onPanelChange(); + colourSheet.onPanelChange(); + }); + panelObserver.observe(block, { attributes: true, attributeFilter: ['data-qm-panel'] }); + + document.addEventListener('click', (e) => { + if (!widget.contains(e.target)) { + block.setAttribute('data-qm-panel', 'none'); + } + }); + + return { + widget, + useQuote: ({ + quote, author, card: bgCard, font, + }) => { + quoteEl.textContent = quote; + authorEl.textContent = author || ''; + authorEl.style.display = author ? '' : 'none'; + if (bgCard) selectSwatch(bgCard.bg, bgCard.theme); + if (font) selectFont(font); + }, + onFontOrColourChange: (listener) => { onFontOrColourPick = listener; }, + }; +} + +function buildDecoCard(entry, useQuote) { + const { + card, quote, author, font, + } = entry; + const deco = createTag('div', { class: 'qm-deco', 'data-theme': card.theme, tabindex: '-1' }); + const cardWrap = createTag('div', { class: 'qm-deco-card-wrap' }); + const inner = createTag('div', { + class: 'qm-deco-card', + style: `background-image:url("${card.bg}")`, + }); + const quoteP = createTag('p', { + class: 'qm-deco-quote', + style: `font-family:${font.font};${font.italic ? 'font-style:italic;' : ''}`, + }); + quoteP.textContent = quote; + inner.append(quoteP); + if (author) { + const authorP = createTag('p', { class: 'qm-deco-author' }); + authorP.textContent = author; + inner.append(authorP); + } + + const actions = createTag('div', { class: 'qm-deco-actions' }); + const useBtn = createTag('button', { type: 'button', class: 'qm-deco-use' }); + useBtn.textContent = 'Use this quote'; + useBtn.addEventListener('click', () => useQuote(entry)); + + const copyBtn = createTag('button', { + type: 'button', + class: 'qm-deco-copy', + 'aria-label': 'Copy quote', + }, [getIconElementDeprecated('copy')]); + copyBtn.addEventListener('click', async () => { + const ok = await copyToClipboard(quote); + if (ok) { + copyBtn.classList.add('is-copied'); + setTimeout(() => copyBtn.classList.remove('is-copied'), 1200); + } + }); + + actions.append(useBtn, copyBtn); + cardWrap.append(inner, actions); + deco.append(cardWrap); + return deco; +} + +function buildDecoCards(cardSet, useQuote) { + const wrap = createTag('div', { class: 'quote-maker-decorations', 'aria-hidden': 'true' }); + // cardSet[0] powers the main widget; decorative cards use the rest. + const decoEntries = cardSet.slice(1, 1 + DECO_CARD_COUNT); + decoEntries.forEach((entry, i) => { + const deco = buildDecoCard(entry, useQuote); + deco.classList.add(`qm-deco--${i + 1}`); + wrap.append(deco); + }); + 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 .qm-arc-card actually animate + * a visible slide/rotate between roles instead of an instant content pop. + */ +function buildArcCard(onActivate) { + const el = createTag('div', { + class: 'qm-arc-card', + role: 'option', + 'aria-selected': 'false', + tabindex: '-1', + }); + const quoteP = createTag('p', { class: 'qm-arc-quote' }); + const authorP = createTag('p', { class: 'qm-arc-author' }); + el.append(quoteP, authorP); + el.addEventListener('click', () => onActivate(el)); + + function render(entry) { + el.dataset.theme = entry.card.theme; + el.style.backgroundImage = `url("${entry.card.bg}")`; + quoteP.textContent = entry.quote; + quoteP.style.fontFamily = entry.font.font; + quoteP.style.fontStyle = entry.font.italic ? 'italic' : ''; + authorP.textContent = entry.author || ''; + authorP.style.display = entry.author ? '' : 'none'; + } + + function setRole(role, { instant = false } = {}) { + // The recycled card (the one that just left the deck at one edge to + // re-enter, with new content, at the other edge) needs to land in its + // new role's position without visibly sliding there — so its role + // change is applied with the transition switched off for one frame, + // then restored, rather than animating over the 1s duration like the + // other two cards' prev<->centre<->next moves. + if (instant) el.style.transition = 'none'; + el.classList.remove('qm-arc-card--prev', 'qm-arc-card--center', 'qm-arc-card--next'); + el.classList.add(`qm-arc-card--${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'; + if (instant) { + el.getBoundingClientRect(); // force reflow so the instant jump commits + el.style.transition = ''; + } + } + + return { + el, render, setRole, + }; +} + +/** + * 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 + * .qm-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) { + const root = createTag('div', { class: 'qm-arc' }); + const total = cardSet.length; + let activeIndex = 0; + // The centre card can be patched independently of cardSet (e.g. the + // widget's own font/colour controls, which apply on top of whichever + // entry is currently active) — centreOverride holds that patch and is + // reset whenever navigation moves a *different* entry into the centre. + let centreOverride = null; + + const onActivate = (el) => { + if (el.classList.contains('qm-arc-card--prev')) goPrev(); // eslint-disable-line no-use-before-define + else if (el.classList.contains('qm-arc-card--next')) goNext(); // eslint-disable-line no-use-before-define + }; + const cardA = buildArcCard(onActivate); + const cardB = buildArcCard(onActivate); + const cardC = buildArcCard(onActivate); + // 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 { ...cardSet[activeIndex], ...centreOverride }; + } + + function renderAll() { + const prevIndex = ((activeIndex - 1) % total + total) % total; + const nextIndex = (activeIndex + 1) % total; + cards[roles.indexOf('prev')].render(cardSet[prevIndex]); + cards[roles.indexOf('center')].render(centerEntry()); + cards[roles.indexOf('next')].render(cardSet[nextIndex]); + } + + function goNext() { + 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: it jumps instantly (no transition) to the next position + // with new content, rather than visibly sliding across the centre. + const recycled = cards[roles.indexOf('prev')]; + cards[roles.indexOf('center')].setRole('prev'); + cards[roles.indexOf('next')].setRole('center'); + recycled.setRole('next', { instant: true }); + roles = roles.map((role) => ({ center: 'prev', next: 'center', prev: 'next' }[role])); + const newNextIndex = (activeIndex + 1) % total; + recycled.render(cardSet[newNextIndex]); + cards[roles.indexOf('center')].render(centerEntry()); + useQuote(cardSet[activeIndex]); + } + + function goPrev() { + 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 — instantly, new content — + // to become the new prev. + const recycled = cards[roles.indexOf('next')]; + cards[roles.indexOf('center')].setRole('next'); + cards[roles.indexOf('prev')].setRole('center'); + recycled.setRole('prev', { instant: true }); + roles = roles.map((role) => ({ center: 'next', prev: 'center', next: 'prev' }[role])); + const newPrevIndex = ((activeIndex - 1) % total + total) % total; + recycled.render(cardSet[newPrevIndex]); + 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's centre card + // exactly as it already updates the desktop widget's own .qm-card. + function updateCentre(patch) { + centreOverride = { ...centreOverride, ...patch }; + cards[roles.indexOf('center')].render(centerEntry()); + } + + applyRoles(); + renderAll(); + root.append(cardA.el, cardB.el, cardC.el); + + const prevBtn = createTag('button', { + type: 'button', + class: 'qm-arc-nav qm-arc-nav--prev', + 'aria-label': 'Previous template', + }, [getIconElementDeprecated('s2-chevron-left')]); + const nextBtn = createTag('button', { + type: 'button', + class: 'qm-arc-nav qm-arc-nav--next', + 'aria-label': 'Next template', + }, [getIconElementDeprecated('s2-chevron-right')]); + root.append(prevBtn, nextBtn); + + prevBtn.addEventListener('click', goPrev); + nextBtn.addEventListener('click', goNext); + + return { root, updateCentre }; +} + +export default async function init(block) { + ({ createTag } = await import(`${getLibs()}/utils/utils.js`)); + + const props = constructProps(block); + block.innerHTML = ''; + block.setAttribute('data-qm-panel', 'none'); + + const header = buildContentHeader(props); + block.append(header); + decorateCta(header); + + const quotes = getPageQuotes(); + + loadWebFonts(); + + try { + const cards = await fetchCardBackgrounds(props); + if (!cards.length || !quotes.length) { + block.closest('.section')?.remove(); + return; + } + const cardSet = buildCardSet(cards, quotes); + // Same 9 entries (the widget's own + the 8 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 + DECO_CARD_COUNT)]; + + const stage = createTag('div', { class: 'quote-maker-stage' }); + const { widget, useQuote, onFontOrColourChange } = buildWidget(block, cardSet); + const decorations = buildDecoCards(cardSet, useQuote); + const { root: arcCarousel, updateCentre } = buildArcCarousel(arcCardSet, useQuote); + onFontOrColourChange(updateCentre); + // 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(decorations); + // The arc carousel is inserted inside the widget, in the same flow slot + // as .qm-card (which .qm-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 .qm-card's place. + widget.querySelector('.qm-card').after(arcCarousel); + stage.append(widget); + block.append(stage); + + const isSmallViewport = () => window.innerWidth <= TABLET_BREAKPOINT; + const syncViewportMode = () => { + block.classList.toggle('qm-carousel-mode', isSmallViewport()); + }; + syncViewportMode(); + window.addEventListener('resize', syncViewportMode); + } catch (error) { + window.lana?.log(`Error in quote-maker: ${error?.message || error}`, { + tags: 'quote-maker', + severity: 'error', + }); + block.closest('.section')?.remove(); + } +} From 13de3365f1a57c9f9b9fbfc7c06cdeedfc792c4f Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Mon, 10 Aug 2026 17:48:37 +0530 Subject: [PATCH 02/21] Fix UI and Fonts --- .../collapsible-rows/collapsible-rows.css | 55 +++ .../collapsible-rows/collapsible-rows.js | 74 +++- .../code/blocks/quote-maker/quote-maker.css | 392 +++++++++++++----- .../code/blocks/quote-maker/quote-maker.js | 339 +++++++++++---- express/code/icons/arc-nav-left.svg | 3 + express/code/icons/arc-nav-right.svg | 3 + express/code/icons/copy-quote.svg | 11 + express/code/icons/create-design.svg | 4 + express/code/scripts/utils/copy-toast.css | 49 +++ express/code/scripts/utils/copy-toast.js | 41 ++ 10 files changed, 790 insertions(+), 181 deletions(-) create mode 100644 express/code/icons/arc-nav-left.svg create mode 100644 express/code/icons/arc-nav-right.svg create mode 100644 express/code/icons/copy-quote.svg create mode 100644 express/code/icons/create-design.svg create mode 100644 express/code/scripts/utils/copy-toast.css create mode 100644 express/code/scripts/utils/copy-toast.js diff --git a/express/code/blocks/collapsible-rows/collapsible-rows.css b/express/code/blocks/collapsible-rows/collapsible-rows.css index 3eb182c36..fa73b1a1d 100644 --- a/express/code/blocks/collapsible-rows/collapsible-rows.css +++ b/express/code/blocks/collapsible-rows/collapsible-rows.css @@ -71,6 +71,61 @@ 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); +} + .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..50049ff19 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 quote-maker block on the same page listens + * for (see quote-maker.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 = 'quote-maker: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 quote-maker's own copy + * actions, so every copy affordance on the page behaves identically. + * "Create a design" only makes sense when a quote-maker 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, hasQuoteMaker) { + if (!hasQuoteMaker) 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 = {}, hasQuoteMaker = false) { const parentDiv = block.closest('.section'); parentDiv?.classList.add('collapsible-rows-grey-bg', 'collapsible-section-padding'); @@ -109,6 +158,8 @@ function buildTableLayout(block, typographyClasses = {}) { subHeaderEl.classList.add(...typographyClasses.body); } subHeaderAccordion.append(subHeaderEl); + const quoteActions = buildQuoteActions(headerEl.textContent.trim(), subHeaderEl.textContent.trim(), hasQuoteMaker); + if (quoteActions) subHeaderAccordion.append(quoteActions); headerEl.addEventListener('click', () => { headerAccordion.classList.toggle('rounded-corners'); @@ -121,7 +172,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', + hasQuoteMaker = false, +) { const collapsibleRows = []; const rows = Array.from(block.children); @@ -169,6 +226,8 @@ function buildOriginalLayout(block, typographyClasses = {}, viewMoreText = 'View subHeaderEl.classList.add(...typographyClasses.body); } accordion.append(subHeaderEl); + const quoteActions = buildQuoteActions(headerEl.textContent.trim(), subHeaderEl.textContent.trim(), hasQuoteMaker); + if (quoteActions) accordion.append(quoteActions); }); const toggleButton = createTag('a', { class: 'collapsible-row-toggle-btn button' }); @@ -242,14 +301,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 quote-maker 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 hasQuoteMaker = !!document.querySelector('.quote-maker'); if (isExpandableVariant) { - buildTableLayout(block, typographyClasses); + buildTableLayout(block, typographyClasses, hasQuoteMaker); } 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', hasQuoteMaker); } } diff --git a/express/code/blocks/quote-maker/quote-maker.css b/express/code/blocks/quote-maker/quote-maker.css index 9ec16a02d..a1ecc211d 100644 --- a/express/code/blocks/quote-maker/quote-maker.css +++ b/express/code/blocks/quote-maker/quote-maker.css @@ -1,6 +1,41 @@ .quote-maker { --qm-card-bg: none; - --qm-quote-font: 'Cal Sans', var(--body-font-family); + --qm-quote-font: var(--body-font-family); + --qm-quote-font-style: normal; + --qm-quote-font-weight: normal; + /* Arc carousel sizing (tablet/mobile) — defined here, not on .qm-arc + itself, so .quote-maker-widget (a sibling of .qm-arc, not a + descendant) can also read --qm-arc-card-w to keep its own max-width + in lockstep with the carousel's card size at every breakpoint. See + .qm-carousel-mode .quote-maker-widget below and the mobile media + query for the per-breakpoint overrides. */ + --qm-arc-card-w: 542px; + --qm-arc-card-h: 350px; + /* Extra height below the centre card's own box: rotation pivots from + each side card's own centre (transform-origin never changes between + roles, so the transition can't swing through the centre card at any + point along the path), then a translateY compensation (--qm-arc-lift) + pulls the whole card down so its bottom-inner corner lines up with + the centre card's bottom edge instead of sitting ~39px above it. That + same downward shift pushes the outer-bottom corner ~76px below the + unrotated baseline, so the container needs that much extra room + beneath the card to avoid clipping it. */ + --qm-arc-extra-h: 80px; + --qm-arc-lift: 39px; + /* translateX distance from centre for the side cards' own centrepoint — + must clear (cardW/2 * cos(8deg) + cardH/2 * sin(8deg)) + centreCardW/2 + at every point along the rotation, not just at rest. Verified clear + in practice (browser-scrubbed at 100/300/500ms) even though a + linear-time worst case exists in the first ~2% of progress — + ease-in-out compresses that window to well under a rendered frame. + 542/2*cos(8)+350/2*sin(8)+542/2 ≈ 564px; +10px margin. */ + --qm-arc-slide: 574px; + /* 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. */ + --qm-arc-gap: var(--spacing-300); display: flex; flex-direction: column; align-items: center; @@ -14,7 +49,7 @@ display: flex; flex-direction: column; align-items: center; - gap: var(--spacing-200); + gap: var(--spacing-300); max-width: 668px; text-align: center; position: relative; @@ -39,13 +74,35 @@ .quote-maker-header h1, .quote-maker-header h2 { margin: 0; + font-size: var(--heading-font-size-l, 2.25rem); + font-weight: var(--heading-font-weight-extra, 900); + line-height: 32px; + letter-spacing: -1px; + color: #222; } .quote-maker-header p { margin: 0; + max-width: 437px; + font-size: var(--body-font-size-m, 1rem); color: var(--color-typography-body, #505050); } +/* 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. */ +.quote-maker-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%; +} + .quote-maker-stage { position: relative; display: flex; @@ -67,7 +124,7 @@ position: relative; width: 100%; height: 350px; - margin-bottom: var(--spacing-100); + margin-bottom: var(--spacing-300); box-sizing: border-box; padding: var(--spacing-350); border-radius: 16px; @@ -103,21 +160,29 @@ .qm-quote { margin: 0; font-family: var(--qm-quote-font); + font-style: var(--qm-quote-font-style); + font-weight: var(--qm-quote-font-weight); font-size: 20px; - line-height: 1.1; + line-height: 26px; text-align: center; word-break: break-word; color: #131313; } -.quote-maker[data-qm-theme='dark'] .qm-quote, -.quote-maker[data-qm-theme='dark'] .qm-author { - color: #fff; -} - .qm-author { - margin: var(--spacing-200) 0 0; + /* Absolutely positioned (not in-flow with margin-top) so it doesn't share + .qm-card's flex centering group with .qm-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 .qm-arc-author, + which already gets this right. */ + position: absolute; + left: 50%; + bottom: var(--spacing-200); + transform: translateX(-50%); + margin: 0; font-family: var(--qm-quote-font); + font-style: var(--qm-quote-font-style); + font-weight: var(--qm-quote-font-weight); font-size: 16px; text-align: center; white-space: nowrap; @@ -169,7 +234,7 @@ .qm-controls { display: flex; align-items: stretch; - gap: var(--spacing-100); + gap: var(--spacing-80); width: 100%; } @@ -181,9 +246,9 @@ align-items: center; gap: var(--spacing-100); box-sizing: border-box; - background: #f5f5f5; + background: var(--color-gray-150, #f3f3f3); border: 1px solid rgb(0 0 0 / 3%); - border-radius: 12px; + border-radius: 16px; cursor: pointer; text-align: left; padding: var(--spacing-80) var(--spacing-300) var(--spacing-80) var(--spacing-80); @@ -191,29 +256,42 @@ transition: background-color 0.15s ease; } +.qm-control--colour { + padding: var(--spacing-200); +} + .qm-control:hover, .qm-control[aria-expanded='true'] { - background: #ebebeb; + background: var(--color-gray-300-variant, #dadada); } .qm-pill { - display: inline-flex; - align-items: center; - justify-content: center; + /* 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: #fff; border-radius: 10px; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); font-size: 16px; + text-align: center; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .qm-swatch { align-self: center; - width: 46px; - height: 46px; + width: 34px; + height: 34px; flex-shrink: 0; border-radius: 10px; border: 1px solid rgb(255 255 255 / 85%); @@ -223,12 +301,11 @@ } .qm-control-label { + flex-shrink: 0; font-weight: var(--heading-font-weight-medium, 700); font-size: 14px; color: #292929; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } .qm-panel { @@ -248,9 +325,9 @@ width: 100%; height: 58px; box-sizing: border-box; - background: #f5f5f5; + background: var(--color-gray-150, #f3f3f3); border: 1px solid rgb(0 0 0 / 3%); - border-radius: 12px; + border-radius: 16px; transform: translateY(96.5px); transition: transform 0.3s cubic-bezier(0.65, 0, 0.35, 1); } @@ -278,7 +355,7 @@ padding: 0 var(--spacing-200); border: none; background: transparent; - border-radius: 8px; + border-radius: var(--corner-radius-80, 6px); font-size: 16px; white-space: nowrap; cursor: pointer; @@ -286,6 +363,7 @@ .qm-font.is-selected { background: #fff; + border-radius: 10px; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); } @@ -320,7 +398,8 @@ } .qm-swatch-btn.is-selected { - border: 2px solid #1473e6; + border: 2px solid #3b63fb; + box-shadow: 0 2px 6px rgb(0 0 0 / 4%), 0 4px 12px rgb(0 0 0 / 8%); } .qm-swatch-fill { @@ -377,12 +456,12 @@ * the editor's bottom edge. */ .qm-deco--1 { left: calc(50% - 811px); bottom: 496px; transform: rotate(1.5deg); } -.qm-deco--2 { left: calc(50% - 541px); bottom: 260px; transform: rotate(-1.5deg); } -.qm-deco--3 { left: calc(50% - 811px); bottom: 260px; transform: rotate(2deg); } +.qm-deco--2 { left: calc(50% - 541px); bottom: 360px; transform: rotate(-1.5deg); } +.qm-deco--3 { left: calc(50% - 811px); bottom: 160px; transform: rotate(2deg); } .qm-deco--4 { left: calc(50% - 541px); bottom: 0; transform: rotate(-2deg); } .qm-deco--5 { left: calc(50% + 631px); bottom: 496px; transform: rotate(-1.5deg); } -.qm-deco--6 { left: calc(50% + 361px); bottom: 260px; transform: rotate(1.5deg); } -.qm-deco--7 { left: calc(50% + 631px); bottom: 260px; transform: rotate(-2deg); } +.qm-deco--6 { left: calc(50% + 361px); bottom: 360px; transform: rotate(1.5deg); } +.qm-deco--7 { left: calc(50% + 631px); bottom: 160px; transform: rotate(-2deg); } .qm-deco--8 { left: calc(50% + 361px); bottom: 0; transform: rotate(2deg); } .qm-deco:hover, @@ -397,45 +476,82 @@ box-sizing: border-box; padding: 11px; border: 0.5px solid rgb(255 255 255 / 66%); - border-radius: 20px; + border-radius: 17px; 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%); -webkit-user-select: none; user-select: none; } -.qm-deco[data-theme='light'] .qm-deco-quote, -.qm-deco[data-theme='light'] .qm-deco-author { - color: #131313; -} - -.qm-deco[data-theme='dark'] .qm-deco-quote, -.qm-deco[data-theme='dark'] .qm-deco-author { - color: #fff; -} - -.qm-deco-quote { +/* + * 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. Quote is the card's + * only flex item (per Figma node 0-18559) and centers itself vertically + * within the full card height, independent of the author line below — + * author is absolutely positioned near the bottom instead of sharing a + * centering group with the quote, matching that same Figma reference. + */ +/* p.qm-deco-quote (not just .qm-deco-quote): .quote-maker-header p — these + cards are descendants of the header, see init()'s comment on why — is + otherwise equally or more specific and, being a

, matches these too. */ +p.qm-deco-quote { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + line-clamp: 4; + /* Fixed height (not flex: 1 / padding) — -webkit-box's intrinsic height + from -webkit-line-clamp ignores flex-shrink and reduced-by-padding + space alike, so only an explicit height reliably caps it at what + actually fits: full 126px content area when no author line needs + room, or that minus the author's own reserved space (see the + :has() override below) when one is present. */ + width: 100%; + height: 126px; + /* -webkit-box has no justify-content equivalent of its own — this centers + the (possibly fewer-than-clamped) lines of text within the box's own + height, which is what actually centers the quote in the card. */ + -webkit-box-pack: center; margin: 0; + overflow: hidden; + color: var(--Alias-Content-Typography-Heading, #131313); + text-align: center; + font-family: var(--body-font-family); font-size: 12px; + font-style: normal; + font-weight: 600; line-height: 1.25; - text-align: center; word-break: break-word; + align-content: center; +} + +/* Reserves the author's own line height + bottom offset out of the quote's + box only when an author is actually present (see buildDecoCard — the + .qm-deco-author element is omitted otherwise), so a long quote can never + clamp down into visually overlapping the absolutely-positioned author. */ +.qm-deco-card:has(.qm-deco-author) p.qm-deco-quote { + height: 104px; } -.qm-deco-author { +p.qm-deco-author { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); + max-width: calc(100% - 22px); + overflow: hidden; margin: 0; + color: var(--Alias-Content-Typography-Heading, #131313); + text-align: center; + font-family: var(--body-font-family); font-size: 8px; + font-style: normal; + font-weight: 600; white-space: nowrap; + text-overflow: ellipsis; } .qm-deco-actions { @@ -517,27 +633,22 @@ * to exactly 3 slots: prev / centre / next (buildArcCarousel in * quote-maker.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 side slots are translated by their own full width - * (zero overlap with the centre card, however they're rotated) and the - * container is only wide enough to reveal --qm-arc-side-peek of each — - * smaller on mobile than tablet — clipping the rest. + * 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. */ .qm-arc { - --qm-arc-card-w: 542px; - --qm-arc-card-h: 350px; - --qm-arc-side-peek: 64px; + /* Sizing variables (--qm-arc-card-w etc.) are defined on .quote-maker, + not here — see that rule for why. */ position: relative; display: none; - align-items: center; + align-items: flex-start; justify-content: center; - width: calc(var(--qm-arc-card-w) + (2 * var(--qm-arc-side-peek))); - max-width: 100vw; + width: 100vw; + margin: 0 calc(50% - 50vw) var(--qm-arc-gap); height: var(--qm-arc-card-h); - margin: 0 auto var(--spacing-100); - /* Full-bleed within the viewport: the widget itself is only - --qm-arc-card-w wide, so this wider, centred clip box needs to escape - the widget's own narrower box to have room to reveal the side peeks. */ - overflow: hidden; + clip-path: inset(calc(-1 * var(--qm-arc-extra-h)) 0); } .qm-carousel-mode .quote-maker-decorations { @@ -552,6 +663,16 @@ display: flex; } +/* The widget's own max-width (542px, matching the tablet card size) only + happens to equal --qm-arc-card-w on tablet — on mobile the card shrinks + to 327px but the widget didn't, so .qm-controls/.qm-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. */ +.qm-carousel-mode .quote-maker-widget { + max-width: var(--qm-arc-card-w, 542px); +} + .qm-arc-card { position: absolute; top: 0; @@ -570,6 +691,12 @@ 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 is fixed at the card's own centre for every role, and + never changes between roles — only transform itself is animated. That + keeps the transition a single smooth translate+rotate interpolation + that stays clear of the centre card throughout, instead of a + corner/bottom pivot whose intermediate frames aren't guaranteed clear. */ + transform-origin: 50% 50%; transition: transform 1s ease-in-out, opacity 1s ease-in-out; -webkit-user-select: none; user-select: none; @@ -578,7 +705,6 @@ .qm-arc-card--center { z-index: 3; cursor: default; - transform-origin: 50% 50%; } .qm-arc-card--prev, @@ -587,32 +713,57 @@ cursor: pointer; } -/* Side cards are translated by their own full width, so their near edge - lands exactly at the centre card's edge — zero overlap no matter how - they're rotated, since the pivot corner (nearest the centre) doesn't - move under rotation and the rest of the box only swings further away. - Left card pivots from its bottom-right corner, right card from its - bottom-left corner, so each tilts outward and away from centre. The - container's own width (not this offset) controls how much of each - stays visible — see --qm-arc-side-peek above. */ .qm-arc-card--prev { - transform-origin: 100% 100%; - transform: translateX(calc(-1 * var(--qm-arc-card-w))) rotate(-8deg); + transform: translateX(calc(-1 * var(--qm-arc-slide))) translateY(var(--qm-arc-lift)) rotate(-8deg); } .qm-arc-card--next { - transform-origin: 0 100%; - transform: translateX(var(--qm-arc-card-w)) rotate(8deg); + transform: translateX(var(--qm-arc-slide)) translateY(var(--qm-arc-lift)) rotate(8deg); } -.qm-arc-card[data-theme='light'] .qm-arc-quote, -.qm-arc-card[data-theme='light'] .qm-arc-author { - color: #131313; +/* Off-screen staging position for a recycled card (see quote-maker.js + setRole's `instant` path) — parked further out than the resting + prev/next spot, same rotation, so the very next transition (turning off + `instant`) animates it rotating in from beyond the edge instead of + popping directly into its final slot. */ +.qm-arc-card--stage-prev { + transform: translateX(calc(-2 * var(--qm-arc-slide))) translateY(var(--qm-arc-lift)) rotate(-8deg); } -.qm-arc-card[data-theme='dark'] .qm-arc-quote, -.qm-arc-card[data-theme='dark'] .qm-arc-author { - color: #fff; +.qm-arc-card--stage-next { + transform: translateX(calc(2 * var(--qm-arc-slide))) translateY(var(--qm-arc-lift)) rotate(8deg); +} + +/* Ghost: a 4th, non-interactive card (see buildArcGhost in quote-maker.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. */ +.qm-arc-ghost { + z-index: 0; + pointer-events: none; + opacity: 0; + cursor: default; +} + +.qm-arc-ghost.qm-arc-ghost--visible { + opacity: 1; +} + +.qm-arc-card--exit-prev { + transform: translateY(calc(-1 * var(--qm-arc-lift))) translateX(calc(-2 * var(--qm-arc-slide)))rotate(0deg); +} + +.qm-arc-card--exit-next { + transform: translateX(calc(2 * var(--qm-arc-slide))) translateY(var(--qm-arc-lift)) rotate(8deg); +} + +.qm-arc-ghost.qm-arc-ghost--visible.qm-arc-card--exit-prev, +.qm-arc-ghost.qm-arc-ghost--visible.qm-arc-card--exit-next { + opacity: 0; } .qm-arc-quote { @@ -621,6 +772,7 @@ line-height: 1.2; text-align: center; word-break: break-word; + color: #131313; } .qm-arc-author { @@ -631,6 +783,7 @@ margin: 0; font-size: 12px; white-space: nowrap; + color: #505050; } .qm-arc-nav { @@ -655,15 +808,17 @@ .qm-arc-nav img { width: 20px; height: 20px; - filter: brightness(0) invert(1); } +/* 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. */ .qm-arc-nav--prev { - left: calc(50% - (var(--qm-arc-card-w) / 2)); + left: calc(50% - (var(--qm-arc-card-w) / 2) - var(--spacing-400)); } .qm-arc-nav--next { - right: calc(50% - (var(--qm-arc-card-w) / 2)); + right: calc(50% - (var(--qm-arc-card-w) / 2) - var(--spacing-400)); } /* @@ -678,15 +833,6 @@ inset: 0; z-index: 1200; display: none; - background: rgb(0 0 0 / 44%); - -webkit-backdrop-filter: blur(10px); - backdrop-filter: blur(10px); - opacity: 0; - transition: opacity 0.2s ease; -} - -.qm-sheet-overlay.is-open { - opacity: 1; } .qm-sheet { @@ -765,17 +911,65 @@ padding: var(--spacing-500) var(--spacing-300); } + /* "Background colour" control label reads "Background" only at this + width — narrower control column has less room for the full label. */ + .qm-control-label-suffix { + display: none; + } + + /* Mobile header is left-aligned (not centred) per Figma frame 0-18684, + with a larger logo lockup and a darker, looser-line-height heading. */ + .quote-maker-header { + align-items: flex-start; + text-align: left; + gap: var(--spacing-300); + } + + .quote-maker-logo { + justify-content: flex-start; + } + + .quote-maker-logo img { + height: 29px; + } + + .quote-maker-header h1, + .quote-maker-header h2 { + line-height: 1.04; + color: #131313; + } + + .quote-maker-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. */ + .quote-maker-header p:has(a.button) { + display: none; + } + .qm-quote { font-size: 16px; } - .qm-arc { - /* Smaller card, and a much thinner side-card sliver than tablet — per - Figma mobile frame 0-18684, only a small edge of the neighbouring - templates should be visible. */ + .quote-maker { + /* 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 .qm-arc above), how much + of each side card actually shows follows naturally from the smaller + mobile viewport, not a separately tuned peek amount. --qm-arc-slide + recalculated for this card size the same way as the base rule: + 327/2*cos(8)+270/2*sin(8) + 327/2 ≈ 344px; +10px safety margin. + --qm-arc-lift/--qm-arc-extra-h likewise recalculated for 327x270. + Defined on .quote-maker (not .qm-arc) so .quote-maker-widget can + also read --qm-arc-card-w — see .qm-carousel-mode .quote-maker-widget. */ --qm-arc-card-w: 327px; --qm-arc-card-h: 270px; - --qm-arc-side-peek: 24px; + --qm-arc-slide: 354px; + --qm-arc-lift: 24px; + --qm-arc-extra-h: 50px; + --qm-arc-gap: var(--spacing-100); } .qm-arc-quote { @@ -787,6 +981,10 @@ height: 40px; } + .qm-controls { + gap: var(--spacing-250); + } + .qm-panel { display: none; } diff --git a/express/code/blocks/quote-maker/quote-maker.js b/express/code/blocks/quote-maker/quote-maker.js index 3f357ea15..f945eb99b 100644 --- a/express/code/blocks/quote-maker/quote-maker.js +++ b/express/code/blocks/quote-maker/quote-maker.js @@ -10,6 +10,7 @@ import { disableBackgroundScroll, restoreBackgroundScroll, } from '../../scripts/color-shared/spectrum/utils/a11y.js'; +import showCopyToast from '../../scripts/utils/copy-toast.js'; let createTag; @@ -17,7 +18,9 @@ const TEMPLATE_LIMIT = 8; const DECO_CARD_COUNT = 8; const TABLET_BREAKPOINT = 1199; -const FONT_OPTIONS = [ +// Fallback used only if the Typekit kit fails to load or exposes no fonts +// (network failure, ad blocker, or API shape change) — see buildFontOptions. +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 }, @@ -59,9 +62,60 @@ function loadWebFonts() { }); } -async function copyToClipboard(text) { +// 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 FONT_OPTIONS 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; + }); +} + +/** + * 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 quote-maker'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; @@ -153,34 +207,31 @@ async function fetchCardBackgrounds(props) { return res.items .filter((item) => isValidTemplate(item)) .slice(0, props.limit) - .map((item, index) => { + .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, - theme: index % 2 === 0 ? 'light' : 'dark', - }; + return { id: item.id, bg }; }) .filter((card) => !!card.bg); } /** - * Pairs each fetched background card with a quote (and a font, cycling - * through FONT_OPTIONS) so every card/quote/font 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. + * 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 .qm-deco-quote), only the + * editor's own widget has a font choice — see buildFontControl. */ function buildCardSet(cards, quotes) { return cards.map((card, i) => ({ card, quote: quotes[i % quotes.length].quote, author: quotes[i % quotes.length].author, - font: FONT_OPTIONS[i % FONT_OPTIONS.length], })); } @@ -236,14 +287,14 @@ function buildFontButton(opt, index, onPick) { return btn; } -function buildFontControl(block, onSelect) { +function buildFontControl(block, fontOptions, onSelect) { const control = createTag('button', { type: 'button', class: 'qm-control qm-control--font', 'aria-expanded': 'false', }); const pill = createTag('span', { class: 'qm-pill' }); - pill.textContent = FONT_OPTIONS[0].label; + pill.textContent = fontOptions[0].label; const label = createTag('span', { class: 'qm-control-label' }); label.textContent = 'Font style'; control.append(pill, label); @@ -261,8 +312,12 @@ function buildFontControl(block, onSelect) { function selectFont(opt) { block.style.setProperty('--qm-quote-font', opt.font); + block.style.setProperty('--qm-quote-font-style', opt.italic ? 'italic' : 'normal'); + block.style.setProperty('--qm-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('.qm-font').forEach((f) => { const isMatch = f.dataset.font === opt.font; @@ -276,11 +331,18 @@ function buildFontControl(block, onSelect) { selectFont(opt); onSelect?.(opt); }; - FONT_OPTIONS.forEach((opt, index) => { + fontOptions.forEach((opt, index) => { panel.append(buildFontButton(opt, index, onPick)); sheetGrid.append(buildFontButton(opt, index, onPick)); }); + // Applies fontOptions[0] as --qm-quote-font immediately, instead of + // leaving the block on its CSS default (--body-font-family) until the + // user's first click — the first .qm-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 = block.getAttribute('data-qm-panel') === 'fonts'; block.setAttribute('data-qm-panel', isOpen ? 'none' : 'fonts'); @@ -301,7 +363,6 @@ function buildSwatchButton(card, index, onPick) { type: 'button', class: `qm-swatch-btn${index === 0 ? ' is-selected' : ''}`, 'data-bg': card.bg, - 'data-theme': card.theme, role: 'option', 'aria-selected': index === 0 ? 'true' : 'false', 'aria-label': `Background ${index + 1}`, @@ -322,8 +383,13 @@ function buildColorControl(block, cards, onSelect) { 'aria-expanded': 'false', }); const swatch = createTag('span', { class: 'qm-swatch' }); - const label = createTag('span', { class: 'qm-control-label' }); - label.textContent = 'Background colour'; + // "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: 'qm-control-label' }, [ + 'Background', + createTag('span', { class: 'qm-control-label-suffix' }, [' colour']), + ]); control.append(swatch, label); const panel = createTag('div', { @@ -339,9 +405,8 @@ function buildColorControl(block, cards, onSelect) { 'aria-label': 'Background colour', }); - function selectSwatch(bg, theme) { + function selectSwatch(bg) { block.style.setProperty('--qm-card-bg', `url("${bg}")`); - block.setAttribute('data-qm-theme', theme); swatch.style.backgroundImage = `url("${bg}")`; [panel, sheetGrid].forEach((container) => { container.querySelectorAll('.qm-swatch-btn').forEach((s) => { @@ -353,7 +418,7 @@ function buildColorControl(block, cards, onSelect) { } const onPick = (card) => { - selectSwatch(card.bg, card.theme); + selectSwatch(card.bg); onSelect?.(card); }; cards.forEach((card, index) => { @@ -434,7 +499,7 @@ function buildBottomSheet(block, kind, title, contentEl) { return { overlay, onPanelChange }; } -function buildWidget(block, cardSet) { +function buildWidget(block, cardSet, fontOptions) { const widget = createTag('div', { class: 'quote-maker-widget' }); const card = createTag('div', { class: 'qm-card' }); @@ -461,7 +526,7 @@ function buildWidget(block, cardSet) { widget.append(card); const doCopy = async () => { - const ok = await copyToClipboard(quoteEl.textContent); + const ok = await copyQuoteToClipboard(quoteEl.textContent, authorEl.textContent); if (ok) { quoteWrap.classList.add('is-copied'); setTimeout(() => quoteWrap.classList.remove('is-copied'), 1200); @@ -477,7 +542,6 @@ function buildWidget(block, cardSet) { if (first.card) { block.style.setProperty('--qm-card-bg', `url("${first.card.bg}")`); - block.setAttribute('data-qm-theme', first.card.theme); } // Set by init() once the arc carousel exists, so picking a font/colour @@ -492,7 +556,7 @@ function buildWidget(block, cardSet) { panel: fontPanel, sheetGrid: fontSheetGrid, selectFont, - } = buildFontControl(block, (font) => onFontOrColourPick({ font })); + } = buildFontControl(block, fontOptions, (font) => onFontOrColourPick({ font })); const { control: colourControl, panel: colourPanel, @@ -539,7 +603,7 @@ function buildWidget(block, cardSet) { quoteEl.textContent = quote; authorEl.textContent = author || ''; authorEl.style.display = author ? '' : 'none'; - if (bgCard) selectSwatch(bgCard.bg, bgCard.theme); + if (bgCard) selectSwatch(bgCard.bg); if (font) selectFont(font); }, onFontOrColourChange: (listener) => { onFontOrColourPick = listener; }, @@ -547,19 +611,16 @@ function buildWidget(block, cardSet) { } function buildDecoCard(entry, useQuote) { - const { - card, quote, author, font, - } = entry; - const deco = createTag('div', { class: 'qm-deco', 'data-theme': card.theme, tabindex: '-1' }); + const { card, quote, author } = entry; + const deco = createTag('div', { class: 'qm-deco', tabindex: '-1' }); const cardWrap = createTag('div', { class: 'qm-deco-card-wrap' }); const inner = createTag('div', { class: 'qm-deco-card', style: `background-image:url("${card.bg}")`, }); - const quoteP = createTag('p', { - class: 'qm-deco-quote', - style: `font-family:${font.font};${font.italic ? 'font-style:italic;' : ''}`, - }); + // Fixed font/style for every card — see .qm-deco-quote in CSS — so no + // per-instance font styling here; only the editor's own selection varies. + const quoteP = createTag('p', { class: 'qm-deco-quote' }); quoteP.textContent = quote; inner.append(quoteP); if (author) { @@ -577,9 +638,9 @@ function buildDecoCard(entry, useQuote) { type: 'button', class: 'qm-deco-copy', 'aria-label': 'Copy quote', - }, [getIconElementDeprecated('copy')]); + }, [getIconElementDeprecated('copy-quote')]); copyBtn.addEventListener('click', async () => { - const ok = await copyToClipboard(quote); + const ok = await copyQuoteToClipboard(quote, author); if (ok) { copyBtn.classList.add('is-copied'); setTimeout(() => copyBtn.classList.remove('is-copied'), 1200); @@ -612,6 +673,8 @@ function buildDecoCards(cardSet, useQuote) { * what makes the 1s transform transition on .qm-arc-card actually animate * a visible slide/rotate between roles instead of an instant content pop. */ +const ROLE_CLASSES = ['qm-arc-card--prev', 'qm-arc-card--center', 'qm-arc-card--next', 'qm-arc-card--stage-prev', 'qm-arc-card--stage-next']; + function buildArcCard(onActivate) { const el = createTag('div', { class: 'qm-arc-card', @@ -625,40 +688,94 @@ function buildArcCard(onActivate) { el.addEventListener('click', () => onActivate(el)); function render(entry) { - el.dataset.theme = entry.card.theme; el.style.backgroundImage = `url("${entry.card.bg}")`; quoteP.textContent = entry.quote; - quoteP.style.fontFamily = entry.font.font; - quoteP.style.fontStyle = entry.font.italic ? 'italic' : ''; + // 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 setRole(role, { instant = false } = {}) { - // The recycled card (the one that just left the deck at one edge to - // re-enter, with new content, at the other edge) needs to land in its - // new role's position without visibly sliding there — so its role - // change is applied with the transition switched off for one frame, - // then restored, rather than animating over the 1s duration like the - // other two cards' prev<->centre<->next moves. - if (instant) el.style.transition = 'none'; - el.classList.remove('qm-arc-card--prev', 'qm-arc-card--center', 'qm-arc-card--next'); - el.classList.add(`qm-arc-card--${role}`); + 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'; - if (instant) { - el.getBoundingClientRect(); // force reflow so the instant jump commits - el.style.transition = ''; - } + } + + // 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(`qm-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(`qm-arc-card--${role}`); + setInteractivity(role); } return { - el, render, setRole, + 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: 'qm-arc-card qm-arc-ghost', 'aria-hidden': 'true' }); + const quoteP = createTag('p', { class: 'qm-arc-quote' }); + const authorP = createTag('p', { class: 'qm-arc-author' }); + el.append(quoteP, authorP); + + function playExit(entry, fromRole) { + el.style.backgroundImage = `url("${entry.card.bg}")`; + quoteP.textContent = entry.quote; + // 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('qm-arc-card--exit-prev', 'qm-arc-card--exit-next', 'qm-arc-ghost--visible'); + el.classList.add(`qm-arc-card--${fromRole}`, 'qm-arc-ghost--visible'); + el.getBoundingClientRect(); // force reflow so the starting position commits + el.style.transition = ''; + requestAnimationFrame(() => requestAnimationFrame(() => { + el.classList.remove(`qm-arc-card--${fromRole}`); + el.classList.add(`qm-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 @@ -676,10 +793,18 @@ function buildArcCarousel(cardSet, useQuote) { const total = cardSet.length; let activeIndex = 0; // The centre card can be patched independently of cardSet (e.g. the - // widget's own font/colour controls, which apply on top of whichever - // entry is currently active) — centreOverride holds that patch and is - // reset whenever navigation moves a *different* entry into the centre. + // 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. + let selectedFont = null; + + const withFont = (entry) => (selectedFont ? { ...entry, font: selectedFont } : entry); const onActivate = (el) => { if (el.classList.contains('qm-arc-card--prev')) goPrev(); // eslint-disable-line no-use-before-define @@ -688,6 +813,7 @@ function buildArcCarousel(cardSet, useQuote) { 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]; @@ -698,75 +824,102 @@ function buildArcCarousel(cardSet, useQuote) { } function centerEntry() { - return { ...cardSet[activeIndex], ...centreOverride }; + return withFont({ ...cardSet[activeIndex], ...centreOverride }); } function renderAll() { const prevIndex = ((activeIndex - 1) % total + total) % total; const nextIndex = (activeIndex + 1) % total; - cards[roles.indexOf('prev')].render(cardSet[prevIndex]); + cards[roles.indexOf('prev')].render(withFont(cardSet[prevIndex])); cards[roles.indexOf('center')].render(centerEntry()); - cards[roles.indexOf('next')].render(cardSet[nextIndex]); + 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: it jumps instantly (no transition) to the next position - // with new content, rather than visibly sliding across the centre. + // 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'); - recycled.setRole('next', { instant: true }); roles = roles.map((role) => ({ center: 'prev', next: 'center', prev: 'next' }[role])); const newNextIndex = (activeIndex + 1) % total; - recycled.render(cardSet[newNextIndex]); + 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 — instantly, new content — - // to become the new prev. + // 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'); - recycled.setRole('prev', { instant: true }); roles = roles.map((role) => ({ center: 'next', prev: 'center', next: 'prev' }[role])); const newPrevIndex = ((activeIndex - 1) % total + total) % total; - recycled.render(cardSet[newPrevIndex]); + 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's centre card - // exactly as it already updates the desktop widget's own .qm-card. + // selecting a font or background there updates the arc carousel exactly + // as it already updates the desktop widget's own .qm-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(); - root.append(cardA.el, cardB.el, cardC.el); + root.append(ghost.el, cardA.el, cardB.el, cardC.el); const prevBtn = createTag('button', { type: 'button', class: 'qm-arc-nav qm-arc-nav--prev', 'aria-label': 'Previous template', - }, [getIconElementDeprecated('s2-chevron-left')]); + }, [getIconElementDeprecated('arc-nav-left')]); const nextBtn = createTag('button', { type: 'button', class: 'qm-arc-nav qm-arc-nav--next', 'aria-label': 'Next template', - }, [getIconElementDeprecated('s2-chevron-right')]); + }, [getIconElementDeprecated('arc-nav-right')]); root.append(prevBtn, nextBtn); prevBtn.addEventListener('click', goPrev); @@ -788,14 +941,15 @@ export default async function init(block) { const quotes = getPageQuotes(); - loadWebFonts(); - try { - const cards = await fetchCardBackgrounds(props); + const [cards] = await Promise.all([fetchCardBackgrounds(props), loadWebFonts()]); if (!cards.length || !quotes.length) { block.closest('.section')?.remove(); return; } + // Built only once loadWebFonts() has resolved, so this reflects whatever + // families the Typekit kit actually loaded rather than a guessed list. + const fontOptions = buildFontOptions(); const cardSet = buildCardSet(cards, quotes); // Same 9 entries (the widget's own + the 8 desktop decorations) power // the tablet/mobile arc carousel, so it cycles through the identical @@ -803,7 +957,7 @@ export default async function init(block) { const arcCardSet = [cardSet[0], ...cardSet.slice(1, 1 + DECO_CARD_COUNT)]; const stage = createTag('div', { class: 'quote-maker-stage' }); - const { widget, useQuote, onFontOrColourChange } = buildWidget(block, cardSet); + const { widget, useQuote, onFontOrColourChange } = buildWidget(block, cardSet, fontOptions); const decorations = buildDecoCards(cardSet, useQuote); const { root: arcCarousel, updateCentre } = buildArcCarousel(arcCardSet, useQuote); onFontOrColourChange(updateCentre); @@ -819,12 +973,39 @@ export default async function init(block) { stage.append(widget); block.append(stage); - const isSmallViewport = () => window.innerWidth <= TABLET_BREAKPOINT; + // 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 isTouchDevice = window.matchMedia('(pointer: coarse)').matches; + const isSmallViewport = () => { + const size = isTouchDevice + ? Math.min(window.innerWidth, window.innerHeight) + : window.innerWidth; + return size <= TABLET_BREAKPOINT; + }; const syncViewportMode = () => { block.classList.toggle('qm-carousel-mode', isSmallViewport()); }; syncViewportMode(); window.addEventListener('resize', syncViewportMode); + + // "Create a design" buttons on the page's collapsible-rows quotes (see + // collapsible-rows.js) dispatch this instead of importing quote-maker + // directly, since the two blocks are otherwise unrelated. Scrolls the + // editor into view and swaps in the copied quote/author, on whichever + // of the desktop widget or the tablet/mobile carousel is active. + document.addEventListener('quote-maker:use-quote', (e) => { + const { quote, author } = e.detail; + useQuote({ quote, author }); + updateCentre({ quote, author }); + block.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); } catch (error) { window.lana?.log(`Error in quote-maker: ${error?.message || error}`, { tags: 'quote-maker', 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/scripts/utils/copy-toast.css b/express/code/scripts/utils/copy-toast.css new file mode 100644 index 000000000..2a5f05baa --- /dev/null +++ b/express/code/scripts/utils/copy-toast.css @@ -0,0 +1,49 @@ +.copy-toast-container { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 10000; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + pointer-events: none; +} + +.copy-toast { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + padding: 12px 16px 12px 16px; + background: #05834e; + border-radius: 10px; + color: #fff; + font-family: var(--body-font-family); + font-size: 14px; + line-height: 18px; + 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.is-visible { + opacity: 1; + transform: translateY(0); +} + +.copy-toast-icon { + flex-shrink: 0; + width: 18px; + height: 18px; + background-color: #fff; + -webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 16.875C4.65732 16.875 1.125 13.3427 1.125 9C1.125 4.65732 4.65732 1.125 9 1.125C13.3427 1.125 16.875 4.65732 16.875 9C16.875 13.3427 13.3427 16.875 9 16.875ZM9 2.475C5.40175 2.475 2.475 5.40175 2.475 9C2.475 12.5982 5.40175 15.525 9 15.525C12.5982 15.525 15.525 12.5982 15.525 9C15.525 5.40175 12.5982 2.475 9 2.475Z' fill='%23000'/%3E%3Cpath d='M8.29813 12.1526C8.10741 12.1526 7.92459 12.0717 7.79627 11.9293L5.5366 9.41653C5.287 9.1388 5.30985 8.71252 5.5867 8.46292C5.86354 8.21331 6.28982 8.23616 6.54031 8.51301L8.2445 10.4088L11.3831 6.12854C11.6019 5.82796 12.0229 5.76028 12.3261 5.98353C12.6267 6.20325 12.6918 6.62601 12.4712 6.92659L8.84215 11.8766C8.72262 12.0409 8.53453 12.142 8.33151 12.1517C8.32096 12.1526 8.30956 12.1526 8.29813 12.1526Z' fill='%23000'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml;utf8,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 16.875C4.65732 16.875 1.125 13.3427 1.125 9C1.125 4.65732 4.65732 1.125 9 1.125C13.3427 1.125 16.875 4.65732 16.875 9C16.875 13.3427 13.3427 16.875 9 16.875ZM9 2.475C5.40175 2.475 2.475 5.40175 2.475 9C2.475 12.5982 5.40175 15.525 9 15.525C12.5982 15.525 15.525 12.5982 15.525 9C15.525 5.40175 12.5982 2.475 9 2.475Z' fill='%23000'/%3E%3Cpath d='M8.29813 12.1526C8.10741 12.1526 7.92459 12.0717 7.79627 11.9293L5.5366 9.41653C5.287 9.1388 5.30985 8.71252 5.5867 8.46292C5.86354 8.21331 6.28982 8.23616 6.54031 8.51301L8.2445 10.4088L11.3831 6.12854C11.6019 5.82796 12.0229 5.76028 12.3261 5.98353C12.6267 6.20325 12.6918 6.62601 12.4712 6.92659L8.84215 11.8766C8.72262 12.0409 8.53453 12.142 8.33151 12.1517C8.32096 12.1526 8.30956 12.1526 8.29813 12.1526Z' fill='%23000'/%3E%3C/svg%3E"); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} diff --git a/express/code/scripts/utils/copy-toast.js b/express/code/scripts/utils/copy-toast.js new file mode 100644 index 000000000..027ab2b60 --- /dev/null +++ b/express/code/scripts/utils/copy-toast.js @@ -0,0 +1,41 @@ +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 (quote-maker, 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`); + } + 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('span', { class: 'copy-toast-icon', 'aria-hidden': 'true' }), + createTag('span', { class: 'copy-toast-message' }, [message]), + ]); + container.append(toast); + + requestAnimationFrame(() => toast.classList.add('is-visible')); + + const remove = () => { + toast.classList.remove('is-visible'); + toast.addEventListener('transitionend', () => toast.remove(), { once: true }); + }; + setTimeout(remove, 5000); +} From 71b988045f5c97f81a30d2f347be497ec5a6fc6d Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Mon, 10 Aug 2026 17:58:39 +0530 Subject: [PATCH 03/21] Add cross button on toast --- express/code/scripts/utils/copy-toast.css | 40 ++++++++++++++++++++++- express/code/scripts/utils/copy-toast.js | 13 ++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/express/code/scripts/utils/copy-toast.css b/express/code/scripts/utils/copy-toast.css index 2a5f05baa..0e0624d21 100644 --- a/express/code/scripts/utils/copy-toast.css +++ b/express/code/scripts/utils/copy-toast.css @@ -16,7 +16,7 @@ align-items: center; gap: 8px; box-sizing: border-box; - padding: 12px 16px 12px 16px; + padding: 12px 8px 12px 16px; background: #05834e; border-radius: 10px; color: #fff; @@ -28,6 +28,10 @@ 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 { @@ -47,3 +51,37 @@ -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; } + +.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%); +} + +.copy-toast-close::before { + content: ''; + width: 12px; + height: 12px; + background-color: #fff; + -webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L11 11M11 1L1 11' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml;utf8,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L11 11M11 1L1 11' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} diff --git a/express/code/scripts/utils/copy-toast.js b/express/code/scripts/utils/copy-toast.js index 027ab2b60..682a67d6c 100644 --- a/express/code/scripts/utils/copy-toast.js +++ b/express/code/scripts/utils/copy-toast.js @@ -31,11 +31,20 @@ export default async function showCopyToast(message) { ]); container.append(toast); - requestAnimationFrame(() => toast.classList.add('is-visible')); - 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', + }); + closeBtn.addEventListener('click', remove); + toast.append(closeBtn); + + requestAnimationFrame(() => toast.classList.add('is-visible')); + setTimeout(remove, 5000); } From f0631c77ec1bf97aa8dcd1ba9a0f31624e618ecc Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Tue, 11 Aug 2026 12:11:34 +0530 Subject: [PATCH 04/21] Rename quote maker to mini editor --- .../collapsible-rows/collapsible-rows.js | 30 +- .../mini-editor.css} | 382 +++++++++--------- .../mini-editor.js} | 192 ++++----- express/code/scripts/utils/copy-toast.js | 2 +- 4 files changed, 303 insertions(+), 303 deletions(-) rename express/code/blocks/{quote-maker/quote-maker.css => mini-editor/mini-editor.css} (75%) rename express/code/blocks/{quote-maker/quote-maker.js => mini-editor/mini-editor.js} (86%) diff --git a/express/code/blocks/collapsible-rows/collapsible-rows.js b/express/code/blocks/collapsible-rows/collapsible-rows.js index 50049ff19..f4abcf245 100644 --- a/express/code/blocks/collapsible-rows/collapsible-rows.js +++ b/express/code/blocks/collapsible-rows/collapsible-rows.js @@ -7,24 +7,24 @@ let getConfig; let replaceKey; /** - * Name of the custom event a quote-maker block on the same page listens - * for (see quote-maker.js) to jump its editor to this exact quote/author + * 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 = 'quote-maker:use-quote'; +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 quote-maker's own copy + * 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 quote-maker block is present + * "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, hasQuoteMaker) { - if (!hasQuoteMaker) return null; +function buildQuoteActions(quote, author, hasMiniEditor) { + if (!hasMiniEditor) return null; const actions = createTag('div', { class: 'collapsible-row-actions' }); @@ -91,7 +91,7 @@ function createContentElement(html, baseClass, options = {}) { return element; } -function buildTableLayout(block, typographyClasses = {}, hasQuoteMaker = false) { +function buildTableLayout(block, typographyClasses = {}, hasMiniEditor = false) { const parentDiv = block.closest('.section'); parentDiv?.classList.add('collapsible-rows-grey-bg', 'collapsible-section-padding'); @@ -158,7 +158,7 @@ function buildTableLayout(block, typographyClasses = {}, hasQuoteMaker = false) subHeaderEl.classList.add(...typographyClasses.body); } subHeaderAccordion.append(subHeaderEl); - const quoteActions = buildQuoteActions(headerEl.textContent.trim(), subHeaderEl.textContent.trim(), hasQuoteMaker); + const quoteActions = buildQuoteActions(headerEl.textContent.trim(), subHeaderEl.textContent.trim(), hasMiniEditor); if (quoteActions) subHeaderAccordion.append(quoteActions); headerEl.addEventListener('click', () => { @@ -177,7 +177,7 @@ function buildOriginalLayout( typographyClasses = {}, viewMoreText = 'View more', viewLessText = 'View less', - hasQuoteMaker = false, + hasMiniEditor = false, ) { const collapsibleRows = []; const rows = Array.from(block.children); @@ -226,7 +226,7 @@ function buildOriginalLayout( subHeaderEl.classList.add(...typographyClasses.body); } accordion.append(subHeaderEl); - const quoteActions = buildQuoteActions(headerEl.textContent.trim(), subHeaderEl.textContent.trim(), hasQuoteMaker); + const quoteActions = buildQuoteActions(headerEl.textContent.trim(), subHeaderEl.textContent.trim(), hasMiniEditor); if (quoteActions) accordion.append(quoteActions); }); @@ -301,19 +301,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 quote-maker block exists + // "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 hasQuoteMaker = !!document.querySelector('.quote-maker'); + const hasMiniEditor = !!document.querySelector('.mini-editor'); if (isExpandableVariant) { - buildTableLayout(block, typographyClasses, hasQuoteMaker); + 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', hasQuoteMaker); + buildOriginalLayout(block, typographyClasses, viewMoreText || 'View more', viewLessText || 'View less', hasMiniEditor); } } diff --git a/express/code/blocks/quote-maker/quote-maker.css b/express/code/blocks/mini-editor/mini-editor.css similarity index 75% rename from express/code/blocks/quote-maker/quote-maker.css rename to express/code/blocks/mini-editor/mini-editor.css index a1ecc211d..f8f51625c 100644 --- a/express/code/blocks/quote-maker/quote-maker.css +++ b/express/code/blocks/mini-editor/mini-editor.css @@ -1,27 +1,27 @@ -.quote-maker { - --qm-card-bg: none; - --qm-quote-font: var(--body-font-family); - --qm-quote-font-style: normal; - --qm-quote-font-weight: normal; - /* Arc carousel sizing (tablet/mobile) — defined here, not on .qm-arc - itself, so .quote-maker-widget (a sibling of .qm-arc, not a - descendant) can also read --qm-arc-card-w to keep its own max-width +.mini-editor { + --me-card-bg: none; + --me-quote-font: var(--body-font-family); + --me-quote-font-style: normal; + --me-quote-font-weight: normal; + /* 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 - .qm-carousel-mode .quote-maker-widget below and the mobile media + .me-carousel-mode .mini-editor-widget below and the mobile media query for the per-breakpoint overrides. */ - --qm-arc-card-w: 542px; - --qm-arc-card-h: 350px; + --me-arc-card-w: 542px; + --me-arc-card-h: 350px; /* Extra height below the centre card's own box: rotation pivots from each side card's own centre (transform-origin never changes between roles, so the transition can't swing through the centre card at any - point along the path), then a translateY compensation (--qm-arc-lift) + point along the path), then a translateY compensation (--me-arc-lift) pulls the whole card down so its bottom-inner corner lines up with the centre card's bottom edge instead of sitting ~39px above it. That same downward shift pushes the outer-bottom corner ~76px below the unrotated baseline, so the container needs that much extra room beneath the card to avoid clipping it. */ - --qm-arc-extra-h: 80px; - --qm-arc-lift: 39px; + --me-arc-extra-h: 80px; + --me-arc-lift: 39px; /* translateX distance from centre for the side cards' own centrepoint — must clear (cardW/2 * cos(8deg) + cardH/2 * sin(8deg)) + centreCardW/2 at every point along the rotation, not just at rest. Verified clear @@ -29,13 +29,13 @@ linear-time worst case exists in the first ~2% of progress — ease-in-out compresses that window to well under a rendered frame. 542/2*cos(8)+350/2*sin(8)+542/2 ≈ 564px; +10px margin. */ - --qm-arc-slide: 574px; + --me-arc-slide: 574px; /* 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. */ - --qm-arc-gap: var(--spacing-300); + --me-arc-gap: var(--spacing-300); display: flex; flex-direction: column; align-items: center; @@ -45,7 +45,7 @@ gap: var(--spacing-500); } -.quote-maker-header { +.mini-editor-header { display: flex; flex-direction: column; align-items: center; @@ -56,23 +56,23 @@ z-index: 1; } -.quote-maker-header p { +.mini-editor-header p { margin: 0 !important; } -.quote-maker-logo { +.mini-editor-logo { display: flex; align-items: center; justify-content: center; } -.quote-maker-logo img { +.mini-editor-logo img { height: 20px; width: auto; } -.quote-maker-header h1, -.quote-maker-header h2 { +.mini-editor-header h1, +.mini-editor-header h2 { margin: 0; font-size: var(--heading-font-size-l, 2.25rem); font-weight: var(--heading-font-weight-extra, 900); @@ -81,7 +81,7 @@ color: #222; } -.quote-maker-header p { +.mini-editor-header p { margin: 0; max-width: 437px; font-size: var(--body-font-size-m, 1rem); @@ -92,7 +92,7 @@ (.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. */ -.quote-maker-header p a.button.accent:any-link { +.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); @@ -103,14 +103,14 @@ line-height: 125%; } -.quote-maker-stage { +.mini-editor-stage { position: relative; display: flex; justify-content: center; width: 100%; } -.quote-maker-widget { +.mini-editor-widget { position: relative; z-index: 1; display: flex; @@ -120,7 +120,7 @@ max-width: 542px; } -.qm-card { +.me-card { position: relative; width: 100%; height: 350px; @@ -128,7 +128,7 @@ box-sizing: border-box; padding: var(--spacing-350); border-radius: 16px; - background-image: var(--qm-card-bg); + background-image: var(--me-card-bg); background-size: cover; background-position: center; background-repeat: no-repeat; @@ -140,7 +140,7 @@ transition: background-image 0.35s ease; } -.qm-quote-wrap { +.me-quote-wrap { position: relative; box-sizing: border-box; max-width: 322px; @@ -151,17 +151,17 @@ transition: background-color 0.18s ease, border-color 0.18s ease; } -.qm-quote-wrap:hover, -.qm-quote-wrap:focus-visible { +.me-quote-wrap:hover, +.me-quote-wrap:focus-visible { background: rgb(255 255 255 / 20%); border-color: rgb(255 255 255 / 30%); } -.qm-quote { +.me-quote { margin: 0; - font-family: var(--qm-quote-font); - font-style: var(--qm-quote-font-style); - font-weight: var(--qm-quote-font-weight); + font-family: var(--me-quote-font); + font-style: var(--me-quote-font-style); + font-weight: var(--me-quote-font-weight); font-size: 20px; line-height: 26px; text-align: center; @@ -169,27 +169,27 @@ color: #131313; } -.qm-author { +.me-author { /* Absolutely positioned (not in-flow with margin-top) so it doesn't share - .qm-card's flex centering group with .qm-quote-wrap — otherwise a short + .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 .qm-arc-author, + fixed offset from the card's bottom edge. Matches .me-arc-author, which already gets this right. */ position: absolute; left: 50%; bottom: var(--spacing-200); transform: translateX(-50%); margin: 0; - font-family: var(--qm-quote-font); - font-style: var(--qm-quote-font-style); - font-weight: var(--qm-quote-font-weight); + font-family: var(--me-quote-font); + font-style: var(--me-quote-font-style); + font-weight: var(--me-quote-font-weight); font-size: 16px; text-align: center; white-space: nowrap; color: #505050; } -.qm-tip { +.me-tip { position: absolute; bottom: calc(100% + 4px); left: 50%; @@ -200,13 +200,13 @@ z-index: 2; } -.qm-quote-wrap:hover .qm-tip, -.qm-quote-wrap:focus-visible .qm-tip { +.me-quote-wrap:hover .me-tip, +.me-quote-wrap:focus-visible .me-tip { opacity: 1; transform: translateX(-50%) translateY(0); } -.qm-tip-box { +.me-tip-box { display: block; max-width: 160px; padding: 4px 10px; @@ -219,26 +219,26 @@ white-space: nowrap; } -.qm-quote-wrap.is-copied .qm-tip-box::before { +.me-quote-wrap.is-copied .me-tip-box::before { content: 'Copied!'; } -.qm-quote-wrap.is-copied .qm-tip-box { +.me-quote-wrap.is-copied .me-tip-box { font-size: 0; } -.qm-quote-wrap.is-copied .qm-tip-box::before { +.me-quote-wrap.is-copied .me-tip-box::before { font-size: 12px; } -.qm-controls { +.me-controls { display: flex; align-items: stretch; gap: var(--spacing-80); width: 100%; } -.qm-control { +.me-control { flex: 1 1 0; min-width: 0; height: 58px; @@ -256,16 +256,16 @@ transition: background-color 0.15s ease; } -.qm-control--colour { +.me-control--colour { padding: var(--spacing-200); } -.qm-control:hover, -.qm-control[aria-expanded='true'] { +.me-control:hover, +.me-control[aria-expanded='true'] { background: var(--color-gray-300-variant, #dadada); } -.qm-pill { +.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 @@ -288,7 +288,7 @@ text-overflow: ellipsis; } -.qm-swatch { +.me-swatch { align-self: center; width: 34px; height: 34px; @@ -300,7 +300,7 @@ box-shadow: 0 1px 4px rgb(0 0 0 / 4%); } -.qm-control-label { +.me-control-label { flex-shrink: 0; font-weight: var(--heading-font-weight-medium, 700); font-size: 14px; @@ -308,7 +308,7 @@ white-space: nowrap; } -.qm-panel { +.me-panel { position: relative; width: 100%; height: 65px; @@ -316,7 +316,7 @@ overflow: hidden; } -.qm-row { +.me-row { position: absolute; top: 4px; left: 0; @@ -332,23 +332,23 @@ transition: transform 0.3s cubic-bezier(0.65, 0, 0.35, 1); } -.quote-maker[data-qm-panel='fonts'] .qm-row--fonts, -.quote-maker[data-qm-panel='colour'] .qm-row--colour { +.mini-editor[data-me-panel='fonts'] .me-row--fonts, +.mini-editor[data-me-panel='colour'] .me-row--colour { transform: translateY(0); } -.qm-row--fonts { +.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; } -.qm-row--fonts::-webkit-scrollbar { +.me-row--fonts::-webkit-scrollbar { display: none; } -.qm-font { +.me-font { flex: 0 0 auto; min-width: 46px; height: 46px; @@ -361,13 +361,13 @@ cursor: pointer; } -.qm-font.is-selected { +.me-font.is-selected { background: #fff; border-radius: 10px; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); } -.qm-row--colour { +.me-row--colour { justify-content: space-between; gap: var(--spacing-100); padding: 0 var(--spacing-250); @@ -375,11 +375,11 @@ scrollbar-width: none; } -.qm-row--colour::-webkit-scrollbar { +.me-row--colour::-webkit-scrollbar { display: none; } -.qm-swatch-btn { +.me-swatch-btn { position: relative; flex: 0 0 auto; width: 28px; @@ -393,16 +393,16 @@ transition: transform 0.12s ease; } -.qm-swatch-btn:hover { +.me-swatch-btn:hover { transform: scale(1.08); } -.qm-swatch-btn.is-selected { +.me-swatch-btn.is-selected { border: 2px solid #3b63fb; box-shadow: 0 2px 6px rgb(0 0 0 / 4%), 0 4px 12px rgb(0 0 0 / 8%); } -.qm-swatch-fill { +.me-swatch-fill { position: absolute; inset: 0; border-radius: 999px; @@ -410,13 +410,13 @@ background-position: center; } -.quote-maker-decorations { +.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 .quote-maker. */ + the gap matches the header/stage flex gap on .mini-editor. */ position: absolute; top: var(--spacing-700); right: 0; @@ -427,14 +427,14 @@ display: none; } -.qm-deco { +.me-deco { position: absolute; width: 180px; pointer-events: auto; } -.qm-deco-card-wrap { - /* Positioned so .qm-deco-actions's `top: 100%` resolves against the card +.me-deco-card-wrap { + /* Positioned so .me-deco-actions's `top: 100%` resolves against the card itself (not the ancestor). The padding-bottom extends this wrap's own hoverable box down through the gap and the actions row, so moving the pointer from the card down into the buttons never exits the hover @@ -449,27 +449,27 @@ * (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 * holds two 180px-wide cards spaced ~230px apart vertically. Positioned - * from the bottom (not the top) since .quote-maker-decorations' bottom edge + * 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. The lowest card in * each column sits with its own bottom flush at 0 so nothing crosses below * the editor's bottom edge. */ -.qm-deco--1 { left: calc(50% - 811px); bottom: 496px; transform: rotate(1.5deg); } -.qm-deco--2 { left: calc(50% - 541px); bottom: 360px; transform: rotate(-1.5deg); } -.qm-deco--3 { left: calc(50% - 811px); bottom: 160px; transform: rotate(2deg); } -.qm-deco--4 { left: calc(50% - 541px); bottom: 0; transform: rotate(-2deg); } -.qm-deco--5 { left: calc(50% + 631px); bottom: 496px; transform: rotate(-1.5deg); } -.qm-deco--6 { left: calc(50% + 361px); bottom: 360px; transform: rotate(1.5deg); } -.qm-deco--7 { left: calc(50% + 631px); bottom: 160px; transform: rotate(-2deg); } -.qm-deco--8 { left: calc(50% + 361px); bottom: 0; transform: rotate(2deg); } - -.qm-deco:hover, -.qm-deco:focus-within { +.me-deco--1 { left: calc(50% - 811px); bottom: 496px; transform: rotate(1.5deg); } +.me-deco--2 { left: calc(50% - 541px); bottom: 360px; transform: rotate(-1.5deg); } +.me-deco--3 { left: calc(50% - 811px); bottom: 160px; transform: rotate(2deg); } +.me-deco--4 { left: calc(50% - 541px); bottom: 0; transform: rotate(-2deg); } +.me-deco--5 { left: calc(50% + 631px); bottom: 496px; transform: rotate(-1.5deg); } +.me-deco--6 { left: calc(50% + 361px); bottom: 360px; transform: rotate(1.5deg); } +.me-deco--7 { left: calc(50% + 631px); bottom: 160px; transform: rotate(-2deg); } +.me-deco--8 { left: calc(50% + 361px); bottom: 0; transform: rotate(2deg); } + +.me-deco:hover, +.me-deco:focus-within { z-index: 5; } -.qm-deco-card { +.me-deco-card { position: relative; width: 100%; height: 148px; @@ -495,10 +495,10 @@ * author is absolutely positioned near the bottom instead of sharing a * centering group with the quote, matching that same Figma reference. */ -/* p.qm-deco-quote (not just .qm-deco-quote): .quote-maker-header p — these +/* p.me-deco-quote (not just .me-deco-quote): .mini-editor-header p — these cards are descendants of the header, see init()'s comment on why — is otherwise equally or more specific and, being a

, matches these too. */ -p.qm-deco-quote { +p.me-deco-quote { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; @@ -530,13 +530,13 @@ p.qm-deco-quote { /* Reserves the author's own line height + bottom offset out of the quote's box only when an author is actually present (see buildDecoCard — the - .qm-deco-author element is omitted otherwise), so a long quote can never + .me-deco-author element is omitted otherwise), so a long quote can never clamp down into visually overlapping the absolutely-positioned author. */ -.qm-deco-card:has(.qm-deco-author) p.qm-deco-quote { +.me-deco-card:has(.me-deco-author) p.me-deco-quote { height: 104px; } -p.qm-deco-author { +p.me-deco-author { position: absolute; left: 50%; bottom: 12px; @@ -554,8 +554,8 @@ p.qm-deco-author { text-overflow: ellipsis; } -.qm-deco-actions { - /* Fixed px offset, not a percentage — .qm-deco-card-wrap's own +.me-deco-actions { + /* Fixed px offset, not a percentage — .me-deco-card-wrap's own padding-bottom (the hover hit-box buffer) would otherwise be counted twice if this used `top: 100%`, since that percentage resolves against the wrap's full padding box. 148px card height + 11px*2 padding @@ -572,14 +572,14 @@ p.qm-deco-author { transition: opacity 0.18s ease; } -.qm-deco:hover .qm-deco-actions, -.qm-deco:focus-within .qm-deco-actions { +.me-deco:hover .me-deco-actions, +.me-deco:focus-within .me-deco-actions { opacity: 1; pointer-events: auto; } -.qm-deco-use, -.qm-deco-copy { +.me-deco-use, +.me-deco-copy { height: 32px; box-sizing: border-box; background: #e9e9e9; @@ -590,7 +590,7 @@ p.qm-deco-author { transition: background-color 0.15s ease; } -.qm-deco-use { +.me-deco-use { display: inline-flex; align-items: center; padding: 7px 12px; @@ -598,7 +598,7 @@ p.qm-deco-author { white-space: nowrap; } -.qm-deco-copy { +.me-deco-copy { display: inline-flex; align-items: center; justify-content: center; @@ -606,23 +606,23 @@ p.qm-deco-author { padding: 7px; } -.qm-deco-copy img { +.me-deco-copy img { display: block; width: 18px; height: 18px; } -.qm-deco-use:hover, -.qm-deco-copy:hover { +.me-deco-use:hover, +.me-deco-copy:hover { background: #dcdcdc; } -.qm-deco-copy.is-copied { +.me-deco-copy.is-copied { background: #bfe9cb; } @media (width >= 1200px) { - .quote-maker-decorations { + .mini-editor-decorations { display: block; } } @@ -631,54 +631,54 @@ p.qm-deco-author { * Tablet/mobile carousel -------------------------------------------------- * Below 1200px the desktop decorative cards and the fixed widget give way * to exactly 3 slots: prev / centre / next (buildArcCarousel in - * quote-maker.js re-renders each slot's content on every arrow click — it + * mini-editor.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. */ -.qm-arc { - /* Sizing variables (--qm-arc-card-w etc.) are defined on .quote-maker, +.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(--qm-arc-gap); - height: var(--qm-arc-card-h); - clip-path: inset(calc(-1 * var(--qm-arc-extra-h)) 0); + 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); } -.qm-carousel-mode .quote-maker-decorations { +.me-carousel-mode .mini-editor-decorations { display: none; } -.qm-carousel-mode .qm-card { +.me-carousel-mode .me-card { display: none; } -.qm-carousel-mode .qm-arc { +.me-carousel-mode .me-arc { display: flex; } /* The widget's own max-width (542px, matching the tablet card size) only - happens to equal --qm-arc-card-w on tablet — on mobile the card shrinks - to 327px but the widget didn't, so .qm-controls/.qm-panel below the + 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. */ -.qm-carousel-mode .quote-maker-widget { - max-width: var(--qm-arc-card-w, 542px); +.me-carousel-mode .mini-editor-widget { + max-width: var(--me-arc-card-w, 542px); } -.qm-arc-card { +.me-arc-card { position: absolute; top: 0; - left: calc(50% - (var(--qm-arc-card-w) / 2)); - width: var(--qm-arc-card-w); - height: var(--qm-arc-card-h); + 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 rgb(255 255 255 / 66%); @@ -702,39 +702,39 @@ p.qm-deco-author { user-select: none; } -.qm-arc-card--center { +.me-arc-card--center { z-index: 3; cursor: default; } -.qm-arc-card--prev, -.qm-arc-card--next { +.me-arc-card--prev, +.me-arc-card--next { z-index: 1; cursor: pointer; } -.qm-arc-card--prev { - transform: translateX(calc(-1 * var(--qm-arc-slide))) translateY(var(--qm-arc-lift)) rotate(-8deg); +.me-arc-card--prev { + transform: translateX(calc(-1 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(-8deg); } -.qm-arc-card--next { - transform: translateX(var(--qm-arc-slide)) translateY(var(--qm-arc-lift)) rotate(8deg); +.me-arc-card--next { + transform: translateX(var(--me-arc-slide)) translateY(var(--me-arc-lift)) rotate(8deg); } -/* Off-screen staging position for a recycled card (see quote-maker.js +/* Off-screen staging position for a recycled card (see mini-editor.js setRole's `instant` path) — parked further out than the resting prev/next spot, same rotation, so the very next transition (turning off `instant`) animates it rotating in from beyond the edge instead of popping directly into its final slot. */ -.qm-arc-card--stage-prev { - transform: translateX(calc(-2 * var(--qm-arc-slide))) translateY(var(--qm-arc-lift)) rotate(-8deg); +.me-arc-card--stage-prev { + transform: translateX(calc(-2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(-8deg); } -.qm-arc-card--stage-next { - transform: translateX(calc(2 * var(--qm-arc-slide))) translateY(var(--qm-arc-lift)) rotate(8deg); +.me-arc-card--stage-next { + transform: translateX(calc(2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(8deg); } -/* Ghost: a 4th, non-interactive card (see buildArcGhost in quote-maker.js) +/* Ghost: a 4th, non-interactive card (see buildArcGhost in mini-editor.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 @@ -742,31 +742,31 @@ p.qm-deco-author { 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. */ -.qm-arc-ghost { +.me-arc-ghost { z-index: 0; pointer-events: none; opacity: 0; cursor: default; } -.qm-arc-ghost.qm-arc-ghost--visible { +.me-arc-ghost.me-arc-ghost--visible { opacity: 1; } -.qm-arc-card--exit-prev { - transform: translateY(calc(-1 * var(--qm-arc-lift))) translateX(calc(-2 * var(--qm-arc-slide)))rotate(0deg); +.me-arc-card--exit-prev { + transform: translateY(calc(-1 * var(--me-arc-lift))) translateX(calc(-2 * var(--me-arc-slide)))rotate(0deg); } -.qm-arc-card--exit-next { - transform: translateX(calc(2 * var(--qm-arc-slide))) translateY(var(--qm-arc-lift)) rotate(8deg); +.me-arc-card--exit-next { + transform: translateX(calc(2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(8deg); } -.qm-arc-ghost.qm-arc-ghost--visible.qm-arc-card--exit-prev, -.qm-arc-ghost.qm-arc-ghost--visible.qm-arc-card--exit-next { +.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; } -.qm-arc-quote { +.me-arc-quote { margin: 0; font-size: 16px; line-height: 1.2; @@ -775,7 +775,7 @@ p.qm-deco-author { color: #131313; } -.qm-arc-author { +.me-arc-author { position: absolute; left: 50%; bottom: var(--spacing-200); @@ -786,7 +786,7 @@ p.qm-deco-author { color: #505050; } -.qm-arc-nav { +.me-arc-nav { position: absolute; top: 50%; z-index: 3; @@ -805,7 +805,7 @@ p.qm-deco-author { backdrop-filter: blur(8px); } -.qm-arc-nav img { +.me-arc-nav img { width: 20px; height: 20px; } @@ -813,29 +813,29 @@ p.qm-deco-author { /* 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. */ -.qm-arc-nav--prev { - left: calc(50% - (var(--qm-arc-card-w) / 2) - var(--spacing-400)); +.me-arc-nav--prev { + left: calc(50% - (var(--me-arc-card-w) / 2) - var(--spacing-400)); } -.qm-arc-nav--next { - right: calc(50% - (var(--qm-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-qm-panel attribute as the inline row — see .qm-row + * 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. */ -.qm-sheet-overlay { +.me-sheet-overlay { position: fixed; inset: 0; z-index: 1200; display: none; } -.qm-sheet { +.me-sheet { position: absolute; inset-inline: 0; inset-block-end: 0; @@ -852,11 +852,11 @@ p.qm-deco-author { transition: transform 0.34s cubic-bezier(0.22, 1, 0.36, 1); } -.qm-sheet-overlay.is-open .qm-sheet { +.me-sheet-overlay.is-open .me-sheet { transform: translateY(0); } -.qm-sheet-handle { +.me-sheet-handle { width: 50px; height: 4px; margin: var(--spacing-100) auto 0; @@ -865,28 +865,28 @@ p.qm-deco-author { flex-shrink: 0; } -.qm-sheet-title { +.me-sheet-title { margin: 0; font-weight: var(--heading-font-weight-medium, 700); font-size: 16px; text-align: center; } -.qm-sheet-grid { +.me-sheet-grid { display: grid; gap: var(--spacing-200); } -.qm-sheet-grid--fonts { +.me-sheet-grid--fonts { grid-template-columns: repeat(2, 1fr); } -.qm-sheet-grid--fonts .qm-font { +.me-sheet-grid--fonts .me-font { width: 100%; height: 52px; } -.qm-sheet-grid--colour { +.me-sheet-grid--colour { grid-auto-flow: column; grid-template-rows: repeat(2, 46px); grid-auto-columns: 46px; @@ -895,11 +895,11 @@ p.qm-deco-author { scrollbar-width: none; } -.qm-sheet-grid--colour::-webkit-scrollbar { +.me-sheet-grid--colour::-webkit-scrollbar { display: none; } -.qm-sheet-grid--colour .qm-swatch-btn { +.me-sheet-grid--colour .me-swatch-btn { width: 32px; height: 32px; align-self: center; @@ -907,89 +907,89 @@ p.qm-deco-author { } @media (width <= 767px) { - .quote-maker { + .mini-editor { padding: var(--spacing-500) var(--spacing-300); } /* "Background colour" control label reads "Background" only at this width — narrower control column has less room for the full label. */ - .qm-control-label-suffix { + .me-control-label-suffix { display: none; } /* Mobile header is left-aligned (not centred) per Figma frame 0-18684, with a larger logo lockup and a darker, looser-line-height heading. */ - .quote-maker-header { + .mini-editor-header { align-items: flex-start; text-align: left; gap: var(--spacing-300); } - .quote-maker-logo { + .mini-editor-logo { justify-content: flex-start; } - .quote-maker-logo img { + .mini-editor-logo img { height: 29px; } - .quote-maker-header h1, - .quote-maker-header h2 { + .mini-editor-header h1, + .mini-editor-header h2 { line-height: 1.04; color: #131313; } - .quote-maker-header p { + .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. */ - .quote-maker-header p:has(a.button) { + .mini-editor-header p:has(a.button) { display: none; } - .qm-quote { + .me-quote { font-size: 16px; } - .quote-maker { + .mini-editor { /* 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 .qm-arc above), how much + the container is now 100vw everywhere (see .me-arc above), how much of each side card actually shows follows naturally from the smaller - mobile viewport, not a separately tuned peek amount. --qm-arc-slide + mobile viewport, not a separately tuned peek amount. --me-arc-slide recalculated for this card size the same way as the base rule: 327/2*cos(8)+270/2*sin(8) + 327/2 ≈ 344px; +10px safety margin. - --qm-arc-lift/--qm-arc-extra-h likewise recalculated for 327x270. - Defined on .quote-maker (not .qm-arc) so .quote-maker-widget can - also read --qm-arc-card-w — see .qm-carousel-mode .quote-maker-widget. */ - --qm-arc-card-w: 327px; - --qm-arc-card-h: 270px; - --qm-arc-slide: 354px; - --qm-arc-lift: 24px; - --qm-arc-extra-h: 50px; - --qm-arc-gap: var(--spacing-100); + --me-arc-lift/--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-slide: 354px; + --me-arc-lift: 24px; + --me-arc-extra-h: 50px; + --me-arc-gap: var(--spacing-100); } - .qm-arc-quote { + .me-arc-quote { font-size: 14px; } - .qm-arc-nav { + .me-arc-nav { width: 40px; height: 40px; } - .qm-controls { + .me-controls { gap: var(--spacing-250); } - .qm-panel { + .me-panel { display: none; } - .qm-sheet-overlay { + .me-sheet-overlay { display: block; } } diff --git a/express/code/blocks/quote-maker/quote-maker.js b/express/code/blocks/mini-editor/mini-editor.js similarity index 86% rename from express/code/blocks/quote-maker/quote-maker.js rename to express/code/blocks/mini-editor/mini-editor.js index f945eb99b..aed418767 100644 --- a/express/code/blocks/quote-maker/quote-maker.js +++ b/express/code/blocks/mini-editor/mini-editor.js @@ -109,7 +109,7 @@ function buildFontOptions() { * 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 quote-maker's own. + * action on the page uses this same toast, not just mini-editor's own. */ async function copyQuoteToClipboard(quote, author) { const text = author ? `${quote} — ${author}` : quote; @@ -224,7 +224,7 @@ async function fetchCardBackgrounds(props) { * 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 .qm-deco-quote), only the + * decorative card uses one fixed style (see .me-deco-quote), only the * editor's own widget has a font choice — see buildFontControl. */ function buildCardSet(cards, quotes) { @@ -248,13 +248,13 @@ function decorateCta(header) { } function buildLogo() { - return createTag('div', { class: 'quote-maker-logo', 'aria-hidden': 'true' }, [ + return createTag('div', { class: 'mini-editor-logo', 'aria-hidden': 'true' }, [ getIconElementDeprecated('adobe-express-logo'), ]); } function buildContentHeader(props) { - const header = createTag('div', { class: 'quote-maker-header' }); + const header = createTag('div', { class: 'mini-editor-header' }); header.append(buildLogo()); if (props.contentRow) { header.append(...props.contentRow.childNodes); @@ -276,7 +276,7 @@ function buildFontButton(opt, index, onPick) { ].join(''); const btn = createTag('button', { type: 'button', - class: `qm-font${index === 0 ? ' is-selected' : ''}`, + class: `me-font${index === 0 ? ' is-selected' : ''}`, style, 'data-font': opt.font, role: 'option', @@ -290,36 +290,36 @@ function buildFontButton(opt, index, onPick) { function buildFontControl(block, fontOptions, onSelect) { const control = createTag('button', { type: 'button', - class: 'qm-control qm-control--font', + class: 'me-control me-control--font', 'aria-expanded': 'false', }); - const pill = createTag('span', { class: 'qm-pill' }); + const pill = createTag('span', { class: 'me-pill' }); pill.textContent = fontOptions[0].label; - const label = createTag('span', { class: 'qm-control-label' }); + const label = createTag('span', { class: 'me-control-label' }); label.textContent = 'Font style'; control.append(pill, label); const panel = createTag('div', { - class: 'qm-row qm-row--fonts', + class: 'me-row me-row--fonts', role: 'listbox', 'aria-label': 'Font style', }); const sheetGrid = createTag('div', { - class: 'qm-sheet-grid qm-sheet-grid--fonts', + class: 'me-sheet-grid me-sheet-grid--fonts', role: 'listbox', 'aria-label': 'Font style', }); function selectFont(opt) { - block.style.setProperty('--qm-quote-font', opt.font); - block.style.setProperty('--qm-quote-font-style', opt.italic ? 'italic' : 'normal'); - block.style.setProperty('--qm-quote-font-weight', opt.weight || 'normal'); + block.style.setProperty('--me-quote-font', opt.font); + block.style.setProperty('--me-quote-font-style', opt.italic ? 'italic' : 'normal'); + block.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('.qm-font').forEach((f) => { + 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)); @@ -336,16 +336,16 @@ function buildFontControl(block, fontOptions, onSelect) { sheetGrid.append(buildFontButton(opt, index, onPick)); }); - // Applies fontOptions[0] as --qm-quote-font immediately, instead of + // 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 .qm-font button already renders + // 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 = block.getAttribute('data-qm-panel') === 'fonts'; - block.setAttribute('data-qm-panel', isOpen ? 'none' : 'fonts'); + const isOpen = block.getAttribute('data-me-panel') === 'fonts'; + block.setAttribute('data-me-panel', isOpen ? 'none' : 'fonts'); control.setAttribute('aria-expanded', String(!isOpen)); }); @@ -361,14 +361,14 @@ function buildFontControl(block, fontOptions, onSelect) { function buildSwatchButton(card, index, onPick) { const btn = createTag('button', { type: 'button', - class: `qm-swatch-btn${index === 0 ? ' is-selected' : ''}`, + 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: 'qm-swatch-fill', + class: 'me-swatch-fill', style: `background-image:url("${card.bg}")`, }); btn.append(fill); @@ -379,37 +379,37 @@ function buildSwatchButton(card, index, onPick) { function buildColorControl(block, cards, onSelect) { const control = createTag('button', { type: 'button', - class: 'qm-control qm-control--colour', + class: 'me-control me-control--colour', 'aria-expanded': 'false', }); - const swatch = createTag('span', { class: 'qm-swatch' }); + 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: 'qm-control-label' }, [ + const label = createTag('span', { class: 'me-control-label' }, [ 'Background', - createTag('span', { class: 'qm-control-label-suffix' }, [' colour']), + createTag('span', { class: 'me-control-label-suffix' }, [' colour']), ]); control.append(swatch, label); const panel = createTag('div', { - class: 'qm-row qm-row--colour', + 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: 'qm-sheet-grid qm-sheet-grid--colour', + class: 'me-sheet-grid me-sheet-grid--colour', role: 'listbox', 'aria-label': 'Background colour', }); function selectSwatch(bg) { - block.style.setProperty('--qm-card-bg', `url("${bg}")`); + block.style.setProperty('--me-card-bg', `url("${bg}")`); swatch.style.backgroundImage = `url("${bg}")`; [panel, sheetGrid].forEach((container) => { - container.querySelectorAll('.qm-swatch-btn').forEach((s) => { + 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)); @@ -429,8 +429,8 @@ function buildColorControl(block, cards, onSelect) { if (cards[0]) swatch.style.backgroundImage = `url("${cards[0].bg}")`; control.addEventListener('click', () => { - const isOpen = block.getAttribute('data-qm-panel') === 'colour'; - block.setAttribute('data-qm-panel', isOpen ? 'none' : 'colour'); + const isOpen = block.getAttribute('data-me-panel') === 'colour'; + block.setAttribute('data-me-panel', isOpen ? 'none' : 'colour'); control.setAttribute('aria-expanded', String(!isOpen)); }); @@ -442,22 +442,22 @@ function buildColorControl(block, cards, onSelect) { /** * 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-qm-panel` + * 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(block, kind, title, contentEl) { - const overlay = createTag('div', { class: 'qm-sheet-overlay', 'aria-hidden': 'true', inert: '' }); + const overlay = createTag('div', { class: 'me-sheet-overlay', 'aria-hidden': 'true', inert: '' }); const sheet = createTag('div', { - class: 'qm-sheet', + class: 'me-sheet', role: 'dialog', 'aria-modal': 'true', 'aria-label': title, tabindex: '-1', }); - const handle = createTag('div', { class: 'qm-sheet-handle', 'aria-hidden': 'true' }); - const titleEl = createTag('p', { class: 'qm-sheet-title' }); + 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); @@ -467,12 +467,12 @@ function buildBottomSheet(block, kind, title, contentEl) { let previouslyFocused = null; function close() { - if (block.getAttribute('data-qm-panel') !== kind) return; - block.setAttribute('data-qm-panel', 'none'); + if (block.getAttribute('data-me-panel') !== kind) return; + block.setAttribute('data-me-panel', 'none'); } function onPanelChange() { - const isOpen = block.getAttribute('data-qm-panel') === kind; + const isOpen = block.getAttribute('data-me-panel') === kind; overlay.classList.toggle('is-open', isOpen); overlay.setAttribute('aria-hidden', String(!isOpen)); if (isOpen) { @@ -500,25 +500,25 @@ function buildBottomSheet(block, kind, title, contentEl) { } function buildWidget(block, cardSet, fontOptions) { - const widget = createTag('div', { class: 'quote-maker-widget' }); - const card = createTag('div', { class: 'qm-card' }); + const widget = createTag('div', { class: 'mini-editor-widget' }); + const card = createTag('div', { class: 'me-card' }); const quoteWrap = createTag('div', { - class: 'qm-quote-wrap', + class: 'me-quote-wrap', role: 'button', tabindex: '0', 'aria-label': 'Copy quote to clipboard', }); - const quoteEl = createTag('p', { class: 'qm-quote' }); + const quoteEl = createTag('p', { class: 'me-quote' }); const first = cardSet[0] || { quote: '', author: '' }; quoteEl.textContent = first.quote; - const tip = createTag('span', { class: 'qm-tip', 'aria-hidden': 'true' }, [ - createTag('span', { class: 'qm-tip-box' }, ['Click to copy quote']), + const tip = createTag('span', { class: 'me-tip', 'aria-hidden': 'true' }, [ + createTag('span', { class: 'me-tip-box' }, ['Click to copy quote']), ]); quoteWrap.append(quoteEl, tip); - const authorEl = createTag('p', { class: 'qm-author' }); + const authorEl = createTag('p', { class: 'me-author' }); authorEl.textContent = first.author; authorEl.style.display = first.author ? '' : 'none'; @@ -541,16 +541,16 @@ function buildWidget(block, cardSet, fontOptions) { }); if (first.card) { - block.style.setProperty('--qm-card-bg', `url("${first.card.bg}")`); + block.style.setProperty('--me-card-bg', `url("${first.card.bg}")`); } // Set by init() 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 .qm-card (which the CSS vars above + // 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: 'qm-controls' }); + const controls = createTag('div', { class: 'me-controls' }); const { control: fontControl, panel: fontPanel, @@ -569,7 +569,7 @@ function buildWidget(block, cardSet, fontOptions) { ); controls.append(fontControl, colourControl); - const panelWrap = createTag('div', { class: 'qm-panel' }); + const panelWrap = createTag('div', { class: 'me-panel' }); panelWrap.append(fontPanel, colourPanel); const fontSheet = buildBottomSheet(block, 'fonts', 'Choose a font style', fontSheetGrid); @@ -577,21 +577,21 @@ function buildWidget(block, cardSet, fontOptions) { widget.append(controls, panelWrap, fontSheet.overlay, colourSheet.overlay); - // Single MutationObserver on data-qm-panel drives both sheets (mobile) and + // 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-qm-panel='fonts']`). + // inline row's own visibility is pure CSS (`[data-me-panel='fonts']`). const panelObserver = new MutationObserver(() => { - const openPanel = block.getAttribute('data-qm-panel'); + const openPanel = block.getAttribute('data-me-panel'); fontControl.setAttribute('aria-expanded', String(openPanel === 'fonts')); colourControl.setAttribute('aria-expanded', String(openPanel === 'colour')); fontSheet.onPanelChange(); colourSheet.onPanelChange(); }); - panelObserver.observe(block, { attributes: true, attributeFilter: ['data-qm-panel'] }); + panelObserver.observe(block, { attributes: true, attributeFilter: ['data-me-panel'] }); document.addEventListener('click', (e) => { if (!widget.contains(e.target)) { - block.setAttribute('data-qm-panel', 'none'); + block.setAttribute('data-me-panel', 'none'); } }); @@ -612,31 +612,31 @@ function buildWidget(block, cardSet, fontOptions) { function buildDecoCard(entry, useQuote) { const { card, quote, author } = entry; - const deco = createTag('div', { class: 'qm-deco', tabindex: '-1' }); - const cardWrap = createTag('div', { class: 'qm-deco-card-wrap' }); + const deco = createTag('div', { class: 'me-deco', tabindex: '-1' }); + const cardWrap = createTag('div', { class: 'me-deco-card-wrap' }); const inner = createTag('div', { - class: 'qm-deco-card', + class: 'me-deco-card', style: `background-image:url("${card.bg}")`, }); - // Fixed font/style for every card — see .qm-deco-quote in CSS — so no + // 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. - const quoteP = createTag('p', { class: 'qm-deco-quote' }); + const quoteP = createTag('p', { class: 'me-deco-quote' }); quoteP.textContent = quote; inner.append(quoteP); if (author) { - const authorP = createTag('p', { class: 'qm-deco-author' }); + const authorP = createTag('p', { class: 'me-deco-author' }); authorP.textContent = author; inner.append(authorP); } - const actions = createTag('div', { class: 'qm-deco-actions' }); - const useBtn = createTag('button', { type: 'button', class: 'qm-deco-use' }); + const actions = createTag('div', { class: 'me-deco-actions' }); + const useBtn = createTag('button', { type: 'button', class: 'me-deco-use' }); useBtn.textContent = 'Use this quote'; useBtn.addEventListener('click', () => useQuote(entry)); const copyBtn = createTag('button', { type: 'button', - class: 'qm-deco-copy', + class: 'me-deco-copy', 'aria-label': 'Copy quote', }, [getIconElementDeprecated('copy-quote')]); copyBtn.addEventListener('click', async () => { @@ -654,12 +654,12 @@ function buildDecoCard(entry, useQuote) { } function buildDecoCards(cardSet, useQuote) { - const wrap = createTag('div', { class: 'quote-maker-decorations', 'aria-hidden': 'true' }); + const wrap = createTag('div', { class: 'mini-editor-decorations', 'aria-hidden': 'true' }); // cardSet[0] powers the main widget; decorative cards use the rest. const decoEntries = cardSet.slice(1, 1 + DECO_CARD_COUNT); decoEntries.forEach((entry, i) => { const deco = buildDecoCard(entry, useQuote); - deco.classList.add(`qm-deco--${i + 1}`); + deco.classList.add(`me-deco--${i + 1}`); wrap.append(deco); }); return wrap; @@ -670,20 +670,20 @@ function buildDecoCards(cardSet, useQuote) { * 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 .qm-arc-card actually animate + * 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 = ['qm-arc-card--prev', 'qm-arc-card--center', 'qm-arc-card--next', 'qm-arc-card--stage-prev', 'qm-arc-card--stage-next']; +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: 'qm-arc-card', + class: 'me-arc-card', role: 'option', 'aria-selected': 'false', tabindex: '-1', }); - const quoteP = createTag('p', { class: 'qm-arc-quote' }); - const authorP = createTag('p', { class: 'qm-arc-author' }); + 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)); @@ -717,14 +717,14 @@ function buildArcCard(onActivate) { function stageAt(stageRole) { el.style.transition = 'none'; el.classList.remove(...ROLE_CLASSES); - el.classList.add(`qm-arc-card--${stageRole}`); + 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(`qm-arc-card--${role}`); + el.classList.add(`me-arc-card--${role}`); setInteractivity(role); } @@ -745,9 +745,9 @@ function buildArcCard(onActivate) { * the 3 clickable/tabbable cards. */ function buildArcGhost() { - const el = createTag('div', { class: 'qm-arc-card qm-arc-ghost', 'aria-hidden': 'true' }); - const quoteP = createTag('p', { class: 'qm-arc-quote' }); - const authorP = createTag('p', { class: 'qm-arc-author' }); + 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) { @@ -763,13 +763,13 @@ function buildArcGhost() { authorP.style.display = entry.author ? '' : 'none'; el.style.transition = 'none'; - el.classList.remove('qm-arc-card--exit-prev', 'qm-arc-card--exit-next', 'qm-arc-ghost--visible'); - el.classList.add(`qm-arc-card--${fromRole}`, 'qm-arc-ghost--visible'); + 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(`qm-arc-card--${fromRole}`); - el.classList.add(`qm-arc-card--exit-${fromRole}`); + el.classList.remove(`me-arc-card--${fromRole}`); + el.classList.add(`me-arc-card--exit-${fromRole}`); })); } @@ -782,14 +782,14 @@ function buildArcGhost() { * *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 - * .qm-arc-card, not a JS-animated or instantly-popped content swap. Only + * .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) { - const root = createTag('div', { class: 'qm-arc' }); + const root = createTag('div', { class: 'me-arc' }); const total = cardSet.length; let activeIndex = 0; // The centre card can be patched independently of cardSet (e.g. the @@ -807,8 +807,8 @@ function buildArcCarousel(cardSet, useQuote) { const withFont = (entry) => (selectedFont ? { ...entry, font: selectedFont } : entry); const onActivate = (el) => { - if (el.classList.contains('qm-arc-card--prev')) goPrev(); // eslint-disable-line no-use-before-define - else if (el.classList.contains('qm-arc-card--next')) goNext(); // eslint-disable-line no-use-before-define + 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); @@ -886,7 +886,7 @@ function buildArcCarousel(cardSet, useQuote) { // 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 .qm-card. A font patch + // 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. @@ -912,12 +912,12 @@ function buildArcCarousel(cardSet, useQuote) { const prevBtn = createTag('button', { type: 'button', - class: 'qm-arc-nav qm-arc-nav--prev', + class: 'me-arc-nav me-arc-nav--prev', 'aria-label': 'Previous template', }, [getIconElementDeprecated('arc-nav-left')]); const nextBtn = createTag('button', { type: 'button', - class: 'qm-arc-nav qm-arc-nav--next', + class: 'me-arc-nav me-arc-nav--next', 'aria-label': 'Next template', }, [getIconElementDeprecated('arc-nav-right')]); root.append(prevBtn, nextBtn); @@ -933,7 +933,7 @@ export default async function init(block) { const props = constructProps(block); block.innerHTML = ''; - block.setAttribute('data-qm-panel', 'none'); + block.setAttribute('data-me-panel', 'none'); const header = buildContentHeader(props); block.append(header); @@ -956,7 +956,7 @@ export default async function init(block) { // set of quote/background/font combinations as the desktop zig-zag. const arcCardSet = [cardSet[0], ...cardSet.slice(1, 1 + DECO_CARD_COUNT)]; - const stage = createTag('div', { class: 'quote-maker-stage' }); + const stage = createTag('div', { class: 'mini-editor-stage' }); const { widget, useQuote, onFontOrColourChange } = buildWidget(block, cardSet, fontOptions); const decorations = buildDecoCards(cardSet, useQuote); const { root: arcCarousel, updateCentre } = buildArcCarousel(arcCardSet, useQuote); @@ -966,10 +966,10 @@ export default async function init(block) { // bottom edge, per the Figma reference, without extending past it. header.append(decorations); // The arc carousel is inserted inside the widget, in the same flow slot - // as .qm-card (which .qm-carousel-mode hides), rather than as a sibling + // 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 .qm-card's place. - widget.querySelector('.qm-card').after(arcCarousel); + // squeeze both side by side instead of the arc taking .me-card's place. + widget.querySelector('.me-card').after(arcCarousel); stage.append(widget); block.append(stage); @@ -990,25 +990,25 @@ export default async function init(block) { return size <= TABLET_BREAKPOINT; }; const syncViewportMode = () => { - block.classList.toggle('qm-carousel-mode', isSmallViewport()); + block.classList.toggle('me-carousel-mode', isSmallViewport()); }; syncViewportMode(); window.addEventListener('resize', syncViewportMode); // "Create a design" buttons on the page's collapsible-rows quotes (see - // collapsible-rows.js) dispatch this instead of importing quote-maker + // collapsible-rows.js) dispatch this instead of importing mini-editor // directly, since the two blocks are otherwise unrelated. Scrolls the // editor into view and swaps in the copied quote/author, on whichever // of the desktop widget or the tablet/mobile carousel is active. - document.addEventListener('quote-maker:use-quote', (e) => { + document.addEventListener('mini-editor:use-quote', (e) => { const { quote, author } = e.detail; useQuote({ quote, author }); updateCentre({ quote, author }); block.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); } catch (error) { - window.lana?.log(`Error in quote-maker: ${error?.message || error}`, { - tags: 'quote-maker', + window.lana?.log(`Error in mini-editor: ${error?.message || error}`, { + tags: 'mini-editor', severity: 'error', }); block.closest('.section')?.remove(); diff --git a/express/code/scripts/utils/copy-toast.js b/express/code/scripts/utils/copy-toast.js index 682a67d6c..2f6c9dfaf 100644 --- a/express/code/scripts/utils/copy-toast.js +++ b/express/code/scripts/utils/copy-toast.js @@ -10,7 +10,7 @@ let container; * 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 (quote-maker, collapsible-rows). + * across unrelated blocks (mini-editor, collapsible-rows). */ export default async function showCopyToast(message) { if (!createTag) { From 740713853e26752eb25228dcfdc8146e49873c76 Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Tue, 11 Aug 2026 13:01:28 +0530 Subject: [PATCH 05/21] Move mini editor to a widget --- .../code/blocks/mini-editor/mini-editor.css | 847 +----------------- .../code/blocks/mini-editor/mini-editor.js | 745 +-------------- .../mini-editor-widget/mini-editor-widget.css | 845 +++++++++++++++++ .../mini-editor-widget/mini-editor-widget.js | 821 +++++++++++++++++ .../mini-editor-widget/mini-editor-widget.md | 93 ++ 5 files changed, 1790 insertions(+), 1561 deletions(-) create mode 100644 express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css create mode 100644 express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js create mode 100644 express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.md diff --git a/express/code/blocks/mini-editor/mini-editor.css b/express/code/blocks/mini-editor/mini-editor.css index f8f51625c..6212c85df 100644 --- a/express/code/blocks/mini-editor/mini-editor.css +++ b/express/code/blocks/mini-editor/mini-editor.css @@ -7,8 +7,8 @@ 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 below and the mobile media - query for the per-breakpoint overrides. */ + .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: rotation pivots from @@ -103,820 +103,11 @@ line-height: 125%; } -.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: 16px; - 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; -} - -.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%); -} - -.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: 20px; - line-height: 26px; - text-align: center; - word-break: break-word; - color: #131313; -} - -.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: var(--spacing-200); - 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: 16px; - text-align: center; - white-space: nowrap; - color: #505050; -} - -.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: 4px 10px; - border-radius: 7px; - background: #292929; - color: #fff; - font-size: 12px; - line-height: 16px; - 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 { - 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, #f3f3f3); - border: 1px solid rgb(0 0 0 / 3%); - border-radius: 16px; - 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(--color-gray-300-variant, #dadada); -} - -.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: #fff; - border-radius: 10px; - box-shadow: 0 1px 2px rgb(0 0 0 / 4%); - font-size: 16px; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.me-swatch { - align-self: center; - width: 34px; - height: 34px; - flex-shrink: 0; - border-radius: 10px; - border: 1px solid rgb(255 255 255 / 85%); - background-size: cover; - background-position: center; - box-shadow: 0 1px 4px rgb(0 0 0 / 4%); -} - -.me-control-label { - flex-shrink: 0; - font-weight: var(--heading-font-weight-medium, 700); - font-size: 14px; - color: #292929; - 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, #f3f3f3); - border: 1px solid rgb(0 0 0 / 3%); - border-radius: 16px; - 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 { - 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, 6px); - font-size: 16px; - white-space: nowrap; - cursor: pointer; -} - -.me-font.is-selected { - background: #fff; - border-radius: 10px; - box-shadow: 0 1px 2px rgb(0 0 0 / 4%); -} - -.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: 999px; - border: 1px solid rgb(255 255 255 / 10%); - background: none; - cursor: pointer; - box-shadow: 0 1px 4px rgb(0 0 0 / 4%); - transition: transform 0.12s ease; -} - -.me-swatch-btn:hover { - transform: scale(1.08); -} - -.me-swatch-btn.is-selected { - border: 2px solid #3b63fb; - box-shadow: 0 2px 6px rgb(0 0 0 / 4%), 0 4px 12px rgb(0 0 0 / 8%); -} - -.me-swatch-fill { - position: absolute; - inset: 0; - border-radius: 999px; - 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; -} - -.me-deco { - position: absolute; - width: 180px; - pointer-events: auto; -} - -.me-deco-card-wrap { - /* Positioned so .me-deco-actions's `top: 100%` resolves against the card - itself (not the ancestor). The padding-bottom extends this wrap's own - hoverable box down through the gap and the actions row, 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. */ - position: relative; - padding-bottom: 54px; -} - -/* - * 4 slots per side, desktop only (see the >=1200px block below). Matches the - * Figma reference's two-column 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 - * holds two 180px-wide cards spaced ~230px apart vertically. Positioned - * 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. The lowest card in - * each column sits with its own bottom flush at 0 so nothing crosses below - * the editor's bottom edge. - */ -.me-deco--1 { left: calc(50% - 811px); bottom: 496px; transform: rotate(1.5deg); } -.me-deco--2 { left: calc(50% - 541px); bottom: 360px; transform: rotate(-1.5deg); } -.me-deco--3 { left: calc(50% - 811px); bottom: 160px; transform: rotate(2deg); } -.me-deco--4 { left: calc(50% - 541px); bottom: 0; transform: rotate(-2deg); } -.me-deco--5 { left: calc(50% + 631px); bottom: 496px; transform: rotate(-1.5deg); } -.me-deco--6 { left: calc(50% + 361px); bottom: 360px; transform: rotate(1.5deg); } -.me-deco--7 { left: calc(50% + 631px); bottom: 160px; transform: rotate(-2deg); } -.me-deco--8 { left: calc(50% + 361px); bottom: 0; transform: rotate(2deg); } - -.me-deco:hover, -.me-deco:focus-within { - z-index: 5; -} - -.me-deco-card { - position: relative; - width: 100%; - height: 148px; - box-sizing: border-box; - padding: 11px; - border: 0.5px solid rgb(255 255 255 / 66%); - 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%); - -webkit-user-select: none; - user-select: none; -} - -/* - * 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. Quote is the card's - * only flex item (per Figma node 0-18559) and centers itself vertically - * within the full card height, independent of the author line below — - * author is absolutely positioned near the bottom instead of sharing a - * centering group with the quote, matching that same Figma reference. - */ -/* p.me-deco-quote (not just .me-deco-quote): .mini-editor-header p — these - cards are descendants of the header, see init()'s comment on why — is - otherwise equally or more specific and, being a

, matches these too. */ -p.me-deco-quote { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 4; - line-clamp: 4; - /* Fixed height (not flex: 1 / padding) — -webkit-box's intrinsic height - from -webkit-line-clamp ignores flex-shrink and reduced-by-padding - space alike, so only an explicit height reliably caps it at what - actually fits: full 126px content area when no author line needs - room, or that minus the author's own reserved space (see the - :has() override below) when one is present. */ - width: 100%; - height: 126px; - /* -webkit-box has no justify-content equivalent of its own — this centers - the (possibly fewer-than-clamped) lines of text within the box's own - height, which is what actually centers the quote in the card. */ - -webkit-box-pack: center; - margin: 0; - overflow: hidden; - color: var(--Alias-Content-Typography-Heading, #131313); - text-align: center; - font-family: var(--body-font-family); - font-size: 12px; - font-style: normal; - font-weight: 600; - line-height: 1.25; - word-break: break-word; - align-content: center; -} - -/* Reserves the author's own line height + bottom offset out of the quote's - box only when an author is actually present (see buildDecoCard — the - .me-deco-author element is omitted otherwise), so a long quote can never - clamp down into visually overlapping the absolutely-positioned author. */ -.me-deco-card:has(.me-deco-author) p.me-deco-quote { - height: 104px; -} - -p.me-deco-author { - position: absolute; - left: 50%; - bottom: 12px; - transform: translateX(-50%); - max-width: calc(100% - 22px); - overflow: hidden; - margin: 0; - color: var(--Alias-Content-Typography-Heading, #131313); - text-align: center; - font-family: var(--body-font-family); - font-size: 8px; - font-style: normal; - font-weight: 600; - white-space: nowrap; - text-overflow: ellipsis; -} - -.me-deco-actions { - /* Fixed px offset, not a percentage — .me-deco-card-wrap's own - padding-bottom (the hover hit-box buffer) would otherwise be counted - twice if this used `top: 100%`, since that percentage resolves against - the wrap's full padding box. 148px card height + 11px*2 padding - (border-box, so included) + 8px gap. */ - position: absolute; - top: 170px; - 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: #e9e9e9; - border: none; - border-radius: 8px; - color: #292929; - cursor: pointer; - transition: background-color 0.15s ease; -} - -.me-deco-use { - display: inline-flex; - align-items: center; - padding: 7px 12px; - font-size: 14px; - white-space: nowrap; -} - -.me-deco-copy { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - padding: 7px; -} - -.me-deco-copy img { - display: block; - width: 18px; - height: 18px; -} - -.me-deco-use:hover, -.me-deco-copy:hover { - background: #dcdcdc; -} - -.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.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); -} - -.me-carousel-mode .mini-editor-decorations { - display: none; -} - -.me-carousel-mode .me-card { - display: none; -} - -.me-carousel-mode .me-arc { - display: flex; -} - -/* 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 rgb(255 255 255 / 66%); - border-radius: 16px; - 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 is fixed at the card's own centre for every role, and - never changes between roles — only transform itself is animated. That - keeps the transition a single smooth translate+rotate interpolation - that stays clear of the centre card throughout, instead of a - corner/bottom pivot whose intermediate frames aren't guaranteed clear. */ - transform-origin: 50% 50%; - 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: translateX(calc(-1 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(-8deg); -} - -.me-arc-card--next { - transform: translateX(var(--me-arc-slide)) translateY(var(--me-arc-lift)) rotate(8deg); -} - -/* Off-screen staging position for a recycled card (see mini-editor.js - setRole's `instant` path) — parked further out than the resting - prev/next spot, same rotation, so the very next transition (turning off - `instant`) animates it rotating in from beyond the edge instead of - popping directly into its final slot. */ -.me-arc-card--stage-prev { - transform: translateX(calc(-2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(-8deg); -} - -.me-arc-card--stage-next { - transform: translateX(calc(2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(8deg); -} - -/* Ghost: a 4th, non-interactive card (see buildArcGhost in mini-editor.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: translateY(calc(-1 * var(--me-arc-lift))) translateX(calc(-2 * var(--me-arc-slide)))rotate(0deg); -} - -.me-arc-card--exit-next { - transform: translateX(calc(2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(8deg); -} - -.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: 16px; - line-height: 1.2; - text-align: center; - word-break: break-word; - color: #131313; -} - -.me-arc-author { - position: absolute; - left: 50%; - bottom: var(--spacing-200); - transform: translateX(-50%); - margin: 0; - font-size: 12px; - white-space: nowrap; - color: #505050; -} - -.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: 999px; - cursor: pointer; - -webkit-backdrop-filter: blur(8px); - backdrop-filter: blur(8px); -} - -.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: #fff; - 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, 700); - font-size: 16px; - 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) { .mini-editor { padding: var(--spacing-500) var(--spacing-300); } - /* "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; - } - /* 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 { @@ -949,17 +140,14 @@ p.me-deco-author { display: none; } - .me-quote { - font-size: 16px; - } - .mini-editor { /* 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 above), how much - of each side card actually shows follows naturally from the smaller - mobile viewport, not a separately tuned peek amount. --me-arc-slide - recalculated for this card size the same way as the base rule: + 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-slide recalculated for this card size the + same way as the base rule: 327/2*cos(8)+270/2*sin(8) + 327/2 ≈ 344px; +10px safety margin. --me-arc-lift/--me-arc-extra-h likewise recalculated for 327x270. Defined on .mini-editor (not .me-arc) so .mini-editor-widget can @@ -971,25 +159,4 @@ p.me-deco-author { --me-arc-extra-h: 50px; --me-arc-gap: var(--spacing-100); } - - .me-arc-quote { - font-size: 14px; - } - - .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/blocks/mini-editor/mini-editor.js b/express/code/blocks/mini-editor/mini-editor.js index aed418767..882270cf5 100644 --- a/express/code/blocks/mini-editor/mini-editor.js +++ b/express/code/blocks/mini-editor/mini-editor.js @@ -11,12 +11,14 @@ import { restoreBackgroundScroll, } from '../../scripts/color-shared/spectrum/utils/a11y.js'; import showCopyToast from '../../scripts/utils/copy-toast.js'; +import createMiniEditorWidget from '../../scripts/widgets/mini-editor-widget/mini-editor-widget.js'; let createTag; +let loadStyle; +let getConfig; const TEMPLATE_LIMIT = 8; const DECO_CARD_COUNT = 8; -const TABLET_BREAKPOINT = 1199; // Fallback used only if the Typekit kit fails to load or exposes no fonts // (network failure, ad blocker, or API shape change) — see buildFontOptions. @@ -109,7 +111,7 @@ function buildFontOptions() { * 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 mini-editor's own. + * 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; @@ -262,678 +264,12 @@ function buildContentHeader(props) { return header; } -/** - * Builds one font-option button. Used to populate both the tablet/desktop - * inline row and the mobile bottom-sheet grid from the same FONT_OPTIONS - * 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(block, fontOptions, onSelect) { - 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) { - block.style.setProperty('--me-quote-font', opt.font); - block.style.setProperty('--me-quote-font-style', opt.italic ? 'italic' : 'normal'); - block.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 = block.getAttribute('data-me-panel') === 'fonts'; - block.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(block, cards, onSelect) { - 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) { - block.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 = block.getAttribute('data-me-panel') === 'colour'; - block.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(block, kind, title, contentEl) { - 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 (block.getAttribute('data-me-panel') !== kind) return; - block.setAttribute('data-me-panel', 'none'); - } - - function onPanelChange() { - const isOpen = block.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 }; -} - -function buildWidget(block, cardSet, fontOptions) { - const widget = createTag('div', { class: 'mini-editor-widget' }); - const card = createTag('div', { class: 'me-card' }); - - const quoteWrap = createTag('div', { - class: 'me-quote-wrap', - role: 'button', - tabindex: '0', - 'aria-label': 'Copy quote to clipboard', - }); - const quoteEl = createTag('p', { class: 'me-quote' }); - const first = cardSet[0] || { quote: '', author: '' }; - quoteEl.textContent = first.quote; - - const tip = createTag('span', { class: 'me-tip', 'aria-hidden': 'true' }, [ - createTag('span', { class: 'me-tip-box' }, ['Click to copy quote']), - ]); - quoteWrap.append(quoteEl, tip); - - const authorEl = createTag('p', { class: 'me-author' }); - authorEl.textContent = first.author; - authorEl.style.display = first.author ? '' : 'none'; - - card.append(quoteWrap, authorEl); - widget.append(card); - - const doCopy = async () => { - const ok = await copyQuoteToClipboard(quoteEl.textContent, 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) { - block.style.setProperty('--me-card-bg', `url("${first.card.bg}")`); - } - - // Set by init() 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(block, fontOptions, (font) => onFontOrColourPick({ font })); - const { - control: colourControl, - panel: colourPanel, - sheetGrid: colourSheetGrid, - selectSwatch, - } = buildColorControl( - block, - cardSet.map((c) => c.card), - (bgCard) => onFontOrColourPick({ card: bgCard }), - ); - controls.append(fontControl, colourControl); - - const panelWrap = createTag('div', { class: 'me-panel' }); - panelWrap.append(fontPanel, colourPanel); - - const fontSheet = buildBottomSheet(block, 'fonts', 'Choose a font style', fontSheetGrid); - const colourSheet = buildBottomSheet(block, '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 = block.getAttribute('data-me-panel'); - fontControl.setAttribute('aria-expanded', String(openPanel === 'fonts')); - colourControl.setAttribute('aria-expanded', String(openPanel === 'colour')); - fontSheet.onPanelChange(); - colourSheet.onPanelChange(); - }); - panelObserver.observe(block, { attributes: true, attributeFilter: ['data-me-panel'] }); - - document.addEventListener('click', (e) => { - if (!widget.contains(e.target)) { - block.setAttribute('data-me-panel', 'none'); - } - }); - - return { - widget, - useQuote: ({ - quote, author, card: bgCard, font, - }) => { - quoteEl.textContent = quote; - authorEl.textContent = author || ''; - authorEl.style.display = author ? '' : 'none'; - if (bgCard) selectSwatch(bgCard.bg); - if (font) selectFont(font); - }, - onFontOrColourChange: (listener) => { onFontOrColourPick = listener; }, - }; -} - -function buildDecoCard(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' }); - const inner = createTag('div', { - class: 'me-deco-card', - style: `background-image:url("${card.bg}")`, - }); - // 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. - const quoteP = createTag('p', { class: 'me-deco-quote' }); - quoteP.textContent = quote; - inner.append(quoteP); - if (author) { - const authorP = createTag('p', { class: 'me-deco-author' }); - authorP.textContent = author; - inner.append(authorP); - } - - const actions = createTag('div', { class: 'me-deco-actions' }); - const useBtn = createTag('button', { type: 'button', class: 'me-deco-use' }); - useBtn.textContent = 'Use this quote'; - useBtn.addEventListener('click', () => useQuote(entry)); - - const copyBtn = createTag('button', { - type: 'button', - class: 'me-deco-copy', - 'aria-label': 'Copy quote', - }, [getIconElementDeprecated('copy-quote')]); - copyBtn.addEventListener('click', async () => { - const ok = await copyQuoteToClipboard(quote, author); - if (ok) { - copyBtn.classList.add('is-copied'); - setTimeout(() => copyBtn.classList.remove('is-copied'), 1200); - } - }); - - actions.append(useBtn, copyBtn); - cardWrap.append(inner, actions); - deco.append(cardWrap); - return deco; -} - -function buildDecoCards(cardSet, useQuote) { - const wrap = createTag('div', { class: 'mini-editor-decorations', 'aria-hidden': 'true' }); - // cardSet[0] powers the main widget; decorative cards use the rest. - const decoEntries = cardSet.slice(1, 1 + DECO_CARD_COUNT); - decoEntries.forEach((entry, i) => { - const deco = buildDecoCard(entry, useQuote); - deco.classList.add(`me-deco--${i + 1}`); - wrap.append(deco); - }); - 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}")`; - quoteP.textContent = 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}")`; - quoteP.textContent = entry.quote; - // 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) { - const root = createTag('div', { class: 'me-arc' }); - 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. - let selectedFont = 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(); - root.append(ghost.el, cardA.el, cardB.el, cardC.el); - - 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 }; -} - export default async function init(block) { - ({ createTag } = await import(`${getLibs()}/utils/utils.js`)); + ({ createTag, loadStyle, getConfig } = await import(`${getLibs()}/utils/utils.js`)); + loadStyle(`${getConfig().codeRoot}/scripts/widgets/mini-editor-widget/mini-editor-widget.css`); const props = constructProps(block); block.innerHTML = ''; - block.setAttribute('data-me-panel', 'none'); const header = buildContentHeader(props); block.append(header); @@ -951,61 +287,28 @@ export default async function init(block) { // families the Typekit kit actually loaded rather than a guessed list. const fontOptions = buildFontOptions(); const cardSet = buildCardSet(cards, quotes); - // Same 9 entries (the widget's own + the 8 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 + DECO_CARD_COUNT)]; - const stage = createTag('div', { class: 'mini-editor-stage' }); - const { widget, useQuote, onFontOrColourChange } = buildWidget(block, cardSet, fontOptions); - const decorations = buildDecoCards(cardSet, useQuote); - const { root: arcCarousel, updateCentre } = buildArcCarousel(arcCardSet, useQuote); - onFontOrColourChange(updateCentre); + const editor = await createMiniEditorWidget({ + root: block, + // No top action bar in the current design — reserved by the widget API. + topActions: [], + fontOptions, + backgrounds: { cardSet, decoCount: DECO_CARD_COUNT }, + a11y: { + trapFocus, + handleEscapeClose, + disableBackgroundScroll, + restoreBackgroundScroll, + copyQuoteToClipboard, + }, + deps: { createTag, getIconElementDeprecated }, + }); + // 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(decorations); - // 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); - stage.append(widget); - block.append(stage); - - // 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 isTouchDevice = window.matchMedia('(pointer: coarse)').matches; - const isSmallViewport = () => { - const size = isTouchDevice - ? Math.min(window.innerWidth, window.innerHeight) - : window.innerWidth; - return size <= TABLET_BREAKPOINT; - }; - const syncViewportMode = () => { - block.classList.toggle('me-carousel-mode', isSmallViewport()); - }; - syncViewportMode(); - window.addEventListener('resize', syncViewportMode); - - // "Create a design" buttons on the page's collapsible-rows quotes (see - // collapsible-rows.js) dispatch this instead of importing mini-editor - // directly, since the two blocks are otherwise unrelated. Scrolls the - // editor into view and swaps in the copied quote/author, on whichever - // of the desktop widget or the tablet/mobile carousel is active. - document.addEventListener('mini-editor:use-quote', (e) => { - const { quote, author } = e.detail; - useQuote({ quote, author }); - updateCentre({ quote, author }); - block.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); + header.append(editor.decorations); + block.append(editor.stage); } catch (error) { window.lana?.log(`Error in mini-editor: ${error?.message || error}`, { tags: 'mini-editor', 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..9e79d9408 --- /dev/null +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css @@ -0,0 +1,845 @@ +/* ============================================================================ + 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). Layout tokens (--me-*), 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. + ============================================================================ */ + +.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: 16px; + 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; +} + +.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%); +} + +.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: 20px; + line-height: 26px; + text-align: center; + word-break: break-word; + color: #131313; +} + +.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: var(--spacing-200); + 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: 16px; + text-align: center; + white-space: nowrap; + color: #505050; +} + +.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: 4px 10px; + border-radius: 7px; + background: #292929; + color: #fff; + font-size: 12px; + line-height: 16px; + 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 { + 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, #f3f3f3); + border: 1px solid rgb(0 0 0 / 3%); + border-radius: 16px; + 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(--color-gray-300-variant, #dadada); +} + +.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: #fff; + border-radius: 10px; + box-shadow: 0 1px 2px rgb(0 0 0 / 4%); + font-size: 16px; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.me-swatch { + align-self: center; + width: 34px; + height: 34px; + flex-shrink: 0; + border-radius: 10px; + border: 1px solid rgb(255 255 255 / 85%); + background-size: cover; + background-position: center; + box-shadow: 0 1px 4px rgb(0 0 0 / 4%); +} + +.me-control-label { + flex-shrink: 0; + font-weight: var(--heading-font-weight-medium, 700); + font-size: 14px; + color: #292929; + 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, #f3f3f3); + border: 1px solid rgb(0 0 0 / 3%); + border-radius: 16px; + 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 { + 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, 6px); + font-size: 16px; + white-space: nowrap; + cursor: pointer; +} + +.me-font.is-selected { + background: #fff; + border-radius: 10px; + box-shadow: 0 1px 2px rgb(0 0 0 / 4%); +} + +.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: 999px; + border: 1px solid rgb(255 255 255 / 10%); + background: none; + cursor: pointer; + box-shadow: 0 1px 4px rgb(0 0 0 / 4%); + transition: transform 0.12s ease; +} + +.me-swatch-btn:hover { + transform: scale(1.08); +} + +.me-swatch-btn.is-selected { + border: 2px solid #3b63fb; + box-shadow: 0 2px 6px rgb(0 0 0 / 4%), 0 4px 12px rgb(0 0 0 / 8%); +} + +.me-swatch-fill { + position: absolute; + inset: 0; + border-radius: 999px; + 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; +} + +.me-deco { + position: absolute; + width: 180px; + pointer-events: auto; +} + +.me-deco-card-wrap { + /* Positioned so .me-deco-actions's `top: 100%` resolves against the card + itself (not the ancestor). The padding-bottom extends this wrap's own + hoverable box down through the gap and the actions row, 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. */ + position: relative; + padding-bottom: 54px; +} + +/* + * 4 slots per side, desktop only (see the >=1200px block below). Matches the + * Figma reference's two-column 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 + * holds two 180px-wide cards spaced ~230px apart vertically. Positioned + * 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. The lowest card in + * each column sits with its own bottom flush at 0 so nothing crosses below + * the editor's bottom edge. + */ +.me-deco--1 { left: calc(50% - 811px); bottom: 496px; transform: rotate(1.5deg); } +.me-deco--2 { left: calc(50% - 541px); bottom: 360px; transform: rotate(-1.5deg); } +.me-deco--3 { left: calc(50% - 811px); bottom: 160px; transform: rotate(2deg); } +.me-deco--4 { left: calc(50% - 541px); bottom: 0; transform: rotate(-2deg); } +.me-deco--5 { left: calc(50% + 631px); bottom: 496px; transform: rotate(-1.5deg); } +.me-deco--6 { left: calc(50% + 361px); bottom: 360px; transform: rotate(1.5deg); } +.me-deco--7 { left: calc(50% + 631px); bottom: 160px; transform: rotate(-2deg); } +.me-deco--8 { left: calc(50% + 361px); bottom: 0; transform: rotate(2deg); } + +.me-deco:hover, +.me-deco:focus-within { + z-index: 5; +} + +.me-deco-card { + position: relative; + width: 100%; + height: 148px; + box-sizing: border-box; + padding: 11px; + border: 0.5px solid rgb(255 255 255 / 66%); + 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%); + -webkit-user-select: none; + user-select: none; +} + +/* + * 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. Quote is the card's + * only flex item (per Figma node 0-18559) and centers itself vertically + * within the full card height, independent of the author line below — + * author is absolutely positioned near the bottom instead of sharing a + * centering group with the quote, matching that same Figma reference. + */ +/* 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 { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + line-clamp: 4; + /* Fixed height (not flex: 1 / padding) — -webkit-box's intrinsic height + from -webkit-line-clamp ignores flex-shrink and reduced-by-padding + space alike, so only an explicit height reliably caps it at what + actually fits: full 126px content area when no author line needs + room, or that minus the author's own reserved space (see the + :has() override below) when one is present. */ + width: 100%; + height: 126px; + /* -webkit-box has no justify-content equivalent of its own — this centers + the (possibly fewer-than-clamped) lines of text within the box's own + height, which is what actually centers the quote in the card. */ + -webkit-box-pack: center; + margin: 0; + overflow: hidden; + color: var(--Alias-Content-Typography-Heading, #131313); + text-align: center; + font-family: var(--body-font-family); + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 1.25; + word-break: break-word; + align-content: center; +} + +/* Reserves the author's own line height + bottom offset out of the quote's + box only when an author is actually present (see buildDecoCard — the + .me-deco-author element is omitted otherwise), so a long quote can never + clamp down into visually overlapping the absolutely-positioned author. */ +.me-deco-card:has(.me-deco-author) p.me-deco-quote { + height: 104px; +} + +p.me-deco-author { + position: absolute; + left: 50%; + bottom: 12px; + transform: translateX(-50%); + max-width: calc(100% - 22px); + overflow: hidden; + margin: 0; + color: var(--Alias-Content-Typography-Heading, #131313); + text-align: center; + font-family: var(--body-font-family); + font-size: 8px; + font-style: normal; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; +} + +.me-deco-actions { + /* Fixed px offset, not a percentage — .me-deco-card-wrap's own + padding-bottom (the hover hit-box buffer) would otherwise be counted + twice if this used `top: 100%`, since that percentage resolves against + the wrap's full padding box. 148px card height + 11px*2 padding + (border-box, so included) + 8px gap. */ + position: absolute; + top: 170px; + 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: #e9e9e9; + border: none; + border-radius: 8px; + color: #292929; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.me-deco-use { + display: inline-flex; + align-items: center; + padding: 7px 12px; + font-size: 14px; + white-space: nowrap; +} + +.me-deco-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + padding: 7px; +} + +.me-deco-copy img { + display: block; + width: 18px; + height: 18px; +} + +.me-deco-use:hover, +.me-deco-copy:hover { + background: #dcdcdc; +} + +.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); +} + +.me-carousel-mode .mini-editor-decorations { + display: none; +} + +.me-carousel-mode .me-card { + display: none; +} + +.me-carousel-mode .me-arc { + display: flex; +} + +/* 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 rgb(255 255 255 / 66%); + border-radius: 16px; + 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 is fixed at the card's own centre for every role, and + never changes between roles — only transform itself is animated. That + keeps the transition a single smooth translate+rotate interpolation + that stays clear of the centre card throughout, instead of a + corner/bottom pivot whose intermediate frames aren't guaranteed clear. */ + transform-origin: 50% 50%; + 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: translateX(calc(-1 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(-8deg); +} + +.me-arc-card--next { + transform: translateX(var(--me-arc-slide)) translateY(var(--me-arc-lift)) 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, so the very next transition (turning off + `instant`) animates it rotating in from beyond the edge instead of + popping directly into its final slot. */ +.me-arc-card--stage-prev { + transform: translateX(calc(-2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(-8deg); +} + +.me-arc-card--stage-next { + transform: translateX(calc(2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(8deg); +} + +/* 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: translateY(calc(-1 * var(--me-arc-lift))) translateX(calc(-2 * var(--me-arc-slide)))rotate(0deg); +} + +.me-arc-card--exit-next { + transform: translateX(calc(2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(8deg); +} + +.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: 16px; + line-height: 1.2; + text-align: center; + word-break: break-word; + color: #131313; +} + +.me-arc-author { + position: absolute; + left: 50%; + bottom: var(--spacing-200); + transform: translateX(-50%); + margin: 0; + font-size: 12px; + white-space: nowrap; + color: #505050; +} + +.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: 999px; + cursor: pointer; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); +} + +.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: #fff; + 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, 700); + font-size: 16px; + 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: 16px; + } + + .me-arc-quote { + font-size: 14px; + } + + .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..6c96b875c --- /dev/null +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js @@ -0,0 +1,821 @@ +/** + * 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, animation, and the cross-block "use this quote" + * event wiring, so a block only has to fetch data and mount the result. + * + * UI/UX is intentionally identical to the original in-block implementation — + * this widget is an extraction of that surface, not a redesign. It does NOT + * depend on Spectrum Web Components. + * + * 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: [], // reserved (none rendered yet) + * 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); + * + * The event listener that swaps a quote/author (and optionally background / + * font) into the editor — dispatched by collapsible-rows' "Create a design" + * button as `mini-editor:use-quote` — is registered inside the widget. + */ + +let createTag; +let getIconElementDeprecated; + +const DECO_CARD_COUNT = 8; + +/** + * 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) { + 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'; + 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) { + 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'; + 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 }; +} + +function buildWidget(root, a11y, cardSet, fontOptions) { + const widget = createTag('div', { class: 'mini-editor-widget' }); + const card = createTag('div', { class: 'me-card' }); + + const quoteWrap = createTag('div', { + class: 'me-quote-wrap', + role: 'button', + tabindex: '0', + 'aria-label': 'Copy quote to clipboard', + }); + const quoteEl = createTag('p', { class: 'me-quote' }); + const first = cardSet[0] || { quote: '', author: '' }; + quoteEl.textContent = first.quote; + + const tip = createTag('span', { class: 'me-tip', 'aria-hidden': 'true' }, [ + createTag('span', { class: 'me-tip-box' }, ['Click to copy quote']), + ]); + quoteWrap.append(quoteEl, tip); + + const authorEl = createTag('p', { class: 'me-author' }); + authorEl.textContent = first.author; + authorEl.style.display = first.author ? '' : 'none'; + + card.append(quoteWrap, authorEl); + widget.append(card); + + const doCopy = async () => { + const ok = await a11y.copyQuoteToClipboard(quoteEl.textContent, 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 })); + const { + control: colourControl, + panel: colourPanel, + sheetGrid: colourSheetGrid, + selectSwatch, + } = buildColorControl( + root, + cardSet.map((c) => c.card), + (bgCard) => onFontOrColourPick({ card: bgCard }), + ); + 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) => { + if (!widget.contains(e.target)) { + root.setAttribute('data-me-panel', 'none'); + } + }; + document.addEventListener('click', onDocClick); + + return { + widget, + useQuote: ({ + quote, author, card: bgCard, font, + }) => { + quoteEl.textContent = quote; + authorEl.textContent = author || ''; + authorEl.style.display = author ? '' : 'none'; + if (bgCard) selectSwatch(bgCard.bg); + if (font) selectFont(font); + }, + onFontOrColourChange: (listener) => { onFontOrColourPick = listener; }, + 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' }); + const inner = createTag('div', { + class: 'me-deco-card', + style: `background-image:url("${card.bg}")`, + }); + // 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. + const quoteP = createTag('p', { class: 'me-deco-quote' }); + quoteP.textContent = quote; + inner.append(quoteP); + if (author) { + const authorP = createTag('p', { class: 'me-deco-author' }); + authorP.textContent = author; + inner.append(authorP); + } + + const actions = createTag('div', { class: 'me-deco-actions' }); + const useBtn = createTag('button', { type: 'button', class: 'me-deco-use' }); + useBtn.textContent = 'Use this quote'; + useBtn.addEventListener('click', () => useQuote(entry)); + + const copyBtn = createTag('button', { + type: 'button', + class: 'me-deco-copy', + 'aria-label': 'Copy quote', + }, [getIconElementDeprecated('copy-quote')]); + 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); + cardWrap.append(inner, actions); + deco.append(cardWrap); + return deco; +} + +function buildDecoCards(a11y, cardSet, useQuote) { + const wrap = createTag('div', { class: 'mini-editor-decorations', 'aria-hidden': 'true' }); + // cardSet[0] powers the main widget; decorative cards use the rest. + const decoEntries = cardSet.slice(1, 1 + DECO_CARD_COUNT); + decoEntries.forEach((entry, i) => { + const deco = buildDecoCard(a11y, entry, useQuote); + deco.classList.add(`me-deco--${i + 1}`); + wrap.append(deco); + }); + 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}")`; + quoteP.textContent = 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}")`; + quoteP.textContent = entry.quote; + // 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) { + const root = createTag('div', { class: 'me-arc' }); + 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. + let selectedFont = 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(); + root.append(ghost.el, cardA.el, cardB.el, cardC.el); + + 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=[]] — reserved top-bar action descriptors; + * none are rendered yet (the current design has no top action bar). + * @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 }`. + * @returns {Promise<{ stage, decorations, useQuote, updateCentre, + * syncViewportMode, destroy }>} + */ +export default async function createMiniEditorWidget(config = {}) { + const { + root, + fontOptions, + backgrounds, + a11y, + deps, + } = config; + // topActions is accepted for API parity with the proposed widget (PR #680) + // but the current design has no top action bar, so it is intentionally not + // read here — callers pass []. + + ({ createTag, getIconElementDeprecated } = deps); + + const { cardSet } = backgrounds; + const decoCount = backgrounds.decoCount ?? DECO_CARD_COUNT; + + root.setAttribute('data-me-panel', 'none'); + + // 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)]; + + const stage = createTag('div', { class: 'mini-editor-stage' }); + const { + widget, useQuote, onFontOrColourChange, destroy: destroyWidget, + } = buildWidget(root, a11y, cardSet, fontOptions); + const decorations = buildDecoCards(a11y, cardSet, useQuote); + const { root: arcCarousel, updateCentre } = buildArcCarousel(arcCardSet, useQuote); + 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); + stage.append(widget); + + // 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; + }; + const syncViewportMode = () => { + root.classList.toggle('me-carousel-mode', isSmallViewport()); + }; + syncViewportMode(); + window.addEventListener('resize', syncViewportMode); + + // "Create a design" buttons on the page's collapsible-rows quotes (see + // collapsible-rows.js) dispatch this instead of importing the mini-editor + // directly, since the two blocks are otherwise unrelated. Scrolls the + // editor into view and swaps in the copied quote/author, on whichever of + // the desktop widget or the tablet/mobile carousel is active. + const onUseQuoteEvent = (e) => { + const { quote, author } = e.detail; + useQuote({ quote, author }); + updateCentre({ quote, author }); + root.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }; + document.addEventListener('mini-editor:use-quote', onUseQuoteEvent); + + return { + stage, + decorations, + useQuote, + updateCentre, + syncViewportMode, + destroy: () => { + destroyWidget(); + window.removeEventListener('resize', syncViewportMode); + document.removeEventListener('mini-editor:use-quote', onUseQuoteEvent); + }, + }; +} 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..a72505558 --- /dev/null +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.md @@ -0,0 +1,93 @@ +# 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` is accepted for API parity and is currently unused +> (the present design has no top action bar) — pass `[]`. + +## 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: [], // reserved; none rendered yet + 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` | Reserved top-bar action descriptors, mirroring PR #680. **Not rendered yet** — pass `[]`. | +| `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. | + +## 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, the `mini-editor:use-quote` + document listener) and the panel MutationObserver. + +## Events + +The widget listens on `document` for `mini-editor:use-quote` +(`{ detail: { quote, author } }`) — dispatched by the collapsible-rows block's +"Create a design" button — and swaps that quote/author into the editor, then +scrolls the root into view. The two blocks stay decoupled via this event +instead of importing one into the other. From 46ceef3fc6dad3b0c92207e8279e763f01ddac1e Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Tue, 11 Aug 2026 14:43:51 +0530 Subject: [PATCH 06/21] Move backgounds and fonts loader in separate utilities --- .../code/blocks/mini-editor/img/image1.jpg | Bin 0 -> 9123 bytes .../code/blocks/mini-editor/img/image10.jpg | Bin 0 -> 9481 bytes .../code/blocks/mini-editor/img/image2.jpg | Bin 0 -> 9037 bytes .../code/blocks/mini-editor/img/image3.jpg | Bin 0 -> 9123 bytes .../code/blocks/mini-editor/img/image4.jpg | Bin 0 -> 16141 bytes .../code/blocks/mini-editor/img/image5.jpg | Bin 0 -> 10554 bytes .../code/blocks/mini-editor/img/image6.jpg | Bin 0 -> 7768 bytes .../code/blocks/mini-editor/img/image7.jpg | Bin 0 -> 9630 bytes .../code/blocks/mini-editor/img/image8.jpg | Bin 0 -> 17965 bytes .../code/blocks/mini-editor/img/image9.jpg | Bin 0 -> 19656 bytes .../mini-editor-background-loader.js | 105 ++++++++++++++ .../mini-editor/mini-editor-fonts-loader.js | 117 +++++++++++++++ .../code/blocks/mini-editor/mini-editor.js | 136 ++---------------- 13 files changed, 234 insertions(+), 124 deletions(-) create mode 100644 express/code/blocks/mini-editor/img/image1.jpg create mode 100644 express/code/blocks/mini-editor/img/image10.jpg create mode 100644 express/code/blocks/mini-editor/img/image2.jpg create mode 100644 express/code/blocks/mini-editor/img/image3.jpg create mode 100644 express/code/blocks/mini-editor/img/image4.jpg create mode 100644 express/code/blocks/mini-editor/img/image5.jpg create mode 100644 express/code/blocks/mini-editor/img/image6.jpg create mode 100644 express/code/blocks/mini-editor/img/image7.jpg create mode 100644 express/code/blocks/mini-editor/img/image8.jpg create mode 100644 express/code/blocks/mini-editor/img/image9.jpg create mode 100644 express/code/blocks/mini-editor/mini-editor-background-loader.js create mode 100644 express/code/blocks/mini-editor/mini-editor-fonts-loader.js diff --git a/express/code/blocks/mini-editor/img/image1.jpg b/express/code/blocks/mini-editor/img/image1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8025b3df006529240b646ac09e9f1166b15076e6 GIT binary patch literal 9123 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 literal 0 HcmV?d00001 diff --git a/express/code/blocks/mini-editor/img/image10.jpg b/express/code/blocks/mini-editor/img/image10.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7b68d3024b43fb52f6d443f4c3849d15aa80f219 GIT binary patch literal 9481 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 ozLI_0IO0(yL9dLfYOxS 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 literal 0 HcmV?d00001 diff --git a/express/code/blocks/mini-editor/img/image3.jpg b/express/code/blocks/mini-editor/img/image3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8025b3df006529240b646ac09e9f1166b15076e6 GIT binary patch literal 9123 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 literal 0 HcmV?d00001 diff --git a/express/code/blocks/mini-editor/img/image4.jpg b/express/code/blocks/mini-editor/img/image4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..be9d1a01fe3b497531d025447fffa1aa37678df0 GIT binary patch literal 16141 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 V$%K3Z^m7)eEGb1%@MZ3&|3B|I!qNZ$ literal 0 HcmV?d00001 diff --git a/express/code/blocks/mini-editor/img/image5.jpg b/express/code/blocks/mini-editor/img/image5.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c24d42c47980a722b8bfce63b7193cb631324d67 GIT binary patch literal 10554 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 literal 0 HcmV?d00001 diff --git a/express/code/blocks/mini-editor/img/image6.jpg b/express/code/blocks/mini-editor/img/image6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5d103597ca6dc2e5fe1f996bd73a23f00dacd2f0 GIT binary patch literal 7768 zcmeHKc{tQVcEcD;NE^x!(J0xnld(2Qk0m99 z_f576(L;HyXV{Y&RgJ!tuK*G4D%(1NUFhA0L`Q37%=!NbiXLb1z;69Y%0pcvjAYG z6Adf|Qig7xIyH@C@2 z`g!ZgIiWBrm=I&Uub){o3GWbXeH<4ZfYb7pGtlSMjna+^3JJoKJtd=p0)xY}qx9sy zl4~>jyVJ^Yl3yX@06jVLAWt8>w)U1^vl$Z#muv5Wg&nTtfFc~HTr{q_TWB4!}$uETLM+}zS z<@EF-hLiQ=vTxWPOCL^1qw@ z4tM?&udk+}qOAG{{;%`j!N2?$;J?l@y;9zF<1cRg&s}AP<-3aim{>@P)Oxu`* z_aqZZyD`x>+RY3}2Qm>CAZZ)uNhT0ULAuKS8TG%l41@XaZ3_xg{%#%yqkW8s3lG8v zlg*FmGcAGp`FU&OP?`u;0zyNHnHHW(NEJ2GPlyfMtXqx}5_ z?k4X4efxRH4{7{UuAg%KkODsh{+V4r<@zB7ehB4=MoNB>}NA_kh_r*w{FrTzk2oKiqa60K5<&6&Pm)@d7NoAXZ+` zP6r?gFsn;eW{vu-v}C@S27*}j0PGOv>Jc6Q1P1Ne&BFQ(0RmW9d8NRrMtpXjq36=s z_zUZ$A3_JFj_z{pF7DR5-?^AWFdMMvE0;L$?$RIa8v?|_%gQIEY6Rxr83Xn)PhgID z0R!Mu=oeIPe_ZVI3vl*;W>;D@GOT8J#WZFT|AMe?UtXOk*2Fk5zpSZO9qKgDLi{b+ z?!i-&Nk>9Ezgqn?#J?}XOLhG4STiL;FnsUQRTurpa^zAA*1pR zxaCZ8+a-Kcbj?xj>S*t6TCpf#UOit%_Cr_fB zaO)+y=&*$F(5AXooqlfV%Rx=B!q#}a{`9Ct;`&e(uO&6I?o(&}lnwOb{4({z4gd|Q z-4?lvsV2#Gc@{qVO`UH^LuUuD>Oc$cx#HO3I}XmsbkUDCDEF;2d*;9CD2DXNyPY0@ z>#?*EjMzHlGO|kHF>WXs8|<<+1;*3Vudg+yWTjcd`rA{h=4248%gyChlnK<7P5+l) zc7Ox>uyzf}R? zI$8#)gY7&b)xHY0Znpy@#g9PrGz}UK|9U)@YTPuh zwoxc8FYbzRTpL@#4&F_CmZI>f`YEKklmJp_t!{%Am!}RY`JZ~!|7eQcss3uHaf&^6 zwT}z}P>Qs^0X2#N+o$5|F2vgFmoujKAb5L4uB04*G2k;T*+KKN7H&OG52r*+#xt@x zTZiznGr9dEn!Q?P&mCqjmJJZkCz|YU zAdf1Xi;l5kTT+bAezD>>m~xip*E~rt_Cxj3Dqk(%qLQN*iexx2&a+=gaN16^h2XHTe$hz(fe>v|kCg+(2_Fe(b45qdO= zu;0MCX-bY*QE3?tev8Yzz33jUV*-A4$<&_L$Wt7*D@XE>I9WOaVUOa-3e4S4?J=J| zcV$%9zEuLXR-iTGQACKgO+Ke-z=_l8*Qc98igkiG)MH3V){VHD&aFb2bEl)Em8#gi zX^27}_rliuQQ14XZO}WzuCT2`e)P%e;s>LS&xjl7Rv8OgdBJkBi%B@wGnZE@ljCy(>Q@HC^J4k?p-zRr>TmHzw1)-9$t-X~T+|s*jf{Ji*&L zkaqo(P)s8||2SO_r2J~2D|qaqQ+n@g+@nJu#V67gMr1HNfrSgk9Wj#Do>`ag-j$&W z`0%{D*&~lCWOb>T2#K3IUTZuhcqfO`kvy?N8S{*%ykOol zx){aDX@$Gv$*>aPz$MP!>B2;fap?<_mxJdgOMHq3Izlgq$y#uO*GHspQ&`%cQr zizQa?&Gyc~Thc5?Ej36DZ@|H&#-X*&iw~QpY&NPro}6O2yLz!S#6)-1Hcd+l#7af^ z@T})O%U-PI5GZB{AUpEU=p0(8pB6+Cy4EZGHCm)+FQQCVdTF(f3zppmD4kB3*v44L zJCD!Pp0{PEuyO=!=m@@a=c)F7#8z|Bm^VSCTg^~MCW%n_m?0VlmN_>W>?=qS1gCVq zOJ!*%)iI#59dC^_IDRd0zJ3coLCR66DZ&=UJ6DPgl^!3)#KPgoU7rXJL%-As-ia+A z6pYQI(tih$Z>Uq$8ij9Y(_`cvhTm{egL)YO={Mw7^s3RyGroB71PJ|Vs7@K3e7*{& zYfygrMT|wY#F5H{ylg3_p_5%o^LfwudK-eH&=VZ(w2D_uU*ZH@CRUvXzt}4}{e4Ti zBEsVJm;LD52`yQky%!3zx~0son26%7_(OX&6L~xKEP?GrAw`M_1d(a7Ee&D~YU4 zW@Fv$;OBEm=Q@jy{~GiDteH;5f`+uIp1AFrgsj)~fbEJ!f^{d1m3Fy@jm^2s#*YDb z;K-HzAA0Yn#>m`Tx2UHv6lN2qLD=dxNLxXari?;Oab#C$q}gl85)Jzzqc3xv{j*v9 zt=VEwp^rRP*<092jFsGkaTq~3J z7l@r|J#LYyG^2+4wCtqluloCZK9?t1cE$vgEH?t*-*XA2!(axdAF~7SIMin<9eIxC zb&s;3eF;5jDk%bO8d}XI3kywP7i)6#RR?^J8zwFph0 zCOU``fqE3ob7ZO;N!3)}G)Z6uM-AFuXG+*#b&lCNE3^k{AN`zO< zH4n7NYLG(BCWClvYsK|2_Bxe(#wHG64|nfsqmoZrpDHA5G@Nv(zA0o>tRCc4Upv!S zs=1Vov+R>MBD9$a)kRBD3UXBZsf9Pt2tVOnmCH{cstB-tu@khC6W>8dQke3 z0^ZP`M6vI+tVhc`>U&=34Ji(5j5=L2$+X?*4AHT|L9F%*N<&F7CE)1c_F3 zw%GQHTJc2sj&Q-Nm<#a%9svgquH~NK*9+u!e*I zr;)|q7uiJNSF(ts2*=aMMn9agd4e5a-)mqBQJT1GZrG7KhjVI0`b~i4GDVML8#M>{LIT95-8&(^@v(qg^U`$}@zCMZD(Bhdwf#0t9oAmMuz`V&{?AuauNp!G92trj1Jw3G zNxXFDmi(i7OWHjR@fX-@_1S9Xm37y=JqnB7xR+GkI1z69mcbdle6<(aa*}cM%w_F) zaVJfUdrhlNK-J@odjp`RA zX;~wAI`HmS+sBzdTq`rvnH{dwFzu>W<7^${*XN;VpDt$Njix;8QVP3o8t(+Sq*9Ep zJRUsJ&B@*8cqeEqk@$?|IbuF8e>_=~0k7SBY_rxiA@abiT3K`9OU_wkGG%y`+{9o;o8qXUqcmz*(bqTx8iR;VQY!^SI-jYtu5qlmt zyMH|a(eRmSn~LrH=tva}b8mKi7ry|5x^k6zXT0m{o{=3Sb;HUCyp<+C9<+nlXVPJvOxBvd==;b|NO8DTcFThK=iIZWDh=N;f*D{DD!F+r<`GwRrXiS!7msNrFh3kx_?N$zEBNi zvCh^H9hkoaaCV#dZZ?UrCI>u?Zx&i^C_R0a+L{29IJ!N!*e74X5Qx7o0p`cEvnx+K z>Dr6y6rP@ZX*u2WcoU+JR+J9(E*-hy&QEDJ?)Z2sckip~p%RAo1D+IJF+i7GYxhnV zv;W0}7?P7!TWR4(%FC+tGqoA?-vQ(YdDk{v2Z+W)?Q8qlJvoZ#l-j{8bi8alusCHd zD3BpCE#q}h6#Ve&(PsF5@xTN0GywKmADyV1AG&c=cV>ylap<%zOhfDVz@uZ1P77Qv z9VZ6rGy;sUD`JB29yr~@jynKqo7)qpo#^;d#-X5IvH#ow94!M&vTP{l76nGm;F&Tj$Xnz^?2GYZG z#vv}uGO3Q5jtuv}wM$pQH%!I_I+umeL+6PJpy{qX$&qE7?WyHgd_Bb6q|e#Pa~nx~ zsTD2MP}{Z}JSGt|M^!wKENu43{6XOqOP}=m<>J!&_KWWX$=CWp`h1J;1lAsxgoB$J zGu;$FFxUmdY|!#Fi}EMjJs6&giIy+!uRM*{6|Fsp*$<0)cYeWD0~YnZY#&v-?UYf+ zw(h8T-G!t#4yrT}`1JiY*3YHS5oLzkESnYgu)W?~1?H-orU>q984mTm-tl8DK5OUS VeOdT5-oh$a|9orq`sJ5hp8f21KkxhQWPh3Z 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 literal 0 HcmV?d00001 diff --git a/express/code/blocks/mini-editor/img/image8.jpg b/express/code/blocks/mini-editor/img/image8.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c4646324ba2c432452115801bb867893332df49e GIT binary patch literal 17965 zcmeIXbyOYAwlBJHcY@2p0t9z=cPBU@=z`!7+%>`7-7OF-utFd}2o`K%!GdeB5L|DQ z?>qbKbI-YVyglA{|Gcq#v{YBmo?SKTH?<$WKdgXomF1P>L2$sv3dafpJ*8Lip1U4+6mx{M~+}`m;T9dwl1w>%#$v zRnyMd!`a==*#*qQ&IuBfR#HPm{6mUE0cQk4AZJjZ0O;XCh`=bYoa{V2T!c9|oZQ(h ztejt3vs*ela(G*~aB#76a)3l7yj?6T9jraTFRg7MPGXElAG;XA5Gygp=X`3MYA!O? zb`V8hH)|bVbzMte2TLIyM;AwH4-2rj<0~h3VQ(?UKg@-J_G32(Blr)A zhl3cSlB0#KwXpD`Fm#=rUxB%}*aZa{0VZx%Hp1Gn@_(}c?!*}X9;=s^7rPe^yR(}u z2bYkL5Cu3>JO771p+PcYfvh$W6});vvp0%JDyS|AXA%Kk3DJ zI5|1E{-*!u{(le~{vU|{xgVGn$Ky2qX*ZYwg3Cr9346SF+4RjVOeKOPe*Gf z4<%V~U?l7ih?OvpHJ3G~jUYFhr6n&n8?S{RC!3IvH8-0Dj};#$FE{^7D@)5ilo=za)WwiTK~>`nS0LB?vUw;6Ry zy1FqIIC9z;suF|Bc9nTALHoW8yKVaXwS9na0NqQE#Pmk}jow3I(Iesa*CIdrI_T#w zcD{Tz=~*Ymd_jsUzU)~e%+=RoTOuZ!#vFG?c~T%gtmdf|+j-g4$Vui_gYf>aod>GZ zG<=4|n=l=0`Fc`bjh_1L&Hb|HG8XI2{x2W59~(Q!d0%;GVx!kVf)n-Bp7v)deZ#_X zcJr!ocZ+N57lyNK4qu@aR_a)uZ3dJH((O>M;k?E?JZob$JV(AMzsfw`-{Lq<-_49< zNF~Sl0y=jC@dNTmh(ne5gBVKPD<{=hEK_JdA4{YyO$C&ALxg!S`k!(mM9Refz~iMd zUm-_$?>>x;wdb%;kbFvH-l)i!GHvz-Jz-S{7j;MKu*6ymYh0|wkykFvRN-ZeVu#VC z{&0-SFsh{;8HKqsG`Ew;(;}Zf-?v6J*EgBGZZeeH&S6pNXq>(Me#T|_Vlybkj<-H8 zeu(E6MM%lOzCKmeq@a&!aoJ=W0<#4sAr5l=-Syp+(dF}uyAcvR{|gpd+7(*HElIi} z6llV+vqZ}zZ0g$MTA-!zi;>A`O`aEPh@f8?<>*h!M88Snt;hc5u^h`-1+y%TTkx>KNd|^bPuXz2Ch_qQIXFu%J ziDRZ*7D73;5TGK$=O$ksKwc#oh9EyPq}I6Ruz^GL8FB^cr#kACW_beZFWh5LJ#-n0 zK8rtqvcJxvNHCfcb?NZ?p8Fg^+=ug~PCl|jMMu00zLTL1P)KCLN0|BvzX%!MUBhw6 zaeV)>)?BmwgK?CJZM+^ICU$khxR7D_nw20izH()LKM5$e9(x6BKFIz80*-%Mp+v)O zX$t!cKUA4SjBxSYB=Ci+MDnm!_VC6>{#aguwDgOz6dxIX05spXz5~4d!qp_?eDcR% zOpa41m}yGk*$n7L$b%Tct~(HUz1!&4H&2GN@V19B%FYx9NDeYzI+*OO;49T!>_E%Y z8E3{=hG)~-VTyZk;k@SNzwgp1M66|vEwLTK>hXnfCTy~m)JL^HFQ7F3ROhUQTztXS zm4}LK_@}H{C5?eW+yXLlu1Xw=r~GGkeUJrm6E7)umcnNu!tB})prxHLxJ|onq%Xu+ z5pj(JH6<5LLXnqKartixLm!){;00=3T)#j#n^CUP5f0ZyY~Jr()#jb(Zi(7C6aN<@N`5Ba9zc%~{c+Rt;pwtBY+O%AE;I zyPPRH%#P$(4z70RsEuI@cF_SE&nqU?D&9J_*_1G}UO)R4$5;MQo@evKl7rM$!baFp zhELU{mfrbWk1+MYrn8|BPu#Y{l|VkpJyHMD71~9`9lu)Zt_n44-(lgoz3c?dH+AW% ztrC$FdTIv_1-si=V-?n0%V}y7rewRLs~&Cxh6J6Kr#p=gAm-Q3l=m~&Tc4OwUoZQbAMIh^lJXNnz}(NVuR{+hcU`Dqx9e#~O3JBvE=q<1 zeMFK-${FR8pj6e1nF4uvPBh{P9Zn3;&MR;jhhO!g8afrc^o-keP^CS6P~|RWDTJEy zQn6E#;_kEY5AL>~`+}DpADsu+ozEw=sGS0!tT~Y|*{^M=KTPHavUiuP1x^=~#It%U zlWyK2ea$&e0C`}PYe!*0U>?aLI$V<(d7V^;D|)l%MhA|fOdl?41BC(bXpoH~u4yU`F3aXF>_oKQl`Lf)c^>J}q2O9Fsu;5shD z^-e9ud4d5F_^m7bUVP|Or$nQQHPnuOU8I@EWwQoLza@auwJPJy{x*1mx_C*jS4^S& zGs{!1zMg|z>InxC8_(6jp(9oJ^G$buQ7fXE2N2Wxwaz41N>t4cQ!PdnKYY-an&o4mr43Fg$uQ1EkPuYEbfg$|uz*pGPzso@@GVMmVkV5iR0orz#^L~k*hl#lG(e9q( zG~6Id$j>mJp-!UlC!T{RT1@&o^4tz3<(M^VbcqE6rl+qi6KUhDp9lUw<00Qr>SO{sL*88Ab$Tr_&8_+=;#Zg_|kBD(ZvDHJnJ=>mch_&#C`bVlWq*#;kH!XJ{w z&Q-GdiJU0#XfoTmYC)Mb<*zSr+%9)N$j*2u=~WKquz$mki;Q-VDTb9a>vKWFmP@x-%{zdkGe-= z2EK~(oG+omo0Ey~{LDE)KX>-+wM|DK#uA*Kf~IPTOFuQ1uvoa_i2OsbF?C{rE!bXO z#%WtXvv0O3ChnJ=;$Xi1vcQ_ri;4x?dMZ|H*HJ}b)I|;o{}#R0H`gl1Hg^amSMsvw zJNoIn1(OaF!}UptoGrC3K2NNd-6x!%+z4J74ZV47z5C697f*g*)Ves{6oo#b?!zF| z@=J1$64ntW^8G*>R$<3y-f^~nGP7@rd zaRobrIEq|v1i?^@?qsjoU^jpe23&8%Vq`ex7su#Y2#CcvIOwP(-pU5R0k&9W3W*9Y z4ejlIY#aT3UySj}55ODx1Og~k@(Z&p)gzq#UeEhYqta zf_b7TUTv24V`UDDB7zag8rlFhQot0N$EfD?_&PG_^Jxs4^m-YJ4WZITqs{5%g#n|{ z`JbzCmop#DMisN?li~+df5i1C)Gwq>+y_!TpSsIZ*d2$ed;xsZj;sBw0JcM4dY?ei z&}4hrG?Y1K7&8`|_?pm6@25&p{qE_ zgxO01-ux#Ua$%DbzJXa#C3CvG%S8DF&|259S%9-z-_F_Pt$~5F`O-0`u2kl*aC7q& zy2lqgsu_fCF`@M$Sqh^2%$@B8ZnZiHiG26*te+T4;BLQ3+Acx5ppaPtttoQ3yb^u9 zrHNGM11R~lZqp-y;Fn|hsjrqVg_iuxfY}Kdl7Gpm33XbRLX*U#?K7TgN($_ zkV$9LWHAX@F>(JyLx4|vjDvs^m1d#=IK)g7G9 zK!KIb$aR3@>~g5xucfb_qB=^gdQ10vQuM2We7)y`sW|D~zH>f~>_%ar z7%s0<;sW&aw!O@U-u86iAiGKZdq2W&?aAUHIn=!|ISjbt$o!I|fXm}I3EK{|VHoS^vVy<2(rgo?vI9i5Q7lWPw<2a)H zAT~V%m-!RKYA<6#@5+c<2Mz+I@T*NDd`a835`&smX}6VyEDIt7@is#Fj+!D$)&ypY z4~~Bau;sezH|kCJsZ+Mm_RA6QVYqmN(T*d4C<5OyLHQQ;f2kmn4O%{ph!WWGxo`cX zzm<>H??{z{{PgbQlkWVMi3vn{Ynf0uYPraXN#-0!GIp@0VdYQpl#mPM*d9#l((Lz} zx`U|QKsNTC>}eaCw7Js4UL8F-sr;pc9fIgW1O z;q@=AeFXPo>=w!@EzPB$s9OcD(#AIm$EDOv3>B`9R-QmBar>18aD-biVT!QJ< zEQI|5^aPIj9*E351s#riPA(rnR~#2y+1_-il;3yP!R{1w){uGyF$c68)6~IYca`wi zzE6tY4m?SPrX`#BY+ocr;QSjosQ8o_nIKISP4uxZ)Bzk>6Klzo7DUU%owv z%qQJA*{px|b9H{k09q*gPN9shM0o*+u^ofVR+Zz`jss|`mQ|;g2(Zxtb4=lxQKDMM zb&PnZEJf>c%z`n;qm*4^jcyc%GO8!AeQdf=X*aXC4R<}S(Zo=8>ghz4opMdv%p-)z z3~w_&$P}jnGE8mYhb1Hb$(6$vfJkBY_N=BoGkp2r)rzPj4dDvp_O(a4{vhRa3ccoI^&)1j9x-6i1m z_$|8M>(^waW}W%3F1Ix%ntcsQ2B9kMgv={?$(m)s|3J!Th_~Qkz4&G7-I6y6;0I=3`=cwRgCIgZu!nmBz zqT!;!$H4OZiThZe(Bmcapt9mV+J_K1W*UapDw?d9tvUX3wJ&J@Ekvt~{44s$cm*`r|u&*eYg96P~Qn(&N-*`VVk0p5Sc!&>oYU2&7*pSj}nt- z%T0=(SSnquskg8;ZO^c5d&TnJLvjeX>yMarwuk4yt6~NYGSJ zf|bE9QYE{YLV(6GAPWGPkFu83l>YpMS#lQqvO$BD7L?#_LFJ z*hSMyEi&82S(GTUViNS4-Cy;MeKb<+qmw9}$o7T#rid$&?b6eQ%JFq-oSRuEK*h$s zeX?w|ni;2D6oy{8Ua%UvFdCoOR}|A%3qrxpCK~%` zsS(`wjyq-S$#|eu#B^(ie}XtMhXC5`1L*xVZr_H4HX*sP7J5B8SumM0D(4g~r*!v9 zDDr$LO*AZ;+#VJ=pZb^n{$1-j2Fzv|E+XVHLm-DJAs|*Za4LsWp=**bz&*+jKOGs4 zDI`=2C0bMR3pgZN3a#4+kb|_VZlZ2@XdkLf&NPHHQr1(CBvRec2*zoppxJB{Hlb@Z zaLOW28aAfal*8#`q(JoILVI4QDethXIo>hLdQ*q@>{Po~1T_}-aO9AMwN^HWf`A4vkssNV?5%$m2*YfV@%31KMSw(oH0-y{RLp*lNd9KKjAMC&d2di_EGlZ;PB_pm=mAI^w(J*uxB(S5C_4Y%Z!l39*s*d z=R8eYIW~hW&lXp)z57T}5O;r=@LXdr$zFxb3hoPb@bm+yVA>^6AZOxWEJx&Reko?$ zxbn7&hV#(;UdI6xDwbuqwh)jEqUzSBlYdKn*y!ltjNuyh6UjssZ%rU>b`2boua(GTKZa~Taz#q ziQ&IDnEpHtLGeNyF%BK|{m$M4^;cE)&(WE2t^CF(L6X3RG`t}A$B8r!ZX!hOF zh>%PJEh7p7<3GL27A|o(BUB4UGTMfTu#^~z6XBvreKu53fQj(Byn@V8H*Ei0a-XTG z04lB80y_;JiZ6`FnE2r3^qA}b@x9gVj;u7`WJW|}0waQgap6IdsBobFp-KBhHXK&2 z$fi5U1fN98?QKjG6Q!p$l9|{XZ^J>UKM|G<;o=oeIyY8auMif>Wg>X3yyGsk{Nn+n zSL$lRa2QL(*VnFZ-Z-Vvl;=?Dp0zmXnAr09JGibi)-5O{KM+|mEW;e5T-``0ef4X> zC&*zJ_*!;VUNlOhNV9>gAEf3YmK^iIMtqxlG$i^+P59; z$i#U+8Wo6iQ58BTL{Te>$%yy=I43weBnvd@9&{pH&4$p3!eVxP{FLO0Y$>;f@Z|h& zFclbXl<UTLfjF`1F#)?7?nU8ZQOT8AlkM}t~@xZ^Dq-#fJ?A*x-<*528e zlWHv;w$?SlQp?rs2T)#o7j;3Ksjl7c=933dcYo!Sec^}Tx$lRrUOh(x@!~(<>6@Fy zlg*}xy&IJ{ixYYCDogS(T#+dN2-sB2MFfNPIPcEx)!P_;*uBYAg35`6nJw1xiP*mS znH&Sfj+Ad}ZfV7OtsF&3cRq0|6KvljT!d;9F%?nL+ZWrWb*1jFHfJ=N%Cj}D4K#M1t3HDX9+|i;ul!yk7k(j(J1)UD zeLlt>hrmocORpl9(_o;C7#^m7$4YCk>gVwf&QpJROEvo|u zUzb7>E0+qL%Gv**)J_y;z`2mz7-a>I?N%48l0a~92F$!p$jKd^HPa^&dLU2G{Bv+%+s==C% zg)zA_+7;?OV|%Ar7Usa93RlXj7tTiFmC8oq2KQYPDICsTk|+_W6wU%%WMRN$pvcsS z_P691=*1_Ptn%)THsM!(k7g^t6N(4X(+|frYJ~Jv&KXONP#N z&p!!k;&8CV2HM`hf4p?}0Bl9oEH(MPFHgFP-PTc>Hs{&I8~4|XCZ<8d=GC*4c{)u6 zPp!Dwzh3p%v6bFa>|MJJJ%FOyUW*0}Lnv|%bIY~{8%M)I5O3c1qQ518g`^DqU*lCNj&h+9leSX{SO zxIQ02u2*S0zu5WmsrUGXY_5?%HLkiLzl9&*eUhBUyKST00k2@u12d&q5X!%=QKDGj zOjga&7X%E#nXgbSab){y96kK{M5prZJ+}Cw6KQ7CYiD}CidE-N9t^sxS$rnwZf5jeI+VP841gsHtUC`WNVwSwp4dvKvwjQyGW? z^}>q>0sRG}cT&qhv~mG}$<=`A!xP0+!)kTu#gke_TFg8xI?-ruHW}c=f(gS6TQX2q zUu(#XUFIb6MbhRxbye8|w|qyAwI9yYhq7weqHK0{QmjvP^PanTleB5w4_Dw+Fl>I} z8TP-Z_JD!*L_d6>%&;z4z}xV>8&hqym#uRTLy#*7f2|X{1Dl_9*^5$OZ;}%)7Dez_ zv2`jAsB-r;fDK>29!oG&b#Qn9LAF=6+juRl#y`N_DIe_W3@^BBbM$?E0HvNlmED|p zk`D0|$)yv_;T)Ob*=DilHp%jMROl=*!F=0bjQXPOopx*2fIBfV|KG4v*Oy1G51>R- zb*5lqz9G}R){q!f~+V}+zAq6pbYe9<}2XdqJe+C;Hp zicG!9M6n`6M3H8i03z`tAxM}o{1~poT0$N`fr-|nwQQ=GY_!o?ZvjwdOKaI~8Yr{% zX)`do0@t3zRJnV-@C~Iz7PJAx(5szV27)poSLBaI3|Cn7VRrtp?wK%|K+F9!MiQmw z`AgfZ2N3*}JPZBcw1I7sNQi;DY?gjezAUNlNiEMn+=P9vXtc*?!~N<(A&QjI-4bNR zfgbT!lIshNyJ=#%o)u*`8q}uPP&eAp09lwK!e@8HR%6T;Yi->^Y(KbKL2y3)ZNIdXI)dHOr`FZaebqgM&np%#!7x%xvh8j z7r`q(in{o`F7!Bxx*|zqR$S+2ibe}mQ0Dxv&XM@0igcKS$IDOx?WK#uQX@)mq+`WE zzcrgJx{*k{(rD~X027kfY%z$TP>_a1WxIqSBf-REyNM=OkV?pN5*qoa3nMg4Hh>=3 zcgwW_7uzL3FBFyOXd6=V`Yf`Ql7M9!d)A}v^(4H|jP>_54RcNlTuGQUgbqh@U=QmI z4njvU>{RtDidQuFC=+|PPkg8&TluMtCnAA*mzacSSqjDShJud&xcE+^dXU^@u)>G5 zvSc^L*Ecn2O5E5!E!qq;u;3r)RixzvYFZx)FdP`g?CCOxjF0DtXVTLHbKBM9(OPh# z%qxwE~1UO_k-Uyi)IQ42w zAx+SQ*@cBkS@T-|{&nx6vEcy~ijGUbwzFg7M|EU7pAz!OduF!+7E~Y|uKz6j0Lm?Q zE-K4d6v`bi_9RD{C~|Jf9b2*J;aL5!g0kIzyI=Y#{nsuD^p%NX;&*=327Oo;wtq#r zWo1%Z6oQZcq8|0{G`Fs&y!W|RSNarHXH+rU8m%SRK&a*S1k5Cy%0^{pBpM1AMI$LP z6l}4a4X76oB>*<;G@@kjBoR4KH#C8l4XaN{#ubSuP#ZsT`;SrYmXTmalgrdi&7F?4 zijkKlO^mCy8*sHv{J>-+?Z%y<6Xm^xGIn&oSj z(c3^-Mt4e!#ju6q4jsm)Z~QZy=_%e#tE`e=TYps1?4$bSfZCN)sWj&!hsu37lgxQa z!)*6{xv@%zpT_C?UM(Zdn_)5|oU!LOjD$BBYD}G(Dk9&}u!(?w@Q1C9SyAPkxs6>l zwf_3v7l*AGz3BzZgIhnbWoNZy+2=$*?jKFsQ__*m2nzVVyYa$EPd>k=%7wHL!|0RM zM8BXkx5ux%-K$nn>~$u4a~|)LcRPb^b15b^bJ9sNd2=O{dq@NmoD2|6087pyXDK2- zfaKCME+nE%AP=CI*S7S@5Klxj@?58tx3oP~Y#e6a1Zo{B=7nr|xf{HMR!%RB$JUSC zX17n=g%+Po(8au%Dt1jKYVt6Ci|>}W3$Lf@=X9o7!=K+X8PJel#U&W9$7qj%AoZA(b{4qMkT@BpJ3pl_VZ}v_m^^k(3cMHMO!{k9Q&AVnvHQV-N+7X z^DpUA4+j=(qgU<3jXr>$Vh6Cvq>1q6myQUO?=`=J$%$qsbEZ9jcs&o!Ogz_p=a-o> zZu1MMvn>07|4Pp0K`8kQ3yH*%{cEq@uRL-tGv)b~8&HBJ$?EEgX#0z|0C2$YGeOrv zd+#1VjN&RTTLH!#ESqEPC4(4`aXu>3Z&bjlB4JL2M%s4+rk2LeCQ)+)Trvpc3ew|$ z&RRCOnj>0&<0D@hT|H2bN3I~vf~g6;S2UsxaA&#G;l=JTO32-mZ>vaO<@J@?BwL9B z5eoe$1DhUcb7KLd$F-sZ;T5*!EdxuGM2aJQcAq)EJ+vkayz4uDO##%rKqdY&-MbF{gnH&3dgVn_VUP415jG=6L7ltqxQl z6_8W3`S3pdZVN(eKAv1Gn6TJ(R*Ct!J1ee1&cPkpT*GQlIiG?v@eQBBGf}7>U+Dp~jwQ-_vES4W?V5s3e+$}C4@vCb!&R#ceJ(wV0JiKk z*}7rr)WEVn*C0mPFq(JY6))TK6E71N)?VVtbJ6J*JnyQQHm9OS%sJW>?KIq&rMO=% zSlP@lC$G%Sc>vAT6c2V^5imE(JKp@ZA0^Z#4%>17hI!bnMfVH8gb9D zOkClnS`vKL(G@=WJIK{-P{S1^Ve|U6EHuKo^&7@a(NK0j9dcgxYTuVzLi__#^ONaIw4 zHeg)c%WGVPCI_u$z)J$cT?kD!Rt7=Zv|BBPY=fwI8|v@L_jtd~Uu<@g$(hCUIxbKOFhC_}$2TE~{?+08DZliCijqN(v8*-`PCPKaBXgxZrF;Elh2UAwMY~LC~|MF(oNQC#8Z=8VjUF&Ty zMyC%obZaqyuG1|)$ek@V;vK~rM}~zJ4m3WEbv#+sir2A@ce%p(u-X*)hTEg>OR|#K z5KM9FbTBP;8e%eNW@Hc)rPftHxg;_Ql`88{lQ{Ny-zs{(k@SrSA=-NU0)EzPQz47> z`|>UBMdr3AS^wsN<@>v7(-O83oB)`adZTa35*y+tPNNjUSNrwOm&<7|spzCVmL5e=!9#5C%ILL6 zEJONwKv1YaLB-S|=h_x^)VX4F2_s{in>pN%B9X;yQd6tBo#Gx-=`QwV#{!LS^*Q1* zF%nG5tbjcOV#_9dbkb5F%>sNRy>yH#Ed$n6wC18>1lE^mEvHgK0c~`;XAP;wZ+o9Fp*ybG${I6b({kG=f;l_P8}I$0u8ykf8VmFw8CWv!JK8WMW^6=dbgXvCI- zcGUUUZO22+DHJa!+XeB$l&_}T_3KkSrwOZ}rRsBMUDa9iG9KHq_oHyXMMDF66}DPN z*^?sH)XqmJce4dIN)X)~vj>--Tx~B|JFEHe-WsT@pyPdpxrP#5zIuV=aQi0h7rEm1 zmTbv>IfU1~!tdF;EDsyrdHU_|%=l-^-B@ODMV^Q-S0v&OG>=`9$qM^#yD z^~Yo+d0#OEHq{#vubjE<_AW3*eBbJ|n0g;I#-cMfJJ6-!Ow^k?Y?Y1h8KYQpeW1{* z$aOGw;eyxH_N;yC9q;ecf&v2FHNT7CSqWa9Df_Y1>{INL*W!yk?`L8iwmv-+n{3 z)QHS3+D5*6djHnf{ls1%{iuZUMBHY1!jUfTHmzcYpEQRJA*91h@R{f|maV_(ErEDR z;Pb&S@+7F;%2W3~sX8}jh1WT-t|>9{=XJO0`2;292cnauC<|j(b1t(fr?)Y9AA!kK zP~l7;{~R7QTHWilmh+@6a7$0yEG|>$JE4{kx;gwOO-3Nbweh+~0AQI88K4}p&0EVu< z4Y>RdC{UoTRf8s}G^Y2W&;~J^Py|CR*-e4JiOrbPQV9=p*|cx`hTuA4lDcJKfAeDt z@tCa$T@V%~zc+>_g}&i;`uRkzgDae`qNjA{25GvlJ4}Mm@?VQTCz%Vwkx!X0DbKL+ zEM0)$iAU+}1!mNys1Vs|P!71=o8Q4#|M2%y(U_x-8Ql5>xtu+J*GJ*(VDPm$MXC_) z>e>9ctx#Yxo{v?;JCgSDXtA2=YF;tHL65=|$&iD9CrVkBb7QZrwLajD(zpp<#|PJz z^_zCt%Fi_2Ja?E&&rsuU(xSj^fGSbOD5wY482xa~D8H8HT3=34AvJB7?t2=eeJ5H% z{AJI(^KQD=c#PpRU$u)WpYMQzY;BIlwSUvar77S6)Oe7cz>&;rH@>^d9v6NPl7-ST zF3~t<7|>-iJ>&GegukCY9@7CMR}qB0X` zCuXT9uS7f9d^n+FP04JmlVY`&6x;3HbqMXEAm}ISXLG%RGX;bnUk0>X;yGHB%#WN@ zpDA9uAHTG6EC^0;(vyG2Bz?8wUL2j?ZyZiuBT7A|oDz4#-*}dO^2v`UMu8R9D!A@m z>VxH=psgWSQCnur-Qhd==JlW}tGDBV;gsJ^@tM-@Sa+6dlO)`J)!P8cflgPt17?x8 zJ=&PSp#mcHf&ztwrIT8Ui1tn0H$Y*5X8`fT5{(dCTB}{MXoU95;vbihd1djg$@iQJaI$&( zlRoRuo1O~61i1>^M|sJRlJ$09kJ^90i!2pKTPBQSO{9){M<{h}{0+7d@2er~u!g>&Yl%oLm@Kh#N@42Fv0kFSp_Oy0j{SfGof3k@{3`d_+fdnk zwG+Y_cf~b|NbV4Y$fjJvH6)z5nd4cmn!BCyD`iY9VXIiDsLt*A(PVC08{QyFM9akR z9Oz(5;lsE3&H?CMY5V@xCk4^oEL_ts3gu=)`Yk4nO@VAd`F1_5i~D_~ZIMXxgL-2rE@G zZZfyWDDH?x6?ClK!LJKk58}?CGi|{~l}9Rg0)r2rqB%%e&*@#RC6UkweYPms9O?BICYm^h$=SO{qVdm*EfV+muAxJQd0ejtD4FjBqMt1TQ`7abxs+tb5=S zBsv*2Xu9_*ks_G(NPc(Haq8|A+^?4Xga`aPRU@tPXQg@P!a=yl_wqUr2(UA=$|}-` zC;I9#R79-e-KhGF&CvElN`_!@#Nuj}CXF}pW+gQ#&w0+Y2isXI*2RN{jDBreyP72> za3mdcJ#R)_OxA&v>;)dy@pvl2mjH66K^YX;qhW}4d{xT$=B7GBR}DqnTu;a-b%T^Fwr@Wb%UJt@n!|#eq8d&0Q2YOiBYd_3*)WWpjhH>?<+?fczbmj@xJ-yaPQ zZNEg@htj(iVIGC@V3=*8Ik?67fbN7VVZa=lD$@ESfK@rV&5DzK|81##akgcKYL<%W z4E-0t(+xIjB#`yFS&@RW-Fo`_1$BY>i0ZurikffD?zelnw+&Fbq*C`m3`{n^O!~SZ zdK}`@y#`oN`>jBp=sV`&{W{_*`%gU2sVdZ}xJC%->M1UFbe9vvRXZRaBi^?^G_YrG zeGf=~ZU?@5zGs#4T4~Xo?&GHa^4SfW1?xK&7}v(+g5PA_jbKdf!d8FU1eNbdR#iGt zrc7=(4SA}XcUOg8RO$oBb;4Q6Roy)3;*$GTr|$uT*FWG|_KxM)Hpg7!VeWqc%nogK literal 0 HcmV?d00001 diff --git a/express/code/blocks/mini-editor/img/image9.jpg b/express/code/blocks/mini-editor/img/image9.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5b34a598b14f1ab100512b95444d3b7f38ab1802 GIT binary patch literal 19656 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

RY2EzD0C_tDul+DV;olQsApfVV=7;vq5_VgbJ- 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 literal 0 HcmV?d00001 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..e4601baba --- /dev/null +++ b/express/code/blocks/mini-editor/mini-editor-background-loader.js @@ -0,0 +1,105 @@ +/** + * Mini Editor background loader + * + * Single entry point (`getCardBackgrounds`) that returns the background-card + * collection the mini-editor renders. It owns the decision of WHERE that + * collection comes from, so callers never branch on it: + * + * - A `collectionId` is authored on the block → fetch live templates from + * the template service (the dynamic source). + * - No `collectionId` → fall back to the bundled STATIC_COLLECTION shipped + * with this block (the 10 curated images under ./img). + * + * Every card — dynamic or static — 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'; + +/** + * The curated fallback set, used when no collectionId is authored. Each entry + * pairs a bundled image with its Adobe asset urn so downstream actions can + * recover the source id from a card the same way they would for a fetched + * template. `file` is resolved against codeRoot at read time (see + * resolveStaticCollection) so it works regardless of deploy path. + * + * Keep `id` and `file` in lockstep — the id is what a todo/CTA action will + * look up for a given image, so the pairing here is the source of truth. + */ +const STATIC_COLLECTION = [ + { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25650', file: 'image1.jpg' }, + { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25651', file: 'image2.jpg' }, + { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25652', file: 'image3.jpg' }, + { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25653', file: 'image4.jpg' }, + { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25654', file: 'image5.jpg' }, + { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25655', file: 'image6.jpg' }, + { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25656', file: 'image7.jpg' }, + { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25657', file: 'image8.jpg' }, + { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25658', file: 'image9.jpg' }, + { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25659', file: 'image10.jpg' }, +]; + +const STATIC_IMG_BASE = '/blocks/mini-editor/img'; + +/** + * Turns the STATIC_COLLECTION into runtime cards ({ id, bg }), resolving each + * bundled image against codeRoot and honouring the block's `limit`. + */ +function resolveStaticCollection(props) { + const base = `${props.codeRoot || ''}${STATIC_IMG_BASE}`; + return STATIC_COLLECTION + .slice(0, props.limit) + .map(({ id, file }) => ({ id, bg: `${base}/${file}` })); +} + +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(); +} + +async function fetchTemplateCollection(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); +} + +/** + * Returns the mini-editor's background-card collection as `[{ id, bg }, ...]`. + * Picks the dynamic template fetch when a collectionId is authored, otherwise + * the bundled static collection — the caller does not need to know which. + * + * @param {Object} props + * @param {string} [props.collectionId] — when set, fetch live templates. + * @param {number} props.limit — max cards to return. + * @param {string} [props.topics] — template-fetch topics filter. + * @param {string} [props.codeRoot] — base path for resolving static images. + * @returns {Promise>} + */ +export default async function getCardBackgrounds(props) { + if (!props.collectionId) { + return resolveStaticCollection(props); + } + return fetchTemplateCollection(props); +} 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.js b/express/code/blocks/mini-editor/mini-editor.js index 882270cf5..412d55500 100644 --- a/express/code/blocks/mini-editor/mini-editor.js +++ b/express/code/blocks/mini-editor/mini-editor.js @@ -1,9 +1,4 @@ import { getLibs, getIconElementDeprecated } from '../../scripts/utils.js'; -import { - fetchResults, - isValidTemplate, - getImageThumbnailSrc, -} from '../../scripts/template-utils.js'; import { trapFocus, handleEscapeClose, @@ -12,6 +7,8 @@ import { } from '../../scripts/color-shared/spectrum/utils/a11y.js'; import showCopyToast from '../../scripts/utils/copy-toast.js'; import createMiniEditorWidget from '../../scripts/widgets/mini-editor-widget/mini-editor-widget.js'; +import getCardBackgrounds from './mini-editor-background-loader.js'; +import getFontOptions from './mini-editor-fonts-loader.js'; let createTag; let loadStyle; @@ -20,93 +17,6 @@ let getConfig; const TEMPLATE_LIMIT = 8; const DECO_CARD_COUNT = 8; -// Fallback used only if the Typekit kit fails to load or exposes no fonts -// (network failure, ad blocker, or API shape change) — see buildFontOptions. -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 FONT_OPTIONS 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; - }); -} - /** * Copies the quote and, when present, its author (as "quote — author") so * pasted text always carries attribution instead of the quote alone. Shows @@ -193,34 +103,6 @@ function constructProps(block) { return props; } -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(); -} - -async function fetchCardBackgrounds(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); -} - /** * Pairs each fetched background card with a quote so every card/quote * combination is stable and reusable across the main widget, the desktop @@ -269,6 +151,10 @@ export default async function init(block) { loadStyle(`${getConfig().codeRoot}/scripts/widgets/mini-editor-widget/mini-editor-widget.css`); const props = constructProps(block); + // codeRoot lets the background loader resolve bundled static images against + // the deploy path when no collectionId is authored (see + // mini-editor-background-loader). + props.codeRoot = getConfig().codeRoot; block.innerHTML = ''; const header = buildContentHeader(props); @@ -278,14 +164,16 @@ export default async function init(block) { const quotes = getPageQuotes(); try { - const [cards] = await Promise.all([fetchCardBackgrounds(props), loadWebFonts()]); + // Backgrounds and fonts load in parallel — each loader owns its own + // source selection (static vs fetched cards / Typekit vs fallback fonts). + const [cards, fontOptions] = await Promise.all([ + getCardBackgrounds(props), + getFontOptions(), + ]); if (!cards.length || !quotes.length) { block.closest('.section')?.remove(); return; } - // Built only once loadWebFonts() has resolved, so this reflects whatever - // families the Typekit kit actually loaded rather than a guessed list. - const fontOptions = buildFontOptions(); const cardSet = buildCardSet(cards, quotes); const editor = await createMiniEditorWidget({ From 0c10d56d1db4015860bd1f2b2406ec1e9e4e54f4 Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Tue, 11 Aug 2026 14:48:25 +0530 Subject: [PATCH 07/21] Apply first font for small app frames --- .../widgets/mini-editor-widget/mini-editor-widget.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 index 6c96b875c..3d0d3f325 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js @@ -575,7 +575,7 @@ function buildArcGhost() { * re-entering the deck one step further round; the other two just carry * their existing content into their new role. */ -function buildArcCarousel(cardSet, useQuote) { +function buildArcCarousel(cardSet, useQuote, defaultFont) { const root = createTag('div', { class: 'me-arc' }); const total = cardSet.length; let activeIndex = 0; @@ -589,7 +589,10 @@ function buildArcCarousel(cardSet, useQuote) { // 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. - let selectedFont = null; + // 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); @@ -760,7 +763,7 @@ export default async function createMiniEditorWidget(config = {}) { widget, useQuote, onFontOrColourChange, destroy: destroyWidget, } = buildWidget(root, a11y, cardSet, fontOptions); const decorations = buildDecoCards(a11y, cardSet, useQuote); - const { root: arcCarousel, updateCentre } = buildArcCarousel(arcCardSet, useQuote); + const { root: arcCarousel, updateCentre } = buildArcCarousel(arcCardSet, useQuote, fontOptions[0]); onFontOrColourChange(updateCentre); // The arc carousel is inserted inside the widget, in the same flow slot From 57df2dfcb3b8eb37c9ce6b729d9c3e85ace8e35a Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Tue, 11 Aug 2026 16:30:45 +0530 Subject: [PATCH 08/21] Add test cases --- .../collapsible-rows/collapsible-rows.js | 12 +- .../code/blocks/mini-editor/mini-editor.css | 46 ++-- .../code/blocks/mini-editor/mini-editor.js | 13 +- .../mini-editor-widget/mini-editor-widget.css | 8 + .../mini-editor-widget/mini-editor-widget.js | 27 +- .../collapsible-rows-quote-actions.test.js | 87 +++++++ test/blocks/collapsible-rows/mocks/body.html | 14 ++ .../mini-editor-background-loader.test.js | 113 +++++++++ .../mini-editor-fonts-loader.test.js | 88 +++++++ test/blocks/mini-editor/mini-editor.test.js | 130 ++++++++++ test/blocks/mini-editor/mocks/body.html | 21 ++ test/scripts/utils/copy-toast.test.js | 73 ++++++ .../widgets/mini-editor-widget.test.js | 233 ++++++++++++++++++ 13 files changed, 831 insertions(+), 34 deletions(-) create mode 100644 test/blocks/collapsible-rows/collapsible-rows-quote-actions.test.js create mode 100644 test/blocks/collapsible-rows/mocks/body.html create mode 100644 test/blocks/mini-editor/mini-editor-background-loader.test.js create mode 100644 test/blocks/mini-editor/mini-editor-fonts-loader.test.js create mode 100644 test/blocks/mini-editor/mini-editor.test.js create mode 100644 test/blocks/mini-editor/mocks/body.html create mode 100644 test/scripts/utils/copy-toast.test.js create mode 100644 test/scripts/widgets/mini-editor-widget.test.js diff --git a/express/code/blocks/collapsible-rows/collapsible-rows.js b/express/code/blocks/collapsible-rows/collapsible-rows.js index f4abcf245..112c58dd8 100644 --- a/express/code/blocks/collapsible-rows/collapsible-rows.js +++ b/express/code/blocks/collapsible-rows/collapsible-rows.js @@ -158,7 +158,11 @@ function buildTableLayout(block, typographyClasses = {}, hasMiniEditor = false) subHeaderEl.classList.add(...typographyClasses.body); } subHeaderAccordion.append(subHeaderEl); - const quoteActions = buildQuoteActions(headerEl.textContent.trim(), subHeaderEl.textContent.trim(), hasMiniEditor); + const quoteActions = buildQuoteActions( + headerEl.textContent.trim(), + subHeaderEl.textContent.trim(), + hasMiniEditor, + ); if (quoteActions) subHeaderAccordion.append(quoteActions); headerEl.addEventListener('click', () => { @@ -226,7 +230,11 @@ function buildOriginalLayout( subHeaderEl.classList.add(...typographyClasses.body); } accordion.append(subHeaderEl); - const quoteActions = buildQuoteActions(headerEl.textContent.trim(), subHeaderEl.textContent.trim(), hasMiniEditor); + const quoteActions = buildQuoteActions( + headerEl.textContent.trim(), + subHeaderEl.textContent.trim(), + hasMiniEditor, + ); if (quoteActions) accordion.append(quoteActions); }); diff --git a/express/code/blocks/mini-editor/mini-editor.css b/express/code/blocks/mini-editor/mini-editor.css index 6212c85df..d6deaca71 100644 --- a/express/code/blocks/mini-editor/mini-editor.css +++ b/express/code/blocks/mini-editor/mini-editor.css @@ -56,10 +56,6 @@ z-index: 1; } -.mini-editor-header p { - margin: 0 !important; -} - .mini-editor-logo { display: flex; align-items: center; @@ -81,8 +77,11 @@ color: #222; } +/* 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; + margin: 0 !important; max-width: 437px; font-size: var(--body-font-size-m, 1rem); color: var(--color-typography-body, #505050); @@ -106,6 +105,23 @@ @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-slide recalculated for this card size the + same way as the base rule: + 327/2*cos(8)+270/2*sin(8) + 327/2 ≈ 344px; +10px safety margin. + --me-arc-lift/--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-slide: 354px; + --me-arc-lift: 24px; + --me-arc-extra-h: 50px; + --me-arc-gap: var(--spacing-100); } /* Mobile header is left-aligned (not centred) per Figma frame 0-18684, @@ -139,24 +155,4 @@ .mini-editor-header p:has(a.button) { display: none; } - - .mini-editor { - /* 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-slide recalculated for this card size the - same way as the base rule: - 327/2*cos(8)+270/2*sin(8) + 327/2 ≈ 344px; +10px safety margin. - --me-arc-lift/--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-slide: 354px; - --me-arc-lift: 24px; - --me-arc-extra-h: 50px; - --me-arc-gap: var(--spacing-100); - } } diff --git a/express/code/blocks/mini-editor/mini-editor.js b/express/code/blocks/mini-editor/mini-editor.js index 412d55500..0464bb667 100644 --- a/express/code/blocks/mini-editor/mini-editor.js +++ b/express/code/blocks/mini-editor/mini-editor.js @@ -59,7 +59,13 @@ function getPageQuotes() { }).filter((q) => !!q.quote); } - const rawRows = main.querySelectorAll('.collapsible-rows > div'); + // 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 { @@ -95,8 +101,9 @@ function constructProps(block) { } // Content row: heading/subcopy/CTA authored in the first column, // regardless of whether DA left a trailing empty second column. - if (!props.contentRow && cols[0]?.textContent.trim()) { - props.contentRow = cols[0]; + const [firstCol] = cols; + if (!props.contentRow && firstCol?.textContent.trim()) { + props.contentRow = firstCol; } }); 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 index 9e79d9408..e51cc0a91 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css @@ -556,6 +556,14 @@ p.me-deco-author { 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; } 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 index 3d0d3f325..ff0292df5 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js @@ -289,16 +289,21 @@ function buildWidget(root, a11y, cardSet, fontOptions) { class: 'me-quote-wrap', role: 'button', tabindex: '0', - 'aria-label': 'Copy quote to clipboard', + 'aria-describedby': 'me-quote-wrap-hint', }); const quoteEl = createTag('p', { class: 'me-quote' }); const first = cardSet[0] || { quote: '', author: '' }; quoteEl.textContent = first.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). + 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, tip); + quoteWrap.append(quoteEl, hint, tip); const authorEl = createTag('p', { class: 'me-author' }); authorEl.textContent = first.author; @@ -441,7 +446,11 @@ function buildDecoCard(a11y, entry, useQuote) { } function buildDecoCards(a11y, cardSet, useQuote) { - const wrap = createTag('div', { class: 'mini-editor-decorations', 'aria-hidden': 'true' }); + // 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); decoEntries.forEach((entry, i) => { @@ -577,6 +586,15 @@ function buildArcGhost() { */ 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 @@ -698,7 +716,8 @@ function buildArcCarousel(cardSet, useQuote, defaultFont) { applyRoles(); renderAll(); - root.append(ghost.el, cardA.el, cardB.el, cardC.el); + listboxRole.append(ghost.el, cardA.el, cardB.el, cardC.el); + root.append(listboxRole); const prevBtn = createTag('button', { type: 'button', 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..9d3bf22de --- /dev/null +++ b/test/blocks/mini-editor/mini-editor-background-loader.test.js @@ -0,0 +1,113 @@ +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(); + }); + + describe('static collection (no collectionId)', () => { + it('returns the bundled static cards without calling fetch', async () => { + fetchStub = sinon.stub(window, 'fetch'); + const cards = await getCardBackgrounds({ limit: 8, codeRoot: '/express/code' }); + expect(fetchStub.called).to.be.false; + expect(cards).to.have.length(8); + cards.forEach((card) => { + expect(card.id).to.match(/^urn:aaid:sc:VA6C2:/); + expect(card.bg).to.equal(`/express/code/blocks/mini-editor/img/${card.bg.split('/').pop()}`); + }); + }); + + it('honours the limit and resolves image paths against codeRoot', async () => { + const cards = await getCardBackgrounds({ limit: 3, codeRoot: '/foo' }); + expect(cards).to.have.length(3); + expect(cards[0].bg).to.equal('/foo/blocks/mini-editor/img/image1.jpg'); + }); + + it('resolves against an empty base when codeRoot is not provided', async () => { + const cards = await getCardBackgrounds({ limit: 1 }); + expect(cards[0].bg).to.equal('/blocks/mini-editor/img/image1.jpg'); + }); + + it('caps at the full static collection size even if limit exceeds it', async () => { + const cards = await getCardBackgrounds({ limit: 50, codeRoot: '' }); + expect(cards).to.have.length(10); + }); + }); + + describe('dynamic collection (collectionId set)', () => { + 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'); + }); + }); +}); 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..4ad1d7096 --- /dev/null +++ b/test/blocks/mini-editor/mini-editor.test.js @@ -0,0 +1,130 @@ +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 { waitFor } from '../../helpers/waitfor.js'; + +setLibs('/test/mocks/libs', { hostname: 'prod.example.com', search: '' }); + +describe('mini-editor', () => { + let fetchStub; + + beforeEach(() => { + window.Typekit = { load: ({ active }) => active?.() }; + // getCardBackgrounds only calls fetch when a collectionId is authored — + // none of these tests author one, so this stub exists purely as a safety + // net against an accidental real network call. + fetchStub = sinon.stub(window, 'fetch').resolves({ json: async () => ({ items: [] }) }); + }); + + afterEach(() => { + delete window.Typekit; + fetchStub.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('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) static 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/widgets/mini-editor-widget.test.js b/test/scripts/widgets/mini-editor-widget.test.js new file mode 100644 index 000000000..99638a5d7 --- /dev/null +++ b/test/scripts/widgets/mini-editor-widget.test.js @@ -0,0 +1,233 @@ +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.syncViewportMode).to.be.a('function'); + expect(editor.destroy).to.be.a('function'); + }); + + 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 } = 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; + }); + + 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(''); + }); + + 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 } = 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); + }); + + 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('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(); + }); + }); +}); From 947c161de196cffad1309232422f8299f947d918 Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Wed, 12 Aug 2026 12:13:38 +0530 Subject: [PATCH 09/21] Remove static files backup --- .../code/blocks/mini-editor/img/image1.jpg | Bin 9123 -> 0 bytes .../code/blocks/mini-editor/img/image10.jpg | Bin 9481 -> 0 bytes .../code/blocks/mini-editor/img/image2.jpg | Bin 9037 -> 0 bytes .../code/blocks/mini-editor/img/image3.jpg | Bin 9123 -> 0 bytes .../code/blocks/mini-editor/img/image4.jpg | Bin 16141 -> 0 bytes .../code/blocks/mini-editor/img/image5.jpg | Bin 10554 -> 0 bytes .../code/blocks/mini-editor/img/image6.jpg | Bin 7768 -> 0 bytes .../code/blocks/mini-editor/img/image7.jpg | Bin 9630 -> 0 bytes .../code/blocks/mini-editor/img/image8.jpg | Bin 17965 -> 0 bytes .../code/blocks/mini-editor/img/image9.jpg | Bin 19656 -> 0 bytes .../mini-editor-background-loader.js | 82 +++-------- .../code/blocks/mini-editor/mini-editor.js | 9 +- .../mini-editor-background-loader.test.js | 133 +++++++----------- test/blocks/mini-editor/mini-editor.test.js | 26 +++- 14 files changed, 94 insertions(+), 156 deletions(-) delete mode 100644 express/code/blocks/mini-editor/img/image1.jpg delete mode 100644 express/code/blocks/mini-editor/img/image10.jpg delete mode 100644 express/code/blocks/mini-editor/img/image2.jpg delete mode 100644 express/code/blocks/mini-editor/img/image3.jpg delete mode 100644 express/code/blocks/mini-editor/img/image4.jpg delete mode 100644 express/code/blocks/mini-editor/img/image5.jpg delete mode 100644 express/code/blocks/mini-editor/img/image6.jpg delete mode 100644 express/code/blocks/mini-editor/img/image7.jpg delete mode 100644 express/code/blocks/mini-editor/img/image8.jpg delete mode 100644 express/code/blocks/mini-editor/img/image9.jpg diff --git a/express/code/blocks/mini-editor/img/image1.jpg b/express/code/blocks/mini-editor/img/image1.jpg deleted file mode 100644 index 8025b3df006529240b646ac09e9f1166b15076e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9123 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 diff --git a/express/code/blocks/mini-editor/img/image10.jpg b/express/code/blocks/mini-editor/img/image10.jpg deleted file mode 100644 index 7b68d3024b43fb52f6d443f4c3849d15aa80f219..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9481 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 ozLI_0IO0(yL9dLfYOxS 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 diff --git a/express/code/blocks/mini-editor/img/image3.jpg b/express/code/blocks/mini-editor/img/image3.jpg deleted file mode 100644 index 8025b3df006529240b646ac09e9f1166b15076e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9123 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 diff --git a/express/code/blocks/mini-editor/img/image4.jpg b/express/code/blocks/mini-editor/img/image4.jpg deleted file mode 100644 index be9d1a01fe3b497531d025447fffa1aa37678df0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16141 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 V$%K3Z^m7)eEGb1%@MZ3&|3B|I!qNZ$ diff --git a/express/code/blocks/mini-editor/img/image5.jpg b/express/code/blocks/mini-editor/img/image5.jpg deleted file mode 100644 index c24d42c47980a722b8bfce63b7193cb631324d67..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10554 zcmeHsXH=8h)^3pA2~E26-djlMy@nQ=fM`N!p#%s`l-_&qMIrPiAPR^GNLA?_L8Pf5 z2v|TtgfHx~&p!Lyd+r_I9^bfs?$~Q2BWuq0SR|0FJ~L=?pQ~()pbNXDJK(9&2D= zpm?B+INH}mLJAB9OGrvfNK1?15Mr3yUViW(F)s`cj)9ExHx4Z)jH9ocx1Sr@i}R8b zjzIhSDGLaYasE#2=jQw`?tjza^2%>Q;Mh1hl`c()xf2HM;eW|y>E-69BCRCxpWJ`I zZT^K?koq0}Xa67I>;DJvpZ&P3BrenVQ(FK1RB_w#hl>A}=|6Y;ABo~%5KUhv zxF6d0a!*v$FDru+=7)B4=QQSJeMBGAQ)_9-F79#2?{7p%5*!qraz< zm!H0t3N8|HH#a0i5+tVpf;%~hNg@zZVzNj{Cou(i1(2A$oV1**qdeSM288@A?tfo? zsXvYa=hXMY_`$s#op7B0C|D%g5yy+U%p4+#Pyk6eO38{zJIjN`WWfly7y^tdJV~$& zSRRB#$ik7}f3p9H=wIw6zHYcf1o!w;n|zrm%x8l*WYseB?bNx`0wiaTdu#Pz+VFYe^g!n z>Ybdta1B!+t~0t=2518CuUxtO;06KiLr6kMNI*bHModgZLP17BK~6?aPDw>eLrDds zA}6O|qyf^=GcYhvP+w(Yq-Ua~XQ00%!NbR`As{3rBqXJ$B&VeR%j2RK03-oq0aozw zfPgDNJbWPD#Q=aEfa@;tFPqeV^p-f2fDrEr5rBl46nEKx27rePosfuxoRIjkRmJt4 zSMUjdgj__l+!<3+>OK*f>vTNQ8fKAY)5P=)nsDE!tn!URMqU~77Cu=m3&+0C@(4`p za~v1z<kbZ9|%wdoGyJj)i@gYtTEk8qu$D$a73TtfAR##1*H`} z+_zKU(J17$oCMx|FrOdY7C$Fj*xqGgu9)bW0VB7iTs(hamET2Mjl8}quRg`Rmbj)+ z?lD$9;f#Kw#84$P=w)(m<7Ce`bl^~@h<kfqCywNUd)!mufd1goxG}(6#(i=J$jkSb|5T=QWF>_Q zny9aKBP3!;`<8@{Q1fPK?@DF3hs9)iSS*OAxj92i{V`To4<TaIf4%j+x2)jY_Rzs`MbkSie>w73S=s3$B)9$-J~GX(0V_BRn{ z)3n73N#(Do@phmMT~X;)mW*Fm%U;sv2Uw4+YS6qRg~mCH2(mT)^1nYqTQ_O72}{)& zd-Jhv67>*t5?p~9?&_w>lyK11+lnd|KTIIM#UbH0Ae5wBTpMrGh2=liiXqoG)&t*Q zVcZuGlXbp)$nhqwS-4J$JSR1G>uoa|iy zv|Sk&i?a_}mUJ3~9*h29AAgIIgF~C)scv7)uHywTO0SPrW}u9`h#_b39@5f|hjAgS zliuviRL`e%<-hU3s94nAn|3u%4!zT^PaX6I)4t}Yyfr2;8QD)?kio^aP8kW4rwdyF zl{FhUYwJoRx7J_H|32*bwc58pzxexb&%9z{L#2zV@d@OtV*kK?^aFMz2(I=vFEIG& z+D`rn^RuYaU8_30suW|Q3H4w8<(5nkJ^Vv?o2{@Lt~pjh(;5eT%%!?V^C_0MAcAjrlO{F3Z0}t^(LI_@u)3uL0T$`3gO&n!8>Ore4$LHD<;{>2#iRKIYlH&> z2^0%#W3~4WMH)1>#{xr>i0KO0Zx*mL#VARHG>5f}`Fv2mS#UF7?Y7R(vm%hj@>#_x zc2)Dep^eH&ENUHCueFGxDTRA z8ae=Q}(1XPA< zKcI17MEI`xKmQ)Tk9y?5ebz8o5;;#@$TNrPc1`-y%2f3nMJ#&f^(qNFQd%K!)&mZs zE@IEEbmw|zKhn>COhU9NF3bb6` z{7kN|EZP0p8nide;HDZ)tZ){_rH`rws4i!M_PVBc&w*YQs!LxOsx4v9@!5s?5me5Qd&55hGy2a zF33?UOR&+8Mo#Cx(-TIKjGNvd6=xStx!D z_|y4ky+M1}i^%JF{M`$Sbix(4`9Cjj7d-nY&PYUsM*AOAy(mQ&sT@6}VZj!)|4_33 z;G6qvGf40%$5QyK3xLEb6-kq=4#jO>&|?K5@7BZ#=9U)MqbYu|ff6O1Vb7Abfq{-z zOVhdTcP<`P17my$*auf14u+D4gJ7Nax+2o!>=92C9i$;$d2zEUPiso}h}ibR;m3!T zH4Z7SW*4thvR+$=>^5)HUB5r~HS7U_K~1}suduYz+m8<{8fjDbZ=WhL=2#8s#&!(YC_0GRD7r-UNFhYYLR#X#h1^FHd#*6t zj;IG@^o*q361V`gdt247R7W*8bYQNBbOnEiyo*{H6IG762|$mB^7P3W8RtAnG5`>x zF=>P-*G4JQ6#%&89ZMnJ3^I7>_2k_LD5P_=K^8dKjs_Tqya1r)jP;9Owqz=t7T=Y3 zAy>VVyZEl1CkYHyaXFA&J8EQ}<`^xu9>eJUoVQ7SNM6z-eSEL%!A7Hjn!HGl;_Y*-+p!>*zCk7#)H|Q);&J+1;qB?T)0;C=kuuyF1w}wh7E9H|~c4SLbfy z&Fc~5Z)%@8cGu)^AI-&#tDa-KYwO|@5SbruIj)^;ra`A%r6d^J$r)8hTP7+8ts>kR z6J#7IQX`nVMFbqP!TP3x=p)~f1K1JSbUhST!6*zS{V~CP8 z$AU)@>%V8W>^nn06^Su2v5bD_xZ9~L?-<}nKlN}@(b()o3{#bPtU~V?U$Np zG_`bCvr|6N3mbV^2$+dz8ZWNG9QmCKwa7k(OKcK-dEED~z!tqOz6wLqy$c>Q9Jw-)OU4vi z3yloztqO^wKIMTyPfWVfB6l>38yDV`22_a`W_5RG96^-|`R!U%xP$e-TZhk-4zKvT z?LSXjS~<#x| zx6K!k#_idt{5&(3s{?Ccfj&ns<%|q#2sg;Dy-D+Y(Ng7-W2UdAbI#R2?|auR|AW1O zIxBkP=3LV~CZ@{kfj%sW4~Xw*tj~U%GkPVe-GzH%5&3vKjRQNkon+J26U#x#i@e^^ z{j4Ivynkhi;;uF5i(|8!pG&6ZJV&b``L>_ikL*AY1WBaf`Fh6zHaYdnT(Pjnh`lbL zIJmU@dE^;1(8p!R zEm1}I*0j1h{aXQkJ%k?cS9EpCvY$3u<%460yF_4jSz4ZU#HjxS4pR1; z9ytlBP;MFGx`3=sQ?lo=s>P`nj%ElWhHej1=_2t<8#kKkc1u!ASoN%(XTV^}Pd~hV zy6Jj)Y81*62+Va0)HHWAn^B_L0%_91wOLodxS4v*W6B?nV_y`r#}j7|S#Xo99mm`- zjW_%}@>F`D^MpiaH&edZb-wlyuLEfEDCYhPO9jVxFx5n`Z=ux7C?2a@=%7o>4^;s# zWqw%PG*xgT)Oa{}j%A0fu!GsK=fyYzA|zIv|zKM;{sz`PByX zFXNuN{Zd8)yhn7g*4<9IbhEqWpHr1!2j2zzD}CBL;rn5FY*ye~s~^15l5`vJQzR<@ zyK51@L=>MJZa{#aZRF0c%&|Ddru(-cz!3XH1viO#@lYP<1>l{poI~EN#{tixaoZ87 ztRjmNx1^vOoV-mewlrqb1BYpC zkG>M-ec@fc`)-f4FxEP*qamMem1<;VE_j~tFi*f@dwI{Rud`+jqtqN4ZSaOwJ}`;6 zwzj45BeIk(PNM9^CPb`>eIw$d3&XRVJN^m^4Oa@omcJ?7HPPX9EZNY0!n4suUtiVv z%Y;3l*CHt(je2<>lM_T2p-8zhF5-b=gXBARUH~RK<&Bufo{g-m3e}aRtmpH@flJ*3 zL5T(FW>{XQY-smNZ}4l?H-w0;lN8;u&!97cJ829h3r7&|!>t|z(66k-w=2vYpY{E_q0PO^-y%a3 zwA6p~6{XR)ThGq0UAv#+kT5h4S;T+>%jWLHZOsp>@+Y-C;Mw}|ol5Y?=79jiuG30?r=qgjOC>u)K1%NPW2Sa#-buG1xVs$2jt zRej%X(UN1CLo6ekgv5;w*6(sXlh$>L+x}@g)eMRmu1z>ncDUB>(MewrF#xs&#pF4B zFuRp)&6)C?>sy!>q=7Ha@VgzrR!@{fMC z%zipzWuze!DmZJ?&MSEO<3WLLPLi+MIjkBDw#><69uSeLb51qS5|q($q0{&IXvbcj zSIxDwVOTp<%kE8{g*DdTQ^-WQ@7xR%JQ6dl=D7#%%W^6$7&x)ZjUi>Wivom-?rwb= zY2>A{X$~d2Y7Yn$x=m*w%9d@~;p<$v6f9T*i`rcYU-rW$M;lW65^;rhFFdsCc8}3Y z^*55?Ig6694`6=1Z|iTFdgr;pW?Kic<#q%9E)}`#RzXt^sa7RTPRf>xhqp~d79@K% zQ||7J3u1h6ElQa5v2jgPUFz|yO2=A?HuKhN0h6?O-H`l=CLofb-V3?Y)&opDZ5ash zuF|1urOh&Fk+%;bd2lrQ@>t}$7s1ZCSlF8S@&PP$5T%RR6OaVu%(IiTxP^#10@NbD+E^LgNa-%cy`i#buKyO%(7j4;N^XJQmQe61+5oT%2u**e$^@(d z?2;K)hyqsC@@o_P6XsY9da091=D19Nh`WXM^D9B>ezu$^U4VeBAj2PTQG9AwlkOy` zUi1HwxieKB{fhoO@t2twUtZpI;}LD4_D&usKG$X6{xcnw1&%ZX$Bzd;FpF_}J?URkvfL>XZ^%%dY*skmAx3_N>Ek!@>>IpZ#xMN3eM-({xxvUk z$U?uwQOz_*^x<^^mzP#>L-4}rfsq;G2ZVk8XCHZ=b^|$}IF`T%Tj~-l2Vhq-8IICH z9_ZW`o1-gYg%b}|2}+6At?1Zm_T#%`&#^ME?2CKotI*(HZ#GXVg@LUR{9t-$SgDyf z;i4~@&OOz|rma^#+ckw#AwHc(eZrw-MdXCXlkyNLJy9Rot!sORfwDUNz~^}2%v{&- z0S`l#xUyT6Pi$scrik7{=ZJeL(xNfx)wCU?6TI?@#+J_bE#h@HF~`M?{9%)U(_|_)RyP8{kAUOWhD-u(JD*)3ZZvftxjc_cQ z63&Y?SOeP>h-Y5W>)?Y($rzNy`^bnic_Lrg+;};A)F}EWvYseEV6N>ep&QMc9JOnF zw4A@hyp{S)^>b?LuY0N0_~{UFF0cX^2)tgL@wB$q=O)sBe&_di3~0p>QGnrn>=q9P z_6Vx`Bv1}B&%Pre!j4C$o34l?%&NVAD_Plx?Cg*E$~slWmnDoYbqwZ%i)El*>4A>v zT_=QqBAh8}TY*(PydydJcg2a;-)KWXQpU?b;pRHnodiYw#P>o@i-l*5$sK#IUTY6y zRF6GENN=Xxv~%;$HVN9o{l>_~F z6S64om1$lpgh_U~1aiBcxsw0Ih70ixNz24+IsTQGO|;r|w&kp&b(`jFspacNeb?ih z)XDY}vb{2Eb}_;p3IkQ6YEq90DTgW@#|B8jV8aaV6iQecgmGw4{$_cN{Ai`TYvMKJ z;M1Kz%}htLv4(Jz<|M5xM$l$98M1Mbqt*O$0Rw$u${2s-!^)~qZv@jqC8gZb*Gc}u zYq8=m&h9z`ego3v8t%_oERCs;?=1u^tq$L(Q^Vd8Wzk%*3?nU zXHx#!p2``_d|Q30DUg$79IUq%Wl%cwR?2PzaWF%HG7$mhA4S`GFR};s&@j4D@rf4s zXDU|IW-8LxjU5S3Z!k!MP%iY$W>qp-A5|8v%C2Nab7~1D5*#Nn1PUVZII&cM^|~dK zDSF*HQ){vMjvF~~JouCJWFrVpye+b;xAMB@$|L(8Lf58N8p$+}8dXhs;G`SNmpaF2DOr5z7= hO0LMOH^Ec%fVqpS6IhZifd8B=cFQoM7e*u=Zf5ZR) diff --git a/express/code/blocks/mini-editor/img/image6.jpg b/express/code/blocks/mini-editor/img/image6.jpg deleted file mode 100644 index 5d103597ca6dc2e5fe1f996bd73a23f00dacd2f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7768 zcmeHKc{tQVcEcD;NE^x!(J0xnld(2Qk0m99 z_f576(L;HyXV{Y&RgJ!tuK*G4D%(1NUFhA0L`Q37%=!NbiXLb1z;69Y%0pcvjAYG z6Adf|Qig7xIyH@C@2 z`g!ZgIiWBrm=I&Uub){o3GWbXeH<4ZfYb7pGtlSMjna+^3JJoKJtd=p0)xY}qx9sy zl4~>jyVJ^Yl3yX@06jVLAWt8>w)U1^vl$Z#muv5Wg&nTtfFc~HTr{q_TWB4!}$uETLM+}zS z<@EF-hLiQ=vTxWPOCL^1qw@ z4tM?&udk+}qOAG{{;%`j!N2?$;J?l@y;9zF<1cRg&s}AP<-3aim{>@P)Oxu`* z_aqZZyD`x>+RY3}2Qm>CAZZ)uNhT0ULAuKS8TG%l41@XaZ3_xg{%#%yqkW8s3lG8v zlg*FmGcAGp`FU&OP?`u;0zyNHnHHW(NEJ2GPlyfMtXqx}5_ z?k4X4efxRH4{7{UuAg%KkODsh{+V4r<@zB7ehB4=MoNB>}NA_kh_r*w{FrTzk2oKiqa60K5<&6&Pm)@d7NoAXZ+` zP6r?gFsn;eW{vu-v}C@S27*}j0PGOv>Jc6Q1P1Ne&BFQ(0RmW9d8NRrMtpXjq36=s z_zUZ$A3_JFj_z{pF7DR5-?^AWFdMMvE0;L$?$RIa8v?|_%gQIEY6Rxr83Xn)PhgID z0R!Mu=oeIPe_ZVI3vl*;W>;D@GOT8J#WZFT|AMe?UtXOk*2Fk5zpSZO9qKgDLi{b+ z?!i-&Nk>9Ezgqn?#J?}XOLhG4STiL;FnsUQRTurpa^zAA*1pR zxaCZ8+a-Kcbj?xj>S*t6TCpf#UOit%_Cr_fB zaO)+y=&*$F(5AXooqlfV%Rx=B!q#}a{`9Ct;`&e(uO&6I?o(&}lnwOb{4({z4gd|Q z-4?lvsV2#Gc@{qVO`UH^LuUuD>Oc$cx#HO3I}XmsbkUDCDEF;2d*;9CD2DXNyPY0@ z>#?*EjMzHlGO|kHF>WXs8|<<+1;*3Vudg+yWTjcd`rA{h=4248%gyChlnK<7P5+l) zc7Ox>uyzf}R? zI$8#)gY7&b)xHY0Znpy@#g9PrGz}UK|9U)@YTPuh zwoxc8FYbzRTpL@#4&F_CmZI>f`YEKklmJp_t!{%Am!}RY`JZ~!|7eQcss3uHaf&^6 zwT}z}P>Qs^0X2#N+o$5|F2vgFmoujKAb5L4uB04*G2k;T*+KKN7H&OG52r*+#xt@x zTZiznGr9dEn!Q?P&mCqjmJJZkCz|YU zAdf1Xi;l5kTT+bAezD>>m~xip*E~rt_Cxj3Dqk(%qLQN*iexx2&a+=gaN16^h2XHTe$hz(fe>v|kCg+(2_Fe(b45qdO= zu;0MCX-bY*QE3?tev8Yzz33jUV*-A4$<&_L$Wt7*D@XE>I9WOaVUOa-3e4S4?J=J| zcV$%9zEuLXR-iTGQACKgO+Ke-z=_l8*Qc98igkiG)MH3V){VHD&aFb2bEl)Em8#gi zX^27}_rliuQQ14XZO}WzuCT2`e)P%e;s>LS&xjl7Rv8OgdBJkBi%B@wGnZE@ljCy(>Q@HC^J4k?p-zRr>TmHzw1)-9$t-X~T+|s*jf{Ji*&L zkaqo(P)s8||2SO_r2J~2D|qaqQ+n@g+@nJu#V67gMr1HNfrSgk9Wj#Do>`ag-j$&W z`0%{D*&~lCWOb>T2#K3IUTZuhcqfO`kvy?N8S{*%ykOol zx){aDX@$Gv$*>aPz$MP!>B2;fap?<_mxJdgOMHq3Izlgq$y#uO*GHspQ&`%cQr zizQa?&Gyc~Thc5?Ej36DZ@|H&#-X*&iw~QpY&NPro}6O2yLz!S#6)-1Hcd+l#7af^ z@T})O%U-PI5GZB{AUpEU=p0(8pB6+Cy4EZGHCm)+FQQCVdTF(f3zppmD4kB3*v44L zJCD!Pp0{PEuyO=!=m@@a=c)F7#8z|Bm^VSCTg^~MCW%n_m?0VlmN_>W>?=qS1gCVq zOJ!*%)iI#59dC^_IDRd0zJ3coLCR66DZ&=UJ6DPgl^!3)#KPgoU7rXJL%-As-ia+A z6pYQI(tih$Z>Uq$8ij9Y(_`cvhTm{egL)YO={Mw7^s3RyGroB71PJ|Vs7@K3e7*{& zYfygrMT|wY#F5H{ylg3_p_5%o^LfwudK-eH&=VZ(w2D_uU*ZH@CRUvXzt}4}{e4Ti zBEsVJm;LD52`yQky%!3zx~0son26%7_(OX&6L~xKEP?GrAw`M_1d(a7Ee&D~YU4 zW@Fv$;OBEm=Q@jy{~GiDteH;5f`+uIp1AFrgsj)~fbEJ!f^{d1m3Fy@jm^2s#*YDb z;K-HzAA0Yn#>m`Tx2UHv6lN2qLD=dxNLxXari?;Oab#C$q}gl85)Jzzqc3xv{j*v9 zt=VEwp^rRP*<092jFsGkaTq~3J z7l@r|J#LYyG^2+4wCtqluloCZK9?t1cE$vgEH?t*-*XA2!(axdAF~7SIMin<9eIxC zb&s;3eF;5jDk%bO8d}XI3kywP7i)6#RR?^J8zwFph0 zCOU``fqE3ob7ZO;N!3)}G)Z6uM-AFuXG+*#b&lCNE3^k{AN`zO< zH4n7NYLG(BCWClvYsK|2_Bxe(#wHG64|nfsqmoZrpDHA5G@Nv(zA0o>tRCc4Upv!S zs=1Vov+R>MBD9$a)kRBD3UXBZsf9Pt2tVOnmCH{cstB-tu@khC6W>8dQke3 z0^ZP`M6vI+tVhc`>U&=34Ji(5j5=L2$+X?*4AHT|L9F%*N<&F7CE)1c_F3 zw%GQHTJc2sj&Q-Nm<#a%9svgquH~NK*9+u!e*I zr;)|q7uiJNSF(ts2*=aMMn9agd4e5a-)mqBQJT1GZrG7KhjVI0`b~i4GDVML8#M>{LIT95-8&(^@v(qg^U`$}@zCMZD(Bhdwf#0t9oAmMuz`V&{?AuauNp!G92trj1Jw3G zNxXFDmi(i7OWHjR@fX-@_1S9Xm37y=JqnB7xR+GkI1z69mcbdle6<(aa*}cM%w_F) zaVJfUdrhlNK-J@odjp`RA zX;~wAI`HmS+sBzdTq`rvnH{dwFzu>W<7^${*XN;VpDt$Njix;8QVP3o8t(+Sq*9Ep zJRUsJ&B@*8cqeEqk@$?|IbuF8e>_=~0k7SBY_rxiA@abiT3K`9OU_wkGG%y`+{9o;o8qXUqcmz*(bqTx8iR;VQY!^SI-jYtu5qlmt zyMH|a(eRmSn~LrH=tva}b8mKi7ry|5x^k6zXT0m{o{=3Sb;HUCyp<+C9<+nlXVPJvOxBvd==;b|NO8DTcFThK=iIZWDh=N;f*D{DD!F+r<`GwRrXiS!7msNrFh3kx_?N$zEBNi zvCh^H9hkoaaCV#dZZ?UrCI>u?Zx&i^C_R0a+L{29IJ!N!*e74X5Qx7o0p`cEvnx+K z>Dr6y6rP@ZX*u2WcoU+JR+J9(E*-hy&QEDJ?)Z2sckip~p%RAo1D+IJF+i7GYxhnV zv;W0}7?P7!TWR4(%FC+tGqoA?-vQ(YdDk{v2Z+W)?Q8qlJvoZ#l-j{8bi8alusCHd zD3BpCE#q}h6#Ve&(PsF5@xTN0GywKmADyV1AG&c=cV>ylap<%zOhfDVz@uZ1P77Qv z9VZ6rGy;sUD`JB29yr~@jynKqo7)qpo#^;d#-X5IvH#ow94!M&vTP{l76nGm;F&Tj$Xnz^?2GYZG z#vv}uGO3Q5jtuv}wM$pQH%!I_I+umeL+6PJpy{qX$&qE7?WyHgd_Bb6q|e#Pa~nx~ zsTD2MP}{Z}JSGt|M^!wKENu43{6XOqOP}=m<>J!&_KWWX$=CWp`h1J;1lAsxgoB$J zGu;$FFxUmdY|!#Fi}EMjJs6&giIy+!uRM*{6|Fsp*$<0)cYeWD0~YnZY#&v-?UYf+ zw(h8T-G!t#4yrT}`1JiY*3YHS5oLzkESnYgu)W?~1?H-orU>q984mTm-tl8DK5OUS VeOdT5-oh$a|9orq`sJ5hp8f21KkxhQWPh3Z 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 diff --git a/express/code/blocks/mini-editor/img/image8.jpg b/express/code/blocks/mini-editor/img/image8.jpg deleted file mode 100644 index c4646324ba2c432452115801bb867893332df49e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17965 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 diff --git a/express/code/blocks/mini-editor/img/image9.jpg b/express/code/blocks/mini-editor/img/image9.jpg deleted file mode 100644 index 5b34a598b14f1ab100512b95444d3b7f38ab1802..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19656 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

RY2EzD0C_tDul+DV;olQsApfVV=7;vq5_VgbJ- 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 diff --git a/express/code/blocks/mini-editor/mini-editor-background-loader.js b/express/code/blocks/mini-editor/mini-editor-background-loader.js index e4601baba..d14f9eb7e 100644 --- a/express/code/blocks/mini-editor/mini-editor-background-loader.js +++ b/express/code/blocks/mini-editor/mini-editor-background-loader.js @@ -2,17 +2,12 @@ * Mini Editor background loader * * Single entry point (`getCardBackgrounds`) that returns the background-card - * collection the mini-editor renders. It owns the decision of WHERE that - * collection comes from, so callers never branch on it: + * collection the mini-editor renders, by fetching live templates from the + * template service for the block's authored `collectionId`. * - * - A `collectionId` is authored on the block → fetch live templates from - * the template service (the dynamic source). - * - No `collectionId` → fall back to the bundled STATIC_COLLECTION shipped - * with this block (the 10 curated images under ./img). - * - * Every card — dynamic or static — 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. + * 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 { @@ -21,42 +16,6 @@ import { getImageThumbnailSrc, } from '../../scripts/template-utils.js'; -/** - * The curated fallback set, used when no collectionId is authored. Each entry - * pairs a bundled image with its Adobe asset urn so downstream actions can - * recover the source id from a card the same way they would for a fetched - * template. `file` is resolved against codeRoot at read time (see - * resolveStaticCollection) so it works regardless of deploy path. - * - * Keep `id` and `file` in lockstep — the id is what a todo/CTA action will - * look up for a given image, so the pairing here is the source of truth. - */ -const STATIC_COLLECTION = [ - { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25650', file: 'image1.jpg' }, - { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25651', file: 'image2.jpg' }, - { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25652', file: 'image3.jpg' }, - { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25653', file: 'image4.jpg' }, - { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25654', file: 'image5.jpg' }, - { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25655', file: 'image6.jpg' }, - { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25656', file: 'image7.jpg' }, - { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25657', file: 'image8.jpg' }, - { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25658', file: 'image9.jpg' }, - { id: 'urn:aaid:sc:VA6C2:79e88577-0d8f-5ad7-88ef-f38f03c25659', file: 'image10.jpg' }, -]; - -const STATIC_IMG_BASE = '/blocks/mini-editor/img'; - -/** - * Turns the STATIC_COLLECTION into runtime cards ({ id, bg }), resolving each - * bundled image against codeRoot and honouring the block's `limit`. - */ -function resolveStaticCollection(props) { - const base = `${props.codeRoot || ''}${STATIC_IMG_BASE}`; - return STATIC_COLLECTION - .slice(0, props.limit) - .map(({ id, file }) => ({ id, bg: `${base}/${file}` })); -} - function buildRecipe(props) { const params = new URLSearchParams(); params.set('limit', String(props.limit)); @@ -65,7 +24,17 @@ function buildRecipe(props) { return params.toString(); } -async function fetchTemplateCollection(props) { +/** + * 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 []; @@ -84,22 +53,3 @@ async function fetchTemplateCollection(props) { }) .filter((card) => !!card.bg); } - -/** - * Returns the mini-editor's background-card collection as `[{ id, bg }, ...]`. - * Picks the dynamic template fetch when a collectionId is authored, otherwise - * the bundled static collection — the caller does not need to know which. - * - * @param {Object} props - * @param {string} [props.collectionId] — when set, fetch live templates. - * @param {number} props.limit — max cards to return. - * @param {string} [props.topics] — template-fetch topics filter. - * @param {string} [props.codeRoot] — base path for resolving static images. - * @returns {Promise>} - */ -export default async function getCardBackgrounds(props) { - if (!props.collectionId) { - return resolveStaticCollection(props); - } - return fetchTemplateCollection(props); -} diff --git a/express/code/blocks/mini-editor/mini-editor.js b/express/code/blocks/mini-editor/mini-editor.js index 0464bb667..bb7a70526 100644 --- a/express/code/blocks/mini-editor/mini-editor.js +++ b/express/code/blocks/mini-editor/mini-editor.js @@ -158,10 +158,6 @@ export default async function init(block) { loadStyle(`${getConfig().codeRoot}/scripts/widgets/mini-editor-widget/mini-editor-widget.css`); const props = constructProps(block); - // codeRoot lets the background loader resolve bundled static images against - // the deploy path when no collectionId is authored (see - // mini-editor-background-loader). - props.codeRoot = getConfig().codeRoot; block.innerHTML = ''; const header = buildContentHeader(props); @@ -171,8 +167,9 @@ export default async function init(block) { const quotes = getPageQuotes(); try { - // Backgrounds and fonts load in parallel — each loader owns its own - // source selection (static vs fetched cards / Typekit vs fallback fonts). + // 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(), diff --git a/test/blocks/mini-editor/mini-editor-background-loader.test.js b/test/blocks/mini-editor/mini-editor-background-loader.test.js index 9d3bf22de..b3bc1cbe1 100644 --- a/test/blocks/mini-editor/mini-editor-background-loader.test.js +++ b/test/blocks/mini-editor/mini-editor-background-loader.test.js @@ -23,91 +23,66 @@ describe('mini-editor-background-loader', () => { fetchStub?.restore(); }); - describe('static collection (no collectionId)', () => { - it('returns the bundled static cards without calling fetch', async () => { - fetchStub = sinon.stub(window, 'fetch'); - const cards = await getCardBackgrounds({ limit: 8, codeRoot: '/express/code' }); - expect(fetchStub.called).to.be.false; - expect(cards).to.have.length(8); - cards.forEach((card) => { - expect(card.id).to.match(/^urn:aaid:sc:VA6C2:/); - expect(card.bg).to.equal(`/express/code/blocks/mini-editor/img/${card.bg.split('/').pop()}`); - }); - }); - - it('honours the limit and resolves image paths against codeRoot', async () => { - const cards = await getCardBackgrounds({ limit: 3, codeRoot: '/foo' }); - expect(cards).to.have.length(3); - expect(cards[0].bg).to.equal('/foo/blocks/mini-editor/img/image1.jpg'); - }); - - it('resolves against an empty base when codeRoot is not provided', async () => { - const cards = await getCardBackgrounds({ limit: 1 }); - expect(cards[0].bg).to.equal('/blocks/mini-editor/img/image1.jpg'); - }); - - it('caps at the full static collection size even if limit exceeds it', async () => { - const cards = await getCardBackgrounds({ limit: 50, codeRoot: '' }); - expect(cards).to.have.length(10); - }); + 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' }, + ]); }); - describe('dynamic collection (collectionId set)', () => { - 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('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('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 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('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('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.test.js b/test/blocks/mini-editor/mini-editor.test.js index 4ad1d7096..33da8f7b0 100644 --- a/test/blocks/mini-editor/mini-editor.test.js +++ b/test/blocks/mini-editor/mini-editor.test.js @@ -7,15 +7,31 @@ 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?.() }; - // getCardBackgrounds only calls fetch when a collectionId is authored — - // none of these tests author one, so this stub exists purely as a safety - // net against an accidental real network call. - fetchStub = sinon.stub(window, 'fetch').resolves({ json: async () => ({ items: [] }) }); + fetchStub = sinon.stub(window, 'fetch').resolves({ json: async () => ({ items: defaultTemplateItems }) }); }); afterEach(() => { @@ -102,7 +118,7 @@ describe('mini-editor', () => { block.append(limitRow); await init(block); await waitFor(() => !!block.querySelector('.mini-editor-decorations')); - // Default TEMPLATE_LIMIT (8) static cards minus the one powering the main + // 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); }); From 7c604040282e57dfecb4721e27c029351ff5e6d9 Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Wed, 12 Aug 2026 14:58:52 +0530 Subject: [PATCH 10/21] Use css variables --- .../code/blocks/mini-editor/mini-editor.css | 12 +- express/code/scripts/utils/copy-toast.css | 20 +-- .../mini-editor-widget/mini-editor-widget.css | 123 +++++++++--------- express/code/styles/styles.css | 13 ++ 4 files changed, 91 insertions(+), 77 deletions(-) diff --git a/express/code/blocks/mini-editor/mini-editor.css b/express/code/blocks/mini-editor/mini-editor.css index d6deaca71..7fd6e72b8 100644 --- a/express/code/blocks/mini-editor/mini-editor.css +++ b/express/code/blocks/mini-editor/mini-editor.css @@ -70,11 +70,11 @@ .mini-editor-header h1, .mini-editor-header h2 { margin: 0; - font-size: var(--heading-font-size-l, 2.25rem); - font-weight: var(--heading-font-weight-extra, 900); + font-size: var(--heading-font-size-l); + font-weight: var(--heading-font-weight-extra); line-height: 32px; letter-spacing: -1px; - color: #222; + color: var(--color-content-neutral); } /* margin: 0 needs !important to beat main .section .content p / main .section p @@ -83,8 +83,8 @@ .mini-editor-header p { margin: 0 !important; max-width: 437px; - font-size: var(--body-font-size-m, 1rem); - color: var(--color-typography-body, #505050); + 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 @@ -143,7 +143,7 @@ .mini-editor-header h1, .mini-editor-header h2 { line-height: 1.04; - color: #131313; + color: var(--Alias-content-typography-Heading); } .mini-editor-header p { diff --git a/express/code/scripts/utils/copy-toast.css b/express/code/scripts/utils/copy-toast.css index 0e0624d21..49a178fd2 100644 --- a/express/code/scripts/utils/copy-toast.css +++ b/express/code/scripts/utils/copy-toast.css @@ -1,28 +1,28 @@ .copy-toast-container { position: fixed; - bottom: 24px; + bottom: var(--spacing-400); left: 50%; transform: translateX(-50%); z-index: 10000; display: flex; flex-direction: column; align-items: center; - gap: 8px; + gap: var(--spacing-100); pointer-events: none; } .copy-toast { display: flex; align-items: center; - gap: 8px; + gap: var(--spacing-100); box-sizing: border-box; - padding: 12px 8px 12px 16px; - background: #05834e; + padding: var(--spacing-200) var(--spacing-100) var(--spacing-200) var(--spacing-300); + background: var(--color-green-900); border-radius: 10px; - color: #fff; + color: var(--color-white); font-family: var(--body-font-family); - font-size: 14px; - line-height: 18px; + 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; @@ -43,7 +43,7 @@ flex-shrink: 0; width: 18px; height: 18px; - background-color: #fff; + background-color: var(--color-white); -webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 16.875C4.65732 16.875 1.125 13.3427 1.125 9C1.125 4.65732 4.65732 1.125 9 1.125C13.3427 1.125 16.875 4.65732 16.875 9C16.875 13.3427 13.3427 16.875 9 16.875ZM9 2.475C5.40175 2.475 2.475 5.40175 2.475 9C2.475 12.5982 5.40175 15.525 9 15.525C12.5982 15.525 15.525 12.5982 15.525 9C15.525 5.40175 12.5982 2.475 9 2.475Z' fill='%23000'/%3E%3Cpath d='M8.29813 12.1526C8.10741 12.1526 7.92459 12.0717 7.79627 11.9293L5.5366 9.41653C5.287 9.1388 5.30985 8.71252 5.5867 8.46292C5.86354 8.21331 6.28982 8.23616 6.54031 8.51301L8.2445 10.4088L11.3831 6.12854C11.6019 5.82796 12.0229 5.76028 12.3261 5.98353C12.6267 6.20325 12.6918 6.62601 12.4712 6.92659L8.84215 11.8766C8.72262 12.0409 8.53453 12.142 8.33151 12.1517C8.32096 12.1526 8.30956 12.1526 8.29813 12.1526Z' fill='%23000'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml;utf8,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 16.875C4.65732 16.875 1.125 13.3427 1.125 9C1.125 4.65732 4.65732 1.125 9 1.125C13.3427 1.125 16.875 4.65732 16.875 9C16.875 13.3427 13.3427 16.875 9 16.875ZM9 2.475C5.40175 2.475 2.475 5.40175 2.475 9C2.475 12.5982 5.40175 15.525 9 15.525C12.5982 15.525 15.525 12.5982 15.525 9C15.525 5.40175 12.5982 2.475 9 2.475Z' fill='%23000'/%3E%3Cpath d='M8.29813 12.1526C8.10741 12.1526 7.92459 12.0717 7.79627 11.9293L5.5366 9.41653C5.287 9.1388 5.30985 8.71252 5.5867 8.46292C5.86354 8.21331 6.28982 8.23616 6.54031 8.51301L8.2445 10.4088L11.3831 6.12854C11.6019 5.82796 12.0229 5.76028 12.3261 5.98353C12.6267 6.20325 12.6918 6.62601 12.4712 6.92659L8.84215 11.8766C8.72262 12.0409 8.53453 12.142 8.33151 12.1517C8.32096 12.1526 8.30956 12.1526 8.29813 12.1526Z' fill='%23000'/%3E%3C/svg%3E"); -webkit-mask-size: contain; @@ -77,7 +77,7 @@ content: ''; width: 12px; height: 12px; - background-color: #fff; + background-color: var(--color-white); -webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L11 11M11 1L1 11' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml;utf8,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L11 11M11 1L1 11' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); -webkit-mask-size: contain; 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 index e51cc0a91..2a93b7b81 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css @@ -32,7 +32,7 @@ margin-bottom: var(--spacing-300); box-sizing: border-box; padding: var(--spacing-350); - border-radius: 16px; + border-radius: var(--Radius-corner-radius-200); background-image: var(--me-card-bg); background-size: cover; background-position: center; @@ -67,11 +67,11 @@ font-family: var(--me-quote-font); font-style: var(--me-quote-font-style); font-weight: var(--me-quote-font-weight); - font-size: 20px; - line-height: 26px; + 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: #131313; + color: var(--Alias-content-typography-Heading); } .me-author { @@ -88,10 +88,10 @@ font-family: var(--me-quote-font); font-style: var(--me-quote-font-style); font-weight: var(--me-quote-font-weight); - font-size: 16px; + font-size: var(--Global-Typography-Size-Label-Label-L); text-align: center; white-space: nowrap; - color: #505050; + color: var(--Alias-content-typography-Body); } .me-tip { @@ -114,12 +114,12 @@ .me-tip-box { display: block; max-width: 160px; - padding: 4px 10px; - border-radius: 7px; - background: #292929; - color: #fff; - font-size: 12px; - line-height: 16px; + 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; } @@ -151,9 +151,9 @@ align-items: center; gap: var(--spacing-100); box-sizing: border-box; - background: var(--color-gray-150, #f3f3f3); - border: 1px solid rgb(0 0 0 / 3%); - border-radius: 16px; + 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); @@ -167,7 +167,7 @@ .me-control:hover, .me-control[aria-expanded='true'] { - background: var(--color-gray-300-variant, #dadada); + background: var(--S2AC-Palette-gray-300); } .me-pill { @@ -183,14 +183,15 @@ height: 46px; line-height: 46px; padding: 0 var(--spacing-200); - background: #fff; + background: var(--color-white); border-radius: 10px; - box-shadow: 0 1px 2px rgb(0 0 0 / 4%); - font-size: 16px; + 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 { @@ -199,17 +200,17 @@ height: 34px; flex-shrink: 0; border-radius: 10px; - border: 1px solid rgb(255 255 255 / 85%); + border: 1px solid var(--Palette-transparent-white-800); background-size: cover; background-position: center; - box-shadow: 0 1px 4px rgb(0 0 0 / 4%); + box-shadow: var(--Drop-shadow-emphasized-default); } .me-control-label { flex-shrink: 0; - font-weight: var(--heading-font-weight-medium, 700); - font-size: 14px; - color: #292929; + 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; } @@ -230,9 +231,9 @@ width: 100%; height: 58px; box-sizing: border-box; - background: var(--color-gray-150, #f3f3f3); - border: 1px solid rgb(0 0 0 / 3%); - border-radius: 16px; + 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); } @@ -260,16 +261,16 @@ padding: 0 var(--spacing-200); border: none; background: transparent; - border-radius: var(--corner-radius-80, 6px); - font-size: 16px; + border-radius: var(--corner-radius-80); + font-size: var(--Global-Typography-Size-Label-Label-L); white-space: nowrap; cursor: pointer; } .me-font.is-selected { - background: #fff; + background: var(--color-white); border-radius: 10px; - box-shadow: 0 1px 2px rgb(0 0 0 / 4%); + box-shadow: var(--Drop-shadow-emphasized-default); } .me-row--colour { @@ -290,11 +291,11 @@ width: 28px; height: 28px; padding: 0; - border-radius: 999px; - border: 1px solid rgb(255 255 255 / 10%); + border-radius: var(--Corner-radius-corner-radius-500); + border: 1px solid var(--S2AC-Palette-transparent-white-100); background: none; cursor: pointer; - box-shadow: 0 1px 4px rgb(0 0 0 / 4%); + box-shadow: var(--Drop-shadow-emphasized-default); transition: transform 0.12s ease; } @@ -303,14 +304,14 @@ } .me-swatch-btn.is-selected { - border: 2px solid #3b63fb; - box-shadow: 0 2px 6px rgb(0 0 0 / 4%), 0 4px 12px rgb(0 0 0 / 8%); + 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: 999px; + border-radius: var(--Corner-radius-corner-radius-500); background-size: cover; background-position: center; } @@ -361,11 +362,11 @@ * the editor's bottom edge. */ .me-deco--1 { left: calc(50% - 811px); bottom: 496px; transform: rotate(1.5deg); } -.me-deco--2 { left: calc(50% - 541px); bottom: 360px; transform: rotate(-1.5deg); } +.me-deco--2 { left: calc(50% - 541px); bottom: 360px; transform: rotate(1.5deg); } .me-deco--3 { left: calc(50% - 811px); bottom: 160px; transform: rotate(2deg); } .me-deco--4 { left: calc(50% - 541px); bottom: 0; transform: rotate(-2deg); } .me-deco--5 { left: calc(50% + 631px); bottom: 496px; transform: rotate(-1.5deg); } -.me-deco--6 { left: calc(50% + 361px); bottom: 360px; transform: rotate(1.5deg); } +.me-deco--6 { left: calc(50% + 361px); bottom: 360px; transform: rotate(-1.5deg); } .me-deco--7 { left: calc(50% + 631px); bottom: 160px; transform: rotate(-2deg); } .me-deco--8 { left: calc(50% + 361px); bottom: 0; transform: rotate(2deg); } @@ -380,7 +381,7 @@ height: 148px; box-sizing: border-box; padding: 11px; - border: 0.5px solid rgb(255 255 255 / 66%); + border: 0.5px solid var(--Palette-transparent-white-700); border-radius: 17px; background-size: cover; background-position: center; @@ -422,12 +423,12 @@ p.me-deco-quote { -webkit-box-pack: center; margin: 0; overflow: hidden; - color: var(--Alias-Content-Typography-Heading, #131313); + color: var(--Alias-content-typography-Heading); text-align: center; font-family: var(--body-font-family); - font-size: 12px; + font-size: var(--Global-Typography-Size-Label-Label-S); font-style: normal; - font-weight: 600; + font-weight: var(--heading-font-weight-regular); line-height: 1.25; word-break: break-word; align-content: center; @@ -449,10 +450,10 @@ p.me-deco-author { max-width: calc(100% - 22px); overflow: hidden; margin: 0; - color: var(--Alias-Content-Typography-Heading, #131313); + color: var(--Alias-content-typography-Heading); text-align: center; font-family: var(--body-font-family); - font-size: 8px; + font-size: 9px; font-style: normal; font-weight: 600; white-space: nowrap; @@ -487,10 +488,10 @@ p.me-deco-author { .me-deco-copy { height: 32px; box-sizing: border-box; - background: #e9e9e9; + background: var(--Palette-gray-100); border: none; - border-radius: 8px; - color: #292929; + border-radius: var(--Corner-radius-corner-radius-100); + color: var(--Alias-content-neutral-default); cursor: pointer; transition: background-color 0.15s ease; } @@ -499,7 +500,7 @@ p.me-deco-author { display: inline-flex; align-items: center; padding: 7px 12px; - font-size: 14px; + font-size: var(--Global-Typography-Size-Label-Label-M); white-space: nowrap; } @@ -594,8 +595,8 @@ p.me-deco-author { height: var(--me-arc-card-h); box-sizing: border-box; padding: var(--spacing-350); - border: 0.5px solid rgb(255 255 255 / 66%); - border-radius: 16px; + 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; @@ -681,11 +682,11 @@ p.me-deco-author { .me-arc-quote { margin: 0; - font-size: 16px; + font-size: var(--Global-Typography-Size-Label-Label-L); line-height: 1.2; text-align: center; word-break: break-word; - color: #131313; + color: var(--Alias-content-typography-Heading); } .me-arc-author { @@ -694,9 +695,9 @@ p.me-deco-author { bottom: var(--spacing-200); transform: translateX(-50%); margin: 0; - font-size: 12px; + font-size: var(--Global-Typography-Size-Label-Label-S); white-space: nowrap; - color: #505050; + color: var(--Alias-content-typography-Body); } .me-arc-nav { @@ -712,7 +713,7 @@ p.me-deco-author { transform: translateY(-50%); background: rgb(0 0 0 / 22%); border: 1px solid rgb(255 255 255 / 20%); - border-radius: 999px; + border-radius: var(--Corner-radius-corner-radius-500); cursor: pointer; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); @@ -758,7 +759,7 @@ p.me-deco-author { display: flex; flex-direction: column; gap: var(--spacing-200); - background: #fff; + background: var(--color-white); border-radius: 24px 24px 0 0; padding: var(--spacing-100) var(--spacing-300) var(--spacing-400); transform: translateY(100%); @@ -780,8 +781,8 @@ p.me-deco-author { .me-sheet-title { margin: 0; - font-weight: var(--heading-font-weight-medium, 700); - font-size: 16px; + font-weight: var(--heading-font-weight-medium); + font-size: var(--Global-Typography-Size-Label-Label-L); text-align: center; } @@ -827,11 +828,11 @@ p.me-deco-author { } .me-quote { - font-size: 16px; + font-size: var(--Global-Typography-Size-Label-Label-L); } .me-arc-quote { - font-size: 14px; + font-size: var(--Global-Typography-Size-Label-Label-M); } .me-arc-nav { 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); From 9c929459f768af46805e365af653bb9e2cc21401 Mon Sep 17 00:00:00 2001 From: Vineet Sharma Date: Thu, 13 Aug 2026 14:33:16 +0530 Subject: [PATCH 11/21] add Express mini-editor actions UI, functinality to follow --- .../code/blocks/mini-editor/mini-editor.css | 14 ++++ .../code/blocks/mini-editor/mini-editor.js | 25 +++++- .../mini-editor-widget/mini-editor-widget.css | 66 +++++++++++++++ .../mini-editor-widget/mini-editor-widget.js | 80 ++++++++++++++++--- .../mini-editor-widget/mini-editor-widget.md | 12 ++- .../widgets/mini-editor-widget.test.js | 57 +++++++++++++ 6 files changed, 236 insertions(+), 18 deletions(-) diff --git a/express/code/blocks/mini-editor/mini-editor.css b/express/code/blocks/mini-editor/mini-editor.css index 7fd6e72b8..feca380af 100644 --- a/express/code/blocks/mini-editor/mini-editor.css +++ b/express/code/blocks/mini-editor/mini-editor.css @@ -45,6 +45,20 @@ 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; diff --git a/express/code/blocks/mini-editor/mini-editor.js b/express/code/blocks/mini-editor/mini-editor.js index bb7a70526..e2b62ee90 100644 --- a/express/code/blocks/mini-editor/mini-editor.js +++ b/express/code/blocks/mini-editor/mini-editor.js @@ -160,8 +160,19 @@ export default async function init(block) { 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); - block.append(header); + themeHost.append(header); decorateCta(header); const quotes = getPageQuotes(); @@ -182,8 +193,14 @@ export default async function init(block) { const editor = await createMiniEditorWidget({ root: block, - // No top action bar in the current design — reserved by the widget API. - topActions: [], + // Placeholder handlers — real edit/share/download behavior (deep-link + // to the Express editor, Web Share API, image export) is follow-up + // work; this only wires the widget's hover action bar to something. + 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: () => console.info('mini-editor: download action not yet implemented') }, + ], fontOptions, backgrounds: { cardSet, decoCount: DECO_CARD_COUNT }, a11y: { @@ -200,7 +217,7 @@ export default async function init(block) { // 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); - block.append(editor.stage); + themeHost.append(editor.stage); } catch (error) { window.lana?.log(`Error in mini-editor: ${error?.message || error}`, { tags: 'mini-editor', 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 index 2a93b7b81..630dd8e0b 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css @@ -45,6 +45,62 @@ transition: background-image 0.35s ease; } +/* 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; @@ -577,6 +633,16 @@ p.me-deco-author { 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 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 index ff0292df5..9ce1ca371 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js @@ -11,8 +11,13 @@ * event wiring, so a block only has to fetch data and mount the result. * * UI/UX is intentionally identical to the original in-block implementation — - * this widget is an extraction of that surface, not a redesign. It does NOT - * depend on Spectrum Web Components. + * 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 @@ -21,7 +26,11 @@ * const editor = await createMiniEditorWidget({ * root: block, // element the widget sets state attrs/vars on * content: headerEl, // authored heading/subcopy/CTA lockup - * topActions: [], // reserved (none rendered yet) + * 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 }, ...], @@ -281,7 +290,46 @@ function buildBottomSheet(root, a11y, kind, title, contentEl) { return { overlay, onPanelChange }; } -function buildWidget(root, a11y, cardSet, fontOptions) { +// 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) { const widget = createTag('div', { class: 'mini-editor-widget' }); const card = createTag('div', { class: 'me-card' }); @@ -312,6 +360,12 @@ function buildWidget(root, a11y, cardSet, fontOptions) { 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 () => { const ok = await a11y.copyQuoteToClipboard(quoteEl.textContent, authorEl.textContent); if (ok) { @@ -741,8 +795,9 @@ function buildArcCarousel(cardSet, useQuote, defaultFont) { * @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=[]] — reserved top-bar action descriptors; - * none are rendered yet (the current design has no top action bar). + * @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 }, ...]`. @@ -756,17 +811,22 @@ function buildArcCarousel(cardSet, useQuote, defaultFont) { export default async function createMiniEditorWidget(config = {}) { const { root, + topActions = [], fontOptions, backgrounds, a11y, deps, } = config; - // topActions is accepted for API parity with the proposed widget (PR #680) - // but the current design has no top action bar, so it is intentionally not - // read here — callers pass []. ({ createTag, getIconElementDeprecated } = deps); + // topActions' icons are real Spectrum Web Components custom elements + // (sp-icon-*, see TOP_ACTION_DEFS) — only loaded when actually used, so + // callers that don't pass topActions don't pay for the Spectrum bundle. + if (topActions.length) { + await import('../spectrum/dist/icons-workflow.js'); + } + const { cardSet } = backgrounds; const decoCount = backgrounds.decoCount ?? DECO_CARD_COUNT; @@ -780,7 +840,7 @@ export default async function createMiniEditorWidget(config = {}) { const stage = createTag('div', { class: 'mini-editor-stage' }); const { widget, useQuote, onFontOrColourChange, destroy: destroyWidget, - } = buildWidget(root, a11y, cardSet, fontOptions); + } = buildWidget(root, a11y, cardSet, fontOptions, topActions); const decorations = buildDecoCards(a11y, cardSet, useQuote); const { root: arcCarousel, updateCentre } = buildArcCarousel(arcCardSet, useQuote, fontOptions[0]); onFontOrColourChange(updateCentre); 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 index a72505558..0b3bb296f 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.md +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.md @@ -23,8 +23,8 @@ and the cross-block `mini-editor:use-quote` event wiring. > 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` is accepted for API parity and is currently unused -> (the present design has no top action bar) — pass `[]`. +> rendering. `topActions` now renders the top-right hover action bar (Figma +> node 1099-5050) — see the Config table below. ## Usage @@ -33,7 +33,11 @@ import createMiniEditorWidget from '../../scripts/widgets/mini-editor-widget/min const editor = await createMiniEditorWidget({ root: block, // element the widget sets state attrs / CSS vars / mode class on - topActions: [], // reserved; none rendered yet + 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 }, ...], @@ -64,7 +68,7 @@ loadStyle(`${getConfig().codeRoot}/scripts/widgets/mini-editor-widget/mini-edito | 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` | Reserved top-bar action descriptors, mirroring PR #680. **Not rendered yet** — pass `[]`. | +| `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 }`. | diff --git a/test/scripts/widgets/mini-editor-widget.test.js b/test/scripts/widgets/mini-editor-widget.test.js index 99638a5d7..5bb7cdda1 100644 --- a/test/scripts/widgets/mini-editor-widget.test.js +++ b/test/scripts/widgets/mini-editor-widget.test.js @@ -209,6 +209,63 @@ describe('mini-editor-widget', () => { }); }); + 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(); From 877177dd99e6fd96b9a4696bedf2681941819045 Mon Sep 17 00:00:00 2001 From: Vineet Sharma Date: Thu, 13 Aug 2026 14:33:16 +0530 Subject: [PATCH 12/21] add Express mini-editor actions UI, functinality to follow From fd3230dd57952b848bf3bb9af8db601a1924bee0 Mon Sep 17 00:00:00 2001 From: Vineet Sharma Date: Mon, 10 Aug 2026 20:26:19 +0530 Subject: [PATCH 13/21] add html to canvas download utils for using in mini-editor --- express/code/libs/deps/README.md | 56 ++++- express/code/libs/deps/html2canvas-all.min.js | 7 + express/code/libs/deps/html2canvas.js | 3 + express/code/scripts/utils/README.md | 46 ++++ express/code/scripts/utils/download-utils.js | 207 ++++++++++++++++++ package-lock.json | 97 ++++++-- package.json | 1 + test/scripts/utils/download-utils.test.js | 184 ++++++++++++++++ 8 files changed, 580 insertions(+), 21 deletions(-) create mode 100644 express/code/libs/deps/html2canvas-all.min.js create mode 100644 express/code/libs/deps/html2canvas.js create mode 100644 express/code/scripts/utils/download-utils.js create mode 100644 test/scripts/utils/download-utils.test.js 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/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/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/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$/); + }); + }); +}); From 56fa4e6217c592b80ad54f94c4e2832f77fb214f Mon Sep 17 00:00:00 2001 From: Vineet Sharma Date: Fri, 14 Aug 2026 12:48:08 +0530 Subject: [PATCH 14/21] Integrate download utils with mini-editor and improve performance using worker --- .../code/blocks/mini-editor/mini-editor.js | 39 ++++- .../scripts/utils/mini-editor-card-export.js | 149 ++++++++++++++++++ .../utils/mini-editor-card-renderer.js | 122 ++++++++++++++ .../scripts/utils/mini-editor-card-worker.js | 28 ++++ .../mini-editor-widget/mini-editor-widget.js | 54 ++++++- test/blocks/mini-editor/mini-editor.test.js | 59 ++++++- .../utils/mini-editor-card-export.test.js | 58 +++++++ .../utils/mini-editor-card-renderer.test.js | 51 ++++++ .../widgets/mini-editor-widget.test.js | 41 ++++- 9 files changed, 590 insertions(+), 11 deletions(-) create mode 100644 express/code/scripts/utils/mini-editor-card-export.js create mode 100644 express/code/scripts/utils/mini-editor-card-renderer.js create mode 100644 express/code/scripts/utils/mini-editor-card-worker.js create mode 100644 test/scripts/utils/mini-editor-card-export.test.js create mode 100644 test/scripts/utils/mini-editor-card-renderer.test.js diff --git a/express/code/blocks/mini-editor/mini-editor.js b/express/code/blocks/mini-editor/mini-editor.js index e2b62ee90..fdaf66a5a 100644 --- a/express/code/blocks/mini-editor/mini-editor.js +++ b/express/code/blocks/mini-editor/mini-editor.js @@ -6,6 +6,8 @@ import { 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 getCardBackgrounds from './mini-editor-background-loader.js'; import getFontOptions from './mini-editor-fonts-loader.js'; @@ -13,6 +15,7 @@ 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; @@ -34,6 +37,34 @@ async function copyQuoteToClipboard(quote, author) { } } +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 @@ -155,6 +186,7 @@ function buildContentHeader(props) { 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`); const props = constructProps(block); @@ -193,13 +225,12 @@ export default async function init(block) { const editor = await createMiniEditorWidget({ root: block, - // Placeholder handlers — real edit/share/download behavior (deep-link - // to the Express editor, Web Share API, image export) is follow-up - // work; this only wires the widget's hover action bar to something. + // 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: () => console.info('mini-editor: download action not yet implemented') }, + { type: 'download', onClick: () => downloadCard(block, editor) }, ], fontOptions, backgrounds: { cardSet, decoCount: DECO_CARD_COUNT }, 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..216bf9fa4 --- /dev/null +++ b/express/code/scripts/utils/mini-editor-card-export.js @@ -0,0 +1,149 @@ +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() { + if (!window.Worker || !window.OffscreenCanvas || !window.createImageBitmap) return false; + try { + return !!new OffscreenCanvas(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..0a7bd55ef --- /dev/null +++ b/express/code/scripts/utils/mini-editor-card-worker.js @@ -0,0 +1,28 @@ +/* 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()); + const canvas = new 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-widget/mini-editor-widget.js b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js index 9ce1ca371..537fab55e 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js @@ -332,6 +332,25 @@ function buildMiniEditorActions(topActions = []) { function buildWidget(root, a11y, cardSet, fontOptions, topActions) { 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', @@ -340,7 +359,6 @@ function buildWidget(root, a11y, cardSet, fontOptions, topActions) { 'aria-describedby': 'me-quote-wrap-hint', }); const quoteEl = createTag('p', { class: 'me-quote' }); - const first = cardSet[0] || { quote: '', author: '' }; quoteEl.textContent = first.quote; // aria-describedby (not aria-label) so the accessible name stays the @@ -442,13 +460,40 @@ function buildWidget(root, a11y, cardSet, fontOptions, topActions) { 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', + }, + } : {}), + }); quoteEl.textContent = quote; authorEl.textContent = author || ''; authorEl.style.display = author ? '' : 'none'; if (bgCard) selectSwatch(bgCard.bg); if (font) selectFont(font); }, - onFontOrColourChange: (listener) => { onFontOrColourPick = listener; }, + 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); @@ -805,7 +850,7 @@ function buildArcCarousel(cardSet, useQuote, defaultFont) { * own: `{ trapFocus, handleEscapeClose, disableBackgroundScroll, * restoreBackgroundScroll, copyQuoteToClipboard }`. * @param {Object} [config.deps] — `{ createTag, getIconElementDeprecated }`. - * @returns {Promise<{ stage, decorations, useQuote, updateCentre, + * @returns {Promise<{ stage, decorations, useQuote, updateCentre, getContentModel, * syncViewportMode, destroy }>} */ export default async function createMiniEditorWidget(config = {}) { @@ -839,7 +884,7 @@ export default async function createMiniEditorWidget(config = {}) { const stage = createTag('div', { class: 'mini-editor-stage' }); const { - widget, useQuote, onFontOrColourChange, destroy: destroyWidget, + widget, useQuote, getContentModel, onFontOrColourChange, destroy: destroyWidget, } = buildWidget(root, a11y, cardSet, fontOptions, topActions); const decorations = buildDecoCards(a11y, cardSet, useQuote); const { root: arcCarousel, updateCentre } = buildArcCarousel(arcCardSet, useQuote, fontOptions[0]); @@ -893,6 +938,7 @@ export default async function createMiniEditorWidget(config = {}) { decorations, useQuote, updateCentre, + getContentModel, syncViewportMode, destroy: () => { destroyWidget(); diff --git a/test/blocks/mini-editor/mini-editor.test.js b/test/blocks/mini-editor/mini-editor.test.js index 33da8f7b0..6feb8c458 100644 --- a/test/blocks/mini-editor/mini-editor.test.js +++ b/test/blocks/mini-editor/mini-editor.test.js @@ -3,6 +3,7 @@ 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: '' }); @@ -32,11 +33,17 @@ describe('mini-editor', () => { 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; - fetchStub.restore(); + delete window.lana; + delete window.placeholders; + sinon.restore(); document.body.innerHTML = ''; }); @@ -80,6 +87,56 @@ describe('mini-editor', () => { 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.match(/\/image1\.jpg$/); + 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.match(/\/image2\.jpg$/); + }); + + 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(); 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 index 5bb7cdda1..99b8444d4 100644 --- a/test/scripts/widgets/mini-editor-widget.test.js +++ b/test/scripts/widgets/mini-editor-widget.test.js @@ -60,10 +60,27 @@ describe('mini-editor-widget', () => { 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'); @@ -94,13 +111,24 @@ describe('mini-editor-widget', () => { }); it('applies a picked font to the CSS variables and marks it selected', async () => { - const { root } = await mount(); + 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 () => { @@ -128,6 +156,10 @@ describe('mini-editor-widget', () => { 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 () => { @@ -169,12 +201,17 @@ describe('mini-editor-widget', () => { describe('arc carousel (tablet/mobile)', () => { it('navigating next updates the centre card and fires useQuote for the new active entry', async () => { - const { root } = await mount(); + 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 () => { From 11007f00a9d58e42f4e5a4352b6c0a8daf457bb5 Mon Sep 17 00:00:00 2001 From: Vineet Sharma Date: Fri, 14 Aug 2026 13:15:12 +0530 Subject: [PATCH 15/21] fix lint and tests --- express/code/scripts/utils/mini-editor-card-export.js | 7 +++++-- express/code/scripts/utils/mini-editor-card-worker.js | 7 ++++++- test/blocks/mini-editor/mini-editor.test.js | 4 ++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/express/code/scripts/utils/mini-editor-card-export.js b/express/code/scripts/utils/mini-editor-card-export.js index 216bf9fa4..53f855fec 100644 --- a/express/code/scripts/utils/mini-editor-card-export.js +++ b/express/code/scripts/utils/mini-editor-card-export.js @@ -97,9 +97,12 @@ function triggerDownload(blob, filename) { } function supportsWorkerRendering() { - if (!window.Worker || !window.OffscreenCanvas || !window.createImageBitmap) return false; + // 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 OffscreenCanvas(1, 1).getContext('2d'); + return !!new OffscreenCanvasConstructor(1, 1).getContext('2d'); } catch { return false; } diff --git a/express/code/scripts/utils/mini-editor-card-worker.js b/express/code/scripts/utils/mini-editor-card-worker.js index 0a7bd55ef..34852b5e4 100644 --- a/express/code/scripts/utils/mini-editor-card-worker.js +++ b/express/code/scripts/utils/mini-editor-card-worker.js @@ -11,7 +11,12 @@ globalThis.addEventListener('message', async ({ data }) => { 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()); - const canvas = new OffscreenCanvas(MINI_EDITOR_EXPORT_WIDTH, MINI_EDITOR_EXPORT_HEIGHT); + // 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); diff --git a/test/blocks/mini-editor/mini-editor.test.js b/test/blocks/mini-editor/mini-editor.test.js index 6feb8c458..3dbc92b28 100644 --- a/test/blocks/mini-editor/mini-editor.test.js +++ b/test/blocks/mini-editor/mini-editor.test.js @@ -101,7 +101,7 @@ describe('mini-editor', () => { quote: '"Patience is bitter, but its fruit is sweet."', author: 'Jean-Jacques Rousseau', }); - expect(downloadStub.firstCall.args[0].backgroundUrl).to.match(/\/image1\.jpg$/); + 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; }); @@ -118,7 +118,7 @@ describe('mini-editor', () => { quote: '"Adopt the pace of nature: her secret is patience."', author: 'Ralph Waldo Emerson', }); - expect(downloadStub.firstCall.args[0].backgroundUrl).to.match(/\/image2\.jpg$/); + 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 () => { From 796bd8262f5f6c198c8fa982142e6086715a81bc Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Thu, 13 Aug 2026 15:37:12 +0530 Subject: [PATCH 16/21] Add mini editor modal --- .../code/blocks/mini-editor/mini-editor.css | 8 +- .../code/blocks/mini-editor/mini-editor.js | 43 +++- .../mini-editor-modal/mini-editor-modal.css | 149 ++++++++++++++ .../mini-editor-modal/mini-editor-modal.js | 184 ++++++++++++++++++ .../mini-editor-widget/mini-editor-widget.css | 30 ++- .../mini-editor-widget/mini-editor-widget.js | 173 ++++++++++------ .../mini-editor-widget/mini-editor-widget.md | 13 +- 7 files changed, 512 insertions(+), 88 deletions(-) create mode 100644 express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.css create mode 100644 express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.js diff --git a/express/code/blocks/mini-editor/mini-editor.css b/express/code/blocks/mini-editor/mini-editor.css index feca380af..df716b536 100644 --- a/express/code/blocks/mini-editor/mini-editor.css +++ b/express/code/blocks/mini-editor/mini-editor.css @@ -1,8 +1,8 @@ +/* --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 { - --me-card-bg: none; - --me-quote-font: var(--body-font-family); - --me-quote-font-style: normal; - --me-quote-font-weight: normal; /* 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 diff --git a/express/code/blocks/mini-editor/mini-editor.js b/express/code/blocks/mini-editor/mini-editor.js index e2b62ee90..290b51033 100644 --- a/express/code/blocks/mini-editor/mini-editor.js +++ b/express/code/blocks/mini-editor/mini-editor.js @@ -7,6 +7,7 @@ import { } from '../../scripts/color-shared/spectrum/utils/a11y.js'; import showCopyToast from '../../scripts/utils/copy-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'; @@ -17,6 +18,11 @@ let getConfig; 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 @@ -156,6 +162,7 @@ function buildContentHeader(props) { export default async function init(block) { ({ createTag, loadStyle, getConfig } = await import(`${getLibs()}/utils/utils.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 = ''; @@ -190,6 +197,14 @@ export default async function init(block) { return; } const cardSet = buildCardSet(cards, quotes); + const a11y = { + trapFocus, + handleEscapeClose, + disableBackgroundScroll, + restoreBackgroundScroll, + copyQuoteToClipboard, + }; + const deps = { createTag, getIconElementDeprecated }; const editor = await createMiniEditorWidget({ root: block, @@ -203,14 +218,8 @@ export default async function init(block) { ], fontOptions, backgrounds: { cardSet, decoCount: DECO_CARD_COUNT }, - a11y: { - trapFocus, - handleEscapeClose, - disableBackgroundScroll, - restoreBackgroundScroll, - copyQuoteToClipboard, - }, - deps: { createTag, getIconElementDeprecated }, + a11y, + deps, }); // Decorations are appended to the header (not the stage) so they can be @@ -218,6 +227,24 @@ export default async function init(block) { // 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', 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..ce957ae5d --- /dev/null +++ b/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.css @@ -0,0 +1,149 @@ +/* ============================================================================ + 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); +} + +.me-modal-close img { + width: 20px; + height: 20px; +} + +.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..1a7203adb --- /dev/null +++ b/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.js @@ -0,0 +1,184 @@ +/** + * 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; +let getIconElementDeprecated; + +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, getIconElementDeprecated } = deps); + + 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', + }, [getIconElementDeprecated('close-black')]); + 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 index 630dd8e0b..0da9f2044 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css @@ -2,12 +2,27 @@ 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). Layout tokens (--me-*), the block padding/gap, and - the header live in the host block's own stylesheet (mini-editor.css); this + + 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; @@ -193,6 +208,13 @@ } .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); @@ -295,7 +317,9 @@ } .mini-editor[data-me-panel='fonts'] .me-row--fonts, -.mini-editor[data-me-panel='colour'] .me-row--colour { +.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); } 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 index 9ce1ca371..34da167c2 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js @@ -7,8 +7,10 @@ * 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, animation, and the cross-block "use this quote" - * event wiring, so a block only has to fetch data and mount the result. + * 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 @@ -39,10 +41,6 @@ * }); * stageParent.append(editor.stage); * headerEl.append(editor.decorations); - * - * The event listener that swaps a quote/author (and optionally background / - * font) into the editor — dispatched by collapsible-rows' "Create a design" - * button as `mini-editor:use-quote` — is registered inside the widget. */ let createTag; @@ -50,6 +48,17 @@ let getIconElementDeprecated; const DECO_CARD_COUNT = 8; +/** + * 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 @@ -75,7 +84,7 @@ function buildFontButton(opt, index, onPick) { return btn; } -function buildFontControl(root, fontOptions, onSelect) { +function buildFontControl(root, fontOptions, onSelect, panelMode) { const control = createTag('button', { type: 'button', class: 'me-control me-control--font', @@ -133,6 +142,12 @@ function buildFontControl(root, fontOptions, onSelect) { 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)); }); @@ -164,7 +179,7 @@ function buildSwatchButton(card, index, onPick) { return btn; } -function buildColorControl(root, cards, onSelect) { +function buildColorControl(root, cards, onSelect, panelMode) { const control = createTag('button', { type: 'button', class: 'me-control me-control--colour', @@ -218,6 +233,8 @@ function buildColorControl(root, cards, onSelect) { 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)); }); @@ -329,7 +346,7 @@ function buildMiniEditorActions(topActions = []) { return bar; } -function buildWidget(root, a11y, cardSet, fontOptions, topActions) { +function buildWidget(root, a11y, cardSet, fontOptions, topActions, panelMode) { const widget = createTag('div', { class: 'mini-editor-widget' }); const card = createTag('div', { class: 'me-card' }); @@ -397,7 +414,7 @@ function buildWidget(root, a11y, cardSet, fontOptions, topActions) { panel: fontPanel, sheetGrid: fontSheetGrid, selectFont, - } = buildFontControl(root, fontOptions, (font) => onFontOrColourPick({ font })); + } = buildFontControl(root, fontOptions, (font) => onFontOrColourPick({ font }), panelMode); const { control: colourControl, panel: colourPanel, @@ -407,6 +424,7 @@ function buildWidget(root, a11y, cardSet, fontOptions, topActions) { root, cardSet.map((c) => c.card), (bgCard) => onFontOrColourPick({ card: bgCard }), + panelMode, ); controls.append(fontControl, colourControl); @@ -431,6 +449,13 @@ function buildWidget(root, a11y, cardSet, fontOptions, topActions) { 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'); } @@ -805,6 +830,21 @@ function buildArcCarousel(cardSet, useQuote, defaultFont) { * 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, * syncViewportMode, destroy }>} */ @@ -816,6 +856,8 @@ export default async function createMiniEditorWidget(config = {}) { backgrounds, a11y, deps, + decorations: decorationsEnabled = true, + panelMode, } = config; ({ createTag, getIconElementDeprecated } = deps); @@ -830,63 +872,69 @@ export default async function createMiniEditorWidget(config = {}) { const { cardSet } = backgrounds; const decoCount = backgrounds.decoCount ?? DECO_CARD_COUNT; - root.setAttribute('data-me-panel', 'none'); - - // 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)]; + // 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, onFontOrColourChange, destroy: destroyWidget, - } = buildWidget(root, a11y, cardSet, fontOptions, topActions); - const decorations = buildDecoCards(a11y, cardSet, useQuote); - const { root: arcCarousel, updateCentre } = buildArcCarousel(arcCardSet, useQuote, fontOptions[0]); - 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); + } = buildWidget(root, a11y, cardSet, fontOptions, topActions, panelMode); stage.append(widget); - // 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; - }; - const syncViewportMode = () => { - root.classList.toggle('me-carousel-mode', isSmallViewport()); - }; - syncViewportMode(); - window.addEventListener('resize', syncViewportMode); - - // "Create a design" buttons on the page's collapsible-rows quotes (see - // collapsible-rows.js) dispatch this instead of importing the mini-editor - // directly, since the two blocks are otherwise unrelated. Scrolls the - // editor into view and swaps in the copied quote/author, on whichever of - // the desktop widget or the tablet/mobile carousel is active. - const onUseQuoteEvent = (e) => { - const { quote, author } = e.detail; - useQuote({ quote, author }); - updateCentre({ quote, author }); - root.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }; - document.addEventListener('mini-editor:use-quote', onUseQuoteEvent); + 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, @@ -896,8 +944,7 @@ export default async function createMiniEditorWidget(config = {}) { syncViewportMode, destroy: () => { destroyWidget(); - window.removeEventListener('resize', syncViewportMode); - document.removeEventListener('mini-editor:use-quote', onUseQuoteEvent); + 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 index 0b3bb296f..ad2022925 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.md +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.md @@ -73,6 +73,8 @@ loadStyle(`${getConfig().codeRoot}/scripts/widgets/mini-editor-widget/mini-edito | `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 @@ -85,13 +87,4 @@ loadStyle(`${getConfig().codeRoot}/scripts/widgets/mini-editor-widget/mini-edito - `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, the `mini-editor:use-quote` - document listener) and the panel MutationObserver. - -## Events - -The widget listens on `document` for `mini-editor:use-quote` -(`{ detail: { quote, author } }`) — dispatched by the collapsible-rows block's -"Create a design" button — and swaps that quote/author into the editor, then -scrolls the root into view. The two blocks stay decoupled via this event -instead of importing one into the other. +- `destroy()` — remove listeners (resize, outside-click) and the panel MutationObserver. From 5050c0d61e6d0574aa8c00482a48d4eda6fe13f1 Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Thu, 13 Aug 2026 16:57:10 +0530 Subject: [PATCH 17/21] Use spectrum icons --- express/code/scripts/utils/copy-toast.css | 30 ++--- express/code/scripts/utils/copy-toast.js | 9 +- .../mini-editor-modal/mini-editor-modal.css | 9 +- .../mini-editor-modal/mini-editor-modal.js | 10 +- .../mini-editor-widget/mini-editor-widget.css | 10 +- .../mini-editor-widget/mini-editor-widget.js | 11 +- .../code/scripts/widgets/spectrum/build.mjs | 2 +- .../widgets/spectrum/dist/icons-workflow.js | 115 ++++++++++-------- 8 files changed, 107 insertions(+), 89 deletions(-) diff --git a/express/code/scripts/utils/copy-toast.css b/express/code/scripts/utils/copy-toast.css index 49a178fd2..824b4b377 100644 --- a/express/code/scripts/utils/copy-toast.css +++ b/express/code/scripts/utils/copy-toast.css @@ -39,17 +39,13 @@ 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; - width: 18px; - height: 18px; - background-color: var(--color-white); - -webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 16.875C4.65732 16.875 1.125 13.3427 1.125 9C1.125 4.65732 4.65732 1.125 9 1.125C13.3427 1.125 16.875 4.65732 16.875 9C16.875 13.3427 13.3427 16.875 9 16.875ZM9 2.475C5.40175 2.475 2.475 5.40175 2.475 9C2.475 12.5982 5.40175 15.525 9 15.525C12.5982 15.525 15.525 12.5982 15.525 9C15.525 5.40175 12.5982 2.475 9 2.475Z' fill='%23000'/%3E%3Cpath d='M8.29813 12.1526C8.10741 12.1526 7.92459 12.0717 7.79627 11.9293L5.5366 9.41653C5.287 9.1388 5.30985 8.71252 5.5867 8.46292C5.86354 8.21331 6.28982 8.23616 6.54031 8.51301L8.2445 10.4088L11.3831 6.12854C11.6019 5.82796 12.0229 5.76028 12.3261 5.98353C12.6267 6.20325 12.6918 6.62601 12.4712 6.92659L8.84215 11.8766C8.72262 12.0409 8.53453 12.142 8.33151 12.1517C8.32096 12.1526 8.30956 12.1526 8.29813 12.1526Z' fill='%23000'/%3E%3C/svg%3E"); - mask-image: url("data:image/svg+xml;utf8,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 16.875C4.65732 16.875 1.125 13.3427 1.125 9C1.125 4.65732 4.65732 1.125 9 1.125C13.3427 1.125 16.875 4.65732 16.875 9C16.875 13.3427 13.3427 16.875 9 16.875ZM9 2.475C5.40175 2.475 2.475 5.40175 2.475 9C2.475 12.5982 5.40175 15.525 9 15.525C12.5982 15.525 15.525 12.5982 15.525 9C15.525 5.40175 12.5982 2.475 9 2.475Z' fill='%23000'/%3E%3Cpath d='M8.29813 12.1526C8.10741 12.1526 7.92459 12.0717 7.79627 11.9293L5.5366 9.41653C5.287 9.1388 5.30985 8.71252 5.5867 8.46292C5.86354 8.21331 6.28982 8.23616 6.54031 8.51301L8.2445 10.4088L11.3831 6.12854C11.6019 5.82796 12.0229 5.76028 12.3261 5.98353C12.6267 6.20325 12.6918 6.62601 12.4712 6.92659L8.84215 11.8766C8.72262 12.0409 8.53453 12.142 8.33151 12.1517C8.32096 12.1526 8.30956 12.1526 8.29813 12.1526Z' fill='%23000'/%3E%3C/svg%3E"); - -webkit-mask-size: contain; - mask-size: contain; - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; + --mod-icon-size: 18px; + --mod-icon-color: var(--color-white); } .copy-toast-close { @@ -73,15 +69,9 @@ background: rgb(255 255 255 / 15%); } -.copy-toast-close::before { - content: ''; - width: 12px; - height: 12px; - background-color: var(--color-white); - -webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L11 11M11 1L1 11' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); - mask-image: url("data:image/svg+xml;utf8,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L11 11M11 1L1 11' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); - -webkit-mask-size: contain; - mask-size: contain; - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; +/* 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 index 2f6c9dfaf..242964bac 100644 --- a/express/code/scripts/utils/copy-toast.js +++ b/express/code/scripts/utils/copy-toast.js @@ -17,6 +17,11 @@ export default async function showCopyToast(message) { 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' }); @@ -26,7 +31,7 @@ export default async function showCopyToast(message) { container.querySelectorAll('.copy-toast').forEach((t) => t.remove()); const toast = createTag('div', { class: 'copy-toast' }, [ - createTag('span', { class: 'copy-toast-icon', 'aria-hidden': 'true' }), + createTag('sp-icon-checkmark-circle-outline', { class: 'copy-toast-icon', 'aria-hidden': 'true' }), createTag('span', { class: 'copy-toast-message' }, [message]), ]); container.append(toast); @@ -40,7 +45,7 @@ export default async function showCopyToast(message) { 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); 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 index ce957ae5d..78f65f0ac 100644 --- a/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.css +++ b/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.css @@ -90,9 +90,12 @@ html { backdrop-filter: blur(8px); } -.me-modal-close img { - width: 20px; - height: 20px; +/* 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 { 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 index 1a7203adb..248394fdd 100644 --- a/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.js +++ b/express/code/scripts/widgets/mini-editor-modal/mini-editor-modal.js @@ -21,7 +21,6 @@ import createMiniEditorWidget from '../mini-editor-widget/mini-editor-widget.js'; let createTag; -let getIconElementDeprecated; const OPEN_DURATION = 250; const CLOSE_DURATION = 150; @@ -49,7 +48,12 @@ export default async function createMiniEditorModal(config = {}) { trapFocus, handleEscapeClose, disableBackgroundScroll, restoreBackgroundScroll, } = a11y; - ({ createTag, getIconElementDeprecated } = deps); + ({ 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', { @@ -64,7 +68,7 @@ export default async function createMiniEditorModal(config = {}) { type: 'button', class: 'me-modal-close', 'aria-label': 'Close', - }, [getIconElementDeprecated('close-black')]); + }, [createTag('sp-icon-close', { class: 'me-modal-close-icon', 'aria-hidden': 'true' })]); dialog.append(cardWrap, closeBtn); overlay.append(dialog); 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 index 0da9f2044..2a925eace 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css @@ -592,10 +592,12 @@ p.me-deco-author { padding: 7px; } -.me-deco-copy img { - display: block; - width: 18px; - height: 18px; +/* 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, 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 index 34da167c2..b6f169257 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js @@ -509,7 +509,7 @@ function buildDecoCard(a11y, entry, useQuote) { type: 'button', class: 'me-deco-copy', 'aria-label': 'Copy quote', - }, [getIconElementDeprecated('copy-quote')]); + }, [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) { @@ -862,10 +862,11 @@ export default async function createMiniEditorWidget(config = {}) { ({ createTag, getIconElementDeprecated } = deps); - // topActions' icons are real Spectrum Web Components custom elements - // (sp-icon-*, see TOP_ACTION_DEFS) — only loaded when actually used, so - // callers that don't pass topActions don't pay for the Spectrum bundle. - if (topActions.length) { + // 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'); } 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); From fa110cdadbed610ddff5d7225d2d6ed4015e12cb Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Thu, 13 Aug 2026 18:07:54 +0530 Subject: [PATCH 18/21] Character truncation and fixed width variable height handling --- .../code/blocks/mini-editor/mini-editor.js | 41 ++++- .../mini-editor-widget/mini-editor-widget.css | 170 ++++++++++-------- .../mini-editor-widget/mini-editor-widget.js | 122 +++++++++++-- 3 files changed, 242 insertions(+), 91 deletions(-) diff --git a/express/code/blocks/mini-editor/mini-editor.js b/express/code/blocks/mini-editor/mini-editor.js index 290b51033..935c575ea 100644 --- a/express/code/blocks/mini-editor/mini-editor.js +++ b/express/code/blocks/mini-editor/mini-editor.js @@ -116,6 +116,12 @@ function constructProps(block) { 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 @@ -123,13 +129,38 @@ function constructProps(block) { * 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) { - return cards.map((card, i) => ({ - card, - quote: quotes[i % quotes.length].quote, - author: quotes[i % quotes.length].author, - })); + 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 }; + }); } /** 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 index 2a925eace..2f315d493 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css @@ -413,53 +413,88 @@ display: none; } -.me-deco { +/* + * 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 { - /* Positioned so .me-deco-actions's `top: 100%` resolves against the card - itself (not the ancestor). The padding-bottom extends this wrap's own - hoverable box down through the gap and the actions row, 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. */ + /* 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; } -/* - * 4 slots per side, desktop only (see the >=1200px block below). Matches the - * Figma reference's two-column 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 - * holds two 180px-wide cards spaced ~230px apart vertically. Positioned - * 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. The lowest card in - * each column sits with its own bottom flush at 0 so nothing crosses below - * the editor's bottom edge. - */ -.me-deco--1 { left: calc(50% - 811px); bottom: 496px; transform: rotate(1.5deg); } -.me-deco--2 { left: calc(50% - 541px); bottom: 360px; transform: rotate(1.5deg); } -.me-deco--3 { left: calc(50% - 811px); bottom: 160px; transform: rotate(2deg); } -.me-deco--4 { left: calc(50% - 541px); bottom: 0; transform: rotate(-2deg); } -.me-deco--5 { left: calc(50% + 631px); bottom: 496px; transform: rotate(-1.5deg); } -.me-deco--6 { left: calc(50% + 361px); bottom: 360px; transform: rotate(-1.5deg); } -.me-deco--7 { left: calc(50% + 631px); bottom: 160px; transform: rotate(-2deg); } -.me-deco--8 { left: calc(50% + 361px); bottom: 0; transform: rotate(2deg); } - .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%; - height: 148px; + -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. Flex + column so the quote/author flow naturally and the card's own height is + just "however tall its content needs" — the quote is the one flexible + item (see p.me-deco-quote's flex/centering below), the author a fixed + item pinned to the bottom via margin-top: auto, matching the original + design where the author sits at a fixed offset from the card's bottom + edge regardless of how short the quote above it is. */ + 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; @@ -468,41 +503,33 @@ background-repeat: no-repeat; overflow: hidden; box-shadow: 0 5px 6px rgb(0 0 0 / 12%), 0 16px 16px rgb(0 0 0 / 8%); - -webkit-user-select: none; - user-select: none; } /* * 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. Quote is the card's - * only flex item (per Figma node 0-18559) and centers itself vertically - * within the full card height, independent of the author line below — - * author is absolutely positioned near the bottom instead of sharing a - * centering group with the quote, matching that same Figma reference. + * 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 { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 4; - line-clamp: 4; - /* Fixed height (not flex: 1 / padding) — -webkit-box's intrinsic height - from -webkit-line-clamp ignores flex-shrink and reduced-by-padding - space alike, so only an explicit height reliably caps it at what - actually fits: full 126px content area when no author line needs - room, or that minus the author's own reserved space (see the - :has() override below) when one is present. */ + /* The one flexible item in .me-deco-card-inner — grows/shrinks with the + available space above the bottom-pinned author (see p.me-deco-author), + and centers its own (possibly multi-line) text within whatever space + it ends up with, so a short quote still sits vertically centered in + the card above the author, not glued to the top. */ + display: flex; + flex: 1 0 auto; + align-items: center; + justify-content: center; width: 100%; - height: 126px; - /* -webkit-box has no justify-content equivalent of its own — this centers - the (possibly fewer-than-clamped) lines of text within the box's own - height, which is what actually centers the quote in the card. */ - -webkit-box-pack: center; margin: 0; - overflow: hidden; color: var(--Alias-content-typography-Heading); text-align: center; font-family: var(--body-font-family); @@ -511,25 +538,23 @@ p.me-deco-quote { font-weight: var(--heading-font-weight-regular); line-height: 1.25; word-break: break-word; - align-content: center; -} - -/* Reserves the author's own line height + bottom offset out of the quote's - box only when an author is actually present (see buildDecoCard — the - .me-deco-author element is omitted otherwise), so a long quote can never - clamp down into visually overlapping the absolutely-positioned author. */ -.me-deco-card:has(.me-deco-author) p.me-deco-quote { - height: 104px; } +/* Pinned to the card's bottom edge via margin-top: auto — matches the + original design (an absolutely-positioned author at a fixed bottom + offset) without needing that positioning now that the card's own height + varies with the quote above it: auto margin pushes this fixed-size item + to the end of the flex column regardless of how tall the card grows. */ p.me-deco-author { - position: absolute; - left: 50%; - bottom: 12px; - transform: translateX(-50%); + flex-shrink: 0; + margin: auto 0 0; + /* Guarantees a minimum gap above the author even when the quote grows + tall enough to nearly reach the bottom on its own — margin-top: auto + alone would let that gap shrink toward 0 in that case. */ + padding-top: 8px; + width: 100%; max-width: calc(100% - 22px); overflow: hidden; - margin: 0; color: var(--Alias-content-typography-Heading); text-align: center; font-family: var(--body-font-family); @@ -541,13 +566,16 @@ p.me-deco-author { } .me-deco-actions { - /* Fixed px offset, not a percentage — .me-deco-card-wrap's own - padding-bottom (the hover hit-box buffer) would otherwise be counted - twice if this used `top: 100%`, since that percentage resolves against - the wrap's full padding box. 148px card height + 11px*2 padding - (border-box, so included) + 8px gap. */ + /* 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: 170px; + top: calc(100% + var(--spacing-100)); left: 50%; transform: translateX(-50%); display: flex; 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 index b6f169257..a5ae2ba71 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js @@ -47,6 +47,22 @@ 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 @@ -358,17 +374,36 @@ function buildWidget(root, a11y, cardSet, fontOptions, topActions, panelMode) { }); const quoteEl = createTag('p', { class: 'me-quote' }); const first = cardSet[0] || { quote: '', author: '' }; - quoteEl.textContent = first.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('p', { class: 'me-author' }); authorEl.textContent = first.author; @@ -384,7 +419,9 @@ function buildWidget(root, a11y, cardSet, fontOptions, topActions, panelMode) { widget.append(buildMiniEditorActions(topActions)); const doCopy = async () => { - const ok = await a11y.copyQuoteToClipboard(quoteEl.textContent, authorEl.textContent); + // 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); @@ -467,7 +504,7 @@ function buildWidget(root, a11y, cardSet, fontOptions, topActions, panelMode) { useQuote: ({ quote, author, card: bgCard, font, }) => { - quoteEl.textContent = quote; + renderQuote(quote); authorEl.textContent = author || ''; authorEl.style.display = author ? '' : 'none'; if (bgCard) selectSwatch(bgCard.bg); @@ -485,30 +522,47 @@ 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' }); - const inner = createTag('div', { - class: 'me-deco-card', + // .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 = quote; - inner.append(quoteP); + quoteP.textContent = truncateQuote(quote, DECO_QUOTE_CHAR_LIMIT); + clipped.append(quoteP); if (author) { const authorP = createTag('p', { class: 'me-deco-author' }); authorP.textContent = author; - inner.append(authorP); + clipped.append(authorP); } const actions = createTag('div', { class: 'me-deco-actions' }); - const useBtn = createTag('button', { type: 'button', class: 'me-deco-use' }); + 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', + '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); @@ -519,11 +573,30 @@ function buildDecoCard(a11y, entry, useQuote) { }); actions.append(useBtn, copyBtn); - cardWrap.append(inner, actions); + // 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") @@ -532,10 +605,15 @@ function buildDecoCards(a11y, cardSet, useQuote) { 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); - decoEntries.forEach((entry, i) => { + const decos = decoEntries.map((entry, i) => { const deco = buildDecoCard(a11y, entry, useQuote); deco.classList.add(`me-deco--${i + 1}`); - wrap.append(deco); + 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; } @@ -564,7 +642,17 @@ function buildArcCard(onActivate) { function render(entry) { el.style.backgroundImage = `url("${entry.card.bg}")`; - quoteP.textContent = entry.quote; + // 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 @@ -627,7 +715,11 @@ function buildArcGhost() { function playExit(entry, fromRole) { el.style.backgroundImage = `url("${entry.card.bg}")`; - quoteP.textContent = entry.quote; + // 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. From 0ac5f8d161954304d9d365f975f20f5706b44496 Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Fri, 14 Aug 2026 12:36:44 +0530 Subject: [PATCH 19/21] UI fixes --- .../collapsible-rows/collapsible-rows.css | 23 +++++++++ .../mini-editor-widget/mini-editor-widget.css | 48 ++++++++++--------- .../mini-editor-widget/mini-editor-widget.js | 4 +- 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/express/code/blocks/collapsible-rows/collapsible-rows.css b/express/code/blocks/collapsible-rows/collapsible-rows.css index fa73b1a1d..6dc6e6bae 100644 --- a/express/code/blocks/collapsible-rows/collapsible-rows.css +++ b/express/code/blocks/collapsible-rows/collapsible-rows.css @@ -126,6 +126,29 @@ main .section .collapsible-row-sub-header p:last-of-type { 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/scripts/widgets/mini-editor-widget/mini-editor-widget.css b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css index 2f315d493..a2acc9ab3 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css @@ -153,7 +153,7 @@ which already gets this right. */ position: absolute; left: 50%; - bottom: var(--spacing-200); + bottom: 20px; transform: translateX(-50%); margin: 0; font-family: var(--me-quote-font); @@ -483,13 +483,13 @@ /* 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. Flex - column so the quote/author flow naturally and the card's own height is - just "however tall its content needs" — the quote is the one flexible - item (see p.me-deco-quote's flex/centering below), the author a fixed - item pinned to the bottom via margin-top: auto, matching the original - design where the author sits at a fixed offset from the card's bottom - edge regardless of how short the quote above it is. */ + 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; @@ -520,16 +520,18 @@ 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 above the bottom-pinned author (see p.me-deco-author), - and centers its own (possibly multi-line) text within whatever space - it ends up with, so a short quote still sits vertically centered in - the card above the author, not glued to the top. */ + 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); @@ -540,18 +542,18 @@ p.me-deco-quote { word-break: break-word; } -/* Pinned to the card's bottom edge via margin-top: auto — matches the - original design (an absolutely-positioned author at a fixed bottom - offset) without needing that positioning now that the card's own height - varies with the quote above it: auto margin pushes this fixed-size item - to the end of the flex column regardless of how tall the card grows. */ +/* 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 { - flex-shrink: 0; - margin: auto 0 0; - /* Guarantees a minimum gap above the author even when the quote grows - tall enough to nearly reach the bottom on its own — margin-top: auto - alone would let that gap shrink toward 0 in that case. */ - padding-top: 8px; + position: absolute; + left: 50%; + bottom: 12px; + transform: translateX(-50%); + margin: 0; width: 100%; max-width: calc(100% - 22px); overflow: hidden; 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 index a5ae2ba71..243babebc 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.js @@ -372,7 +372,7 @@ function buildWidget(root, a11y, cardSet, fontOptions, topActions, panelMode) { tabindex: '0', 'aria-describedby': 'me-quote-wrap-hint', }); - const quoteEl = createTag('p', { class: 'me-quote' }); + const quoteEl = createTag('div', { class: 'me-quote' }); const first = cardSet[0] || { quote: '', author: '' }; // The full, untruncated quote — kept separate from quoteEl's own display // text (which truncates at EDITOR_QUOTE_CHAR_LIMIT) so copy-to-clipboard @@ -405,7 +405,7 @@ function buildWidget(root, a11y, cardSet, fontOptions, topActions, panelMode) { quoteWrap.append(quoteEl, hint, tip); renderQuote(first.quote); - const authorEl = createTag('p', { class: 'me-author' }); + const authorEl = createTag('div', { class: 'me-author' }); authorEl.textContent = first.author; authorEl.style.display = first.author ? '' : 'none'; From 1fedbd873dab6939ee8f886a2eb794e6daf6c84a Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Fri, 14 Aug 2026 14:38:32 +0530 Subject: [PATCH 20/21] Fix the arc --- .../code/blocks/mini-editor/mini-editor.css | 53 ++++++++++--------- .../mini-editor-widget/mini-editor-widget.css | 36 +++++++------ 2 files changed, 48 insertions(+), 41 deletions(-) diff --git a/express/code/blocks/mini-editor/mini-editor.css b/express/code/blocks/mini-editor/mini-editor.css index df716b536..9c49c5de7 100644 --- a/express/code/blocks/mini-editor/mini-editor.css +++ b/express/code/blocks/mini-editor/mini-editor.css @@ -11,25 +11,27 @@ 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: rotation pivots from - each side card's own centre (transform-origin never changes between - roles, so the transition can't swing through the centre card at any - point along the path), then a translateY compensation (--me-arc-lift) - pulls the whole card down so its bottom-inner corner lines up with - the centre card's bottom edge instead of sitting ~39px above it. That - same downward shift pushes the outer-bottom corner ~76px below the - unrotated baseline, so the container needs that much extra room - beneath the card to avoid clipping it. */ + /* 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; - --me-arc-lift: 39px; - /* translateX distance from centre for the side cards' own centrepoint — - must clear (cardW/2 * cos(8deg) + cardH/2 * sin(8deg)) + centreCardW/2 - at every point along the rotation, not just at rest. Verified clear - in practice (browser-scrubbed at 100/300/500ms) even though a - linear-time worst case exists in the first ~2% of progress — - ease-in-out compresses that window to well under a rendered frame. - 542/2*cos(8)+350/2*sin(8)+542/2 ≈ 564px; +10px margin. */ - --me-arc-slide: 574px; + /* 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 @@ -124,16 +126,15 @@ 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-slide recalculated for this card size the - same way as the base rule: - 327/2*cos(8)+270/2*sin(8) + 327/2 ≈ 344px; +10px safety margin. - --me-arc-lift/--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. */ + 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-slide: 354px; - --me-arc-lift: 24px; + --me-arc-origin-y: 2544px; --me-arc-extra-h: 50px; --me-arc-gap: var(--spacing-100); } 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 index a2acc9ab3..0a6ee8343 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css @@ -727,12 +727,14 @@ p.me-deco-author { 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 is fixed at the card's own centre for every role, and - never changes between roles — only transform itself is animated. That - keeps the transition a single smooth translate+rotate interpolation - that stays clear of the centre card throughout, instead of a - corner/bottom pivot whose intermediate frames aren't guaranteed clear. */ - transform-origin: 50% 50%; + /* 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; @@ -750,24 +752,28 @@ p.me-deco-author { } .me-arc-card--prev { - transform: translateX(calc(-1 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(-8deg); + transform: rotate(-8deg); } .me-arc-card--next { - transform: translateX(var(--me-arc-slide)) translateY(var(--me-arc-lift)) rotate(8deg); + 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, so the very next transition (turning off - `instant`) animates it rotating in from beyond the edge instead of - popping directly into its final slot. */ + 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: translateX(calc(-2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(-8deg); + transform: rotate(-16.1615deg); } .me-arc-card--stage-next { - transform: translateX(calc(2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(8deg); + transform: rotate(16.1615deg); } /* Ghost: a 4th, non-interactive card (see buildArcGhost in @@ -790,11 +796,11 @@ p.me-deco-author { } .me-arc-card--exit-prev { - transform: translateY(calc(-1 * var(--me-arc-lift))) translateX(calc(-2 * var(--me-arc-slide)))rotate(0deg); + transform: rotate(-16.1615deg); } .me-arc-card--exit-next { - transform: translateX(calc(2 * var(--me-arc-slide))) translateY(var(--me-arc-lift)) rotate(8deg); + transform: rotate(16.1615deg); } .me-arc-ghost.me-arc-ghost--visible.me-arc-card--exit-prev, From 2f5c2f3c96f411257435d181a36d8424ebdb2661 Mon Sep 17 00:00:00 2001 From: shikshachauhan Date: Fri, 14 Aug 2026 15:43:26 +0530 Subject: [PATCH 21/21] Implement hover and focus states --- .../mini-editor-widget/mini-editor-widget.css | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) 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 index 0a6ee8343..addcdb176 100644 --- a/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css +++ b/express/code/scripts/widgets/mini-editor-widget/mini-editor-widget.css @@ -60,6 +60,17 @@ 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 @@ -133,6 +144,14 @@ 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); @@ -248,6 +267,15 @@ 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 @@ -347,6 +375,12 @@ 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; @@ -383,6 +417,14 @@ 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); @@ -635,6 +677,16 @@ p.me-deco-author { 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; } @@ -845,6 +897,20 @@ p.me-deco-author { 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 {