{"id":16316,"date":"2025-06-02T16:47:40","date_gmt":"2025-06-02T15:47:40","guid":{"rendered":"https:\/\/performancein.live\/europe\/?page_id=16316"},"modified":"2026-09-08T13:03:07","modified_gmt":"2026-09-08T12:03:07","slug":"create-your-social-badge","status":"publish","type":"page","link":"https:\/\/performancein.live\/europe\/create-your-social-badge\/","title":{"rendered":"Create Your Social Badge"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"16316\" class=\"elementor elementor-16316\" data-elementor-post-type=\"page\">\n\t\t\t\t<div class=\"elementor-element elementor-element-be77d44 e-flex e-con-boxed e-con e-parent\" data-id=\"be77d44\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t\t\t<div class=\"elementor-element elementor-element-f3d66aa elementor-widget elementor-widget-html\" data-id=\"f3d66aa\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<script src=\"https:\/\/cdn.jsdelivr.net\/npm\/@zumer\/snapdom@2.24.15\/dist\/snapdom.min.js\"><\/script>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-abea614 elementor-widget elementor-widget-html\" data-id=\"abea614\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<style id=\"social-generator-styles\">\n\/* ============================ COLOUR SCHEMES ============================\n   2026 palette \u2014 four flat schemes.\n\n     Orange #FF6A00   Pink #EE0978   Purple #43285C   White #FFFFFF\n\n   Each scheme sets three variables and then wires every themeable selector to\n   them explicitly. The per-selector rules are listed in full even where they\n   only repeat the scheme default, so any single element of any single scheme\n   can be retinted by editing one line \u2014 no need to work out which rule wins.\n   To make an exception, replace that line's var() with a literal hex.\n\n   The accent colours: the headshot border, the <span> inside the headline, and\n   the border on each reason pill. The rest of the headline is --text.\n\n   TWO FILES MUST AGREE. Each scheme's --bg is duplicated as SCHEMES[N-1] in the\n   JS widget (the picker swatches). sg-dev\/test-sg.js asserts they stay aligned,\n   including that the base #imageContainer1 --bg matches scheme 1.\n   ======================================================================== *\/\n\n\/* Default \u2014 matches scheme 1. This is what paints before the picker runs and\n   what the Elementor editor preview shows, so it must track scheme 1. *\/\n#imageContainer1 {\n    --bg:     #EE0978;\n    --accent: #EE0978;\n    --text:   #FFFFFF;\n    --rule:   rgba(255,255,255,0.40);   \/* hairline border on the CTA block *\/\n}\n\n\/* Flat background colour + stacking context.\n   Keep position\/z-index: #imageContainer1 holds an absolutely-positioned\n   descendant at z-index 99, and this stacking context is what keeps it inside\n   the card rather than competing with page-level z-indexes. *\/\n#imageContainer1 {\n    background: var(--bg);\n    position: relative;\n    z-index: 0;\n}\n\n\/* ---- Card text elements are not always <p> ----\n   An Elementor Heading widget renders whatever tag its \"HTML Tag\" control is set\n   to. .firstname\/.lastname\/.cta are set to `p`; a heading added with the default\n   is an <h2>. Elementor applies .elementor-heading-title to that element whatever\n   the tag, so every rule below matches BOTH \u2014 otherwise changing a widget's tag\n   silently drops its colour and its margin reset. The JS uses the same rule\n   (see cardTextEl). ---- *\/\n\n\/* Shared defaults. Every per-scheme block below overrides these; they remain as\n   the fallback if a scheme class is ever missing from the container. *\/\n#imageContainer1 .textWrapper :is(p, .elementor-heading-title)            { color: var(--text); }\n\/* Names are white in every scheme, and the CTA on the Pink and Orange schemes is\n   #190F22 \u2014 both sit on the card artwork rather than on --bg.\n   !important is required, not laziness: #imageContainer1's own Elementor custom\n   CSS still carries `selector p { color: var(--accent) !important; }`, which\n   beats any specificity without it. This rule is more specific, so with both\n   marked important this one wins. Comment that card rule out (see \u00a74) and the\n   !important here becomes unnecessary \u2014 but it does no harm either way. *\/\n#imageContainer1 .firstname :is(p, .elementor-heading-title),\n#imageContainer1 .lastname  :is(p, .elementor-heading-title)             { color: #FFFFFF !important; }\n#imageContainer1 .headline :is(p, .elementor-heading-title)               { color: var(--text); }\n#imageContainer1 .headline .sg-headline-accent { color: var(--accent); }\n#imageContainer1 .headshot                 { border-color: var(--accent); }\n#imageContainer1 .reason                   { border-color: var(--accent); color: #FFFFFF; }\n#imageContainer1 .cta > div                { border-color: var(--rule); }\n\n\n\/* ---- Scheme 1: Pink ---- *\/\n#imageContainer1.scheme-1 {\n    --bg:     #EE0978;\n    --accent: #EE0978;   \/* headshot border, headline accent span, reason border *\/\n    --text:   #FFFFFF;\n    --rule:   rgba(255,255,255,0.40);\n}\n#imageContainer1.scheme-1 .firstname :is(p, .elementor-heading-title)                 { color: #FFFFFF !important; }\n#imageContainer1.scheme-1 .lastname :is(p, .elementor-heading-title)                  { color: #FFFFFF !important; }\n#imageContainer1.scheme-1 .cta :is(p, .elementor-heading-title)                       { color: #190F22 !important; }\n#imageContainer1.scheme-1 .headline :is(p, .elementor-heading-title)                  { color: var(--text); }\n#imageContainer1.scheme-1 .headline .sg-headline-accent { color: var(--accent); }\n#imageContainer1.scheme-1 .headshot                    { border-color: var(--accent); }\n#imageContainer1.scheme-1 .reason                      { border-color: var(--accent); }\n#imageContainer1.scheme-1 .reason em,\n#imageContainer1.scheme-1 .reason i                    { color: #FFFFFF; }\n#imageContainer1.scheme-1 .cta > div                   { border-color: var(--rule); }\n\n\/* ---- Scheme 2: Orange ---- *\/\n#imageContainer1.scheme-2 {\n    --bg:     #FF6A00;\n    --accent: #FF6A00;   \/* headshot border, headline accent span, reason border *\/\n    --text:   #FFFFFF;\n    --rule:   rgba(255,255,255,0.40);\n}\n#imageContainer1.scheme-2 .firstname :is(p, .elementor-heading-title)                 { color: #FFFFFF !important; }\n#imageContainer1.scheme-2 .lastname :is(p, .elementor-heading-title)                  { color: #FFFFFF !important; }\n#imageContainer1.scheme-2 .cta :is(p, .elementor-heading-title)                       { color: #190F22 !important; }\n#imageContainer1.scheme-2 .headline :is(p, .elementor-heading-title)                  { color: var(--text); }\n#imageContainer1.scheme-2 .headline .sg-headline-accent { color: var(--accent); }\n#imageContainer1.scheme-2 .headshot                    { border-color: var(--accent); }\n#imageContainer1.scheme-2 .reason                      { border-color: var(--accent); }\n#imageContainer1.scheme-2 .reason em,\n#imageContainer1.scheme-2 .reason i                    { color: #FFFFFF; }\n#imageContainer1.scheme-2 .cta > div                   { border-color: var(--rule); }\n\n\/* ---- Scheme 3: Purple background, Pink accent ---- *\/\n#imageContainer1.scheme-3 {\n    --bg:     #43285C;\n    --accent: #EE0978;\n    --text:   #FFFFFF;\n    --rule:   rgba(255,255,255,0.40);\n}\n#imageContainer1.scheme-3 .firstname :is(p, .elementor-heading-title)                 { color: #FFFFFF !important; }\n#imageContainer1.scheme-3 .lastname :is(p, .elementor-heading-title)                  { color: #FFFFFF !important; }\n#imageContainer1.scheme-3 .cta :is(p, .elementor-heading-title)                       { color: #FF6A00 !important; }\n#imageContainer1.scheme-3 .headline :is(p, .elementor-heading-title)                  { color: var(--text); }\n#imageContainer1.scheme-3 .headline .sg-headline-accent { color: var(--accent); }\n#imageContainer1.scheme-3 .headshot                    { border-color: #FF6A00; }\n#imageContainer1.scheme-3 .reason                      { border-color: var(--accent); }\n#imageContainer1.scheme-3 .reason em,\n#imageContainer1.scheme-3 .reason i                    { color: #FFFFFF; }\n#imageContainer1.scheme-3 .cta > div                   { border-color: var(--rule); }\n\n\/* ---- Scheme 4: White background, Orange accent ---- *\/\n#imageContainer1.scheme-4 {\n    --bg:     #FFFFFF;\n    --accent: #FF6A00;\n    --text:   #43285C;\n    --rule:   rgba(67,40,92,0.30);\n}\n#imageContainer1.scheme-4 .firstname :is(p, .elementor-heading-title)                 { color: #FFFFFF !important; }\n#imageContainer1.scheme-4 .lastname :is(p, .elementor-heading-title)                  { color: #FFFFFF !important; }\n#imageContainer1.scheme-4 .cta :is(p, .elementor-heading-title)                       { color: #EE0978 !important; }\n#imageContainer1.scheme-4 .headline :is(p, .elementor-heading-title)                  { color: #FFFFFF; }\n#imageContainer1.scheme-4 .headline .sg-headline-accent { color: var(--accent); }\n#imageContainer1.scheme-4 .headshot                    { border-color: #EE0978; }\n#imageContainer1.scheme-4 .reason                      { border-color: var(--accent); }\n#imageContainer1.scheme-4 .reason em,\n#imageContainer1.scheme-4 .reason i                    { color: #FFFFFF; }\n#imageContainer1.scheme-4 .cta > div                   { border-color: var(--rule); }\n\n#imageContainer1 .cta,\n#imageContainer1 .cta :is(p, .elementor-heading-title) { white-space: nowrap; }\n\n\/* The accent border must read as a CIRCLE, so the box has to stay square.\n   .headshot is a flex item with the default flex-shrink:1, so a long name\n   squeezes it narrower while min-height holds it tall \u2014 and border-radius:50%\n   on a non-square box renders an ELLIPSE. flex:none pins the width; the\n   aspect-ratio is belt-and-braces if the width is ever set elsewhere.\n   (The existing clip-path below only protects the IMAGE, not this border.) *\/\n#imageContainer1 .headshot {\n  flex: none;\n  \/* Height stated explicitly, NOT left to aspect-ratio. snapdom rasterises via\n     an SVG foreignObject, and aspect-ratio does not reliably survive that on\n     iOS Safari \u2014 the box came out taller than wide in the export and the\n     border-radius therefore drew an ellipse. --width is the variable Elementor\n     already sets from the widget's Width control, so this stays in step with\n     whatever size is set there; the literal is only a fallback. *\/\n  width: var(--width, 120px);\n  height: var(--width, 120px);\n  \/* min\/max pin the height shut. Elementor sets its own min-height on this\n     container, and on iOS the box was still coming out taller than wide even\n     with an explicit height \u2014 max-height is the one that cannot be argued\n     with, whatever is doing the stretching. *\/\n  min-height: var(--width, 120px);\n  max-height: var(--width, 120px);\n  \/* aspect-ratio is deliberately NOT set. It is redundant once width and height\n     are both explicit, and it is the property iOS was not honouring \u2014 Safari\n     has known trouble with aspect-ratio combined with min-height. Leaving it in\n     gives the browser one more thing to disagree about. *\/\n  overflow: hidden;      \/* with a square box + 50% radius this alone makes the\n                            circle, so the shape does not rest on clip-path *\/\n}\n\n\/* Elementor nests an extra widget wrapper between .headshot and\n   .elementor-widget-container, and nothing was sizing it. That broke the\n   percentage-height chain: the img's height:100% resolved against an auto\n   height, fell back to the image's intrinsic ratio, and a PORTRAIT photo made\n   this box tall \u2014 so the border-radius:50% below drew an ellipse instead of a\n   circle. Square photos happened to hide the bug entirely.\n   Targeting the generic .elementor-widget class rather than the image-specific\n   one so this survives the widget being swapped. *\/\n#imageContainer1 .headshot > .elementor-widget {\n  flex: none;\n  width: 100%;\n  height: 100%;\n  min-width: 0;\n  min-height: 0;\n}\n\n#imageContainer1 .headshot .elementor-widget-container {\n  position: relative;\n  overflow: hidden;\n  border-radius: 50%;\n  \/* Fill the (now explicitly square) parent rather than relying on\n     aspect-ratio, for the same foreignObject reason as above. *\/\n  width: 100%;\n  height: 100%;\n  \/* clip-path is kept as a second line of defence, but the circle no longer\n     depends on it: a square box with border-radius:50% and overflow:hidden is\n     the part that rasterises reliably. *\/\n  -webkit-clip-path: circle(closest-side at 50% 50%);\n  clip-path: circle(closest-side at 50% 50%);\n}\n\/* Absolutely positioned so the photo fills the circle regardless of its own\n   aspect ratio and without depending on a percentage-height chain at all.\n   inset:0 resolves against .elementor-widget-container (position:relative),\n   which is now guaranteed square, so object-fit:cover crops a portrait or\n   landscape photo to the circle rather than stretching the box to fit it. *\/\n#imageContainer1 .headshot img {\n  position: absolute;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  \/* Top-weighted, not centred. A portrait photo cropped to a circle from the\n     centre cuts the top of the head off; faces sit in the upper third of almost\n     every headshot, so anchoring to the top keeps the face in frame and takes\n     the crop out of the bottom of the shot instead. *\/\n  object-position: center top;\n  display: block;\n  border-radius: 0;\n  max-width: none;   \/* themes commonly set img{max-width:100%}; with an\n                        absolute box that can shrink the photo off-centre *\/\n}\n\n\/* ---- Colour picker UI ---- *\/\n#color-scheme { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0; max-width: 370px; }\n#color-scheme .scheme-swatch {\n  display: inline-block;\n  box-sizing: border-box;\n  width: 34px; height: 34px; cursor: pointer;\n  border: 0;\n  \/* Squircle, not a circle. clip-path has to match the border-radius: it is\n     here to forcibly clip the element past any theme button styling, so a\n     circle() would square off the corners the radius just rounded. *\/\n  border-radius: 17%;\n  -webkit-clip-path: inset(0 round 17%);\n  clip-path: inset(0 round 17%);\n  padding: 0; margin: 0; outline: none; transition: transform .1s ease;\n  -webkit-appearance: none; appearance: none;\n}\n#color-scheme .scheme-swatch:hover { transform: scale(1.12); }\n\/* The palette includes white, which would be invisible against a light form\n   background. Give every swatch a hairline edge. It has to be an INSET\n   box-shadow, not a border: clip-path: circle(50%) clips a real border away. *\/\n#color-scheme .scheme-swatch { box-shadow: inset 0 0 0 1px rgba(0,0,0,0.28); }\n\/* Listed after the hairline so the active ring replaces rather than stacks. *\/\n#color-scheme .scheme-swatch.active { box-shadow: inset 0 0 0 2px #fff, inset 0 0 0 4px #111; }\n\n\/* ---- Submit button loading state ---- *\/\n.sg-btn-loading { position: relative; color: transparent !important; pointer-events: none; }\n.sg-btn-spinner {\n  position: absolute; top: 50%; left: 50%;\n  width: 18px; height: 18px; margin: -9px 0 0 -9px;\n  border: 2px solid rgba(255,255,255,0.5); border-top-color: #fff;\n  border-radius: 50%; animation: sgSpin 0.8s linear infinite;\n}\n@keyframes sgSpin { to { transform: rotate(360deg); } }\n\n\/* ---- Stored headshot filename indicator ---- *\/\n#sg-headshot-filename { font-size: 0.85em; margin-top: 6px; color: #1a7a4a; display: none; }\n\n\/* ---- Responsive preview scaling ---- *\/\n.sg-scale-outer { width: 100%; overflow: hidden; }\n.sg-scale-wrap { width: 600px; transform-origin: top left; }\n.sg-scale-wrap #imageContainer1 {\n  width: 600px !important;\n  max-width: none !important;\n  flex: none !important;\n  margin: 0 !important;\n}\n\n\/* ---- Headshot is optional: hide the circle entirely so .textWrapper reflows ---- *\/\n#imageContainer1 .headshot.sg-hidden { display: none !important; }\n\n\/* ---- Injected free-text fields (headline \/ reason \/ CTA) ----\n   These sit BETWEEN Elementor's own form fields, so they have to look like\n   them. This form widget renders its fields WHITE with dark text on the dark\n   page (the grey #9C9C9C is the LABEL colour, not the field colour):\n\n     .elementor-field-group .elementor-field:not(.elementor-select-wrapper),\n     .elementor-field-group .elementor-select-wrapper select\n                                        { background-color: #ffffff; }\n     .elementor-field-textual           { border: 1px solid #69727d;\n                                          color: #1f2124; border-radius: 3px;\n                                          min-height: 40px; padding: 5px 14px; }\n\n   Every value below is chosen to match that, so the injected field is\n   indistinguishable from a sibling. An earlier translucent-white-on-dark\n   treatment was inferred from the label colour alone and was wrong. *\/\n.sg-custom-field {\n  \/* .elementor-field-group is display:flex; align-items:center; flex-wrap:wrap,\n     so an appended block becomes a FLEX ITEM and would otherwise sit on the\n     same row as the label\/select. Claim a full row of its own. *\/\n  flex: 0 0 100%;\n  width: 100%;\n  margin-top: 8px;\n}\n.sg-custom-field[hidden] { display: none; }\n.sg-prefixed-input {\n  display: flex; align-items: stretch;\n  background: #fff;\n  border: 1px solid #69727d; border-radius: 3px; overflow: hidden;\n}\n\/* Visible focus indicator (WCAG 2.4.7). It lives on the WRAPPER, not the input:\n   the input clears its own outline so the chip and field read as one control,\n   and this wrapper's overflow:hidden would clip the browser's default ring\n   anyway. :focus-within moves the ring out to the visible edge. *\/\n.sg-prefixed-input:focus-within {\n  \/* TWO indicators on purpose. The page behind this form is dark, so a dark\n     ring would be invisible there; a white ring would be invisible if the\n     section is ever re-skinned light. The darkened border reads against the\n     field's own white fill, and the white halo reads against the dark page. *\/\n  border-color: #1f2124;\n  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.85);\n}\n.sg-prefixed-input .sg-prefix {\n  display: flex; align-items: center; padding: 0 10px;\n  background: #eceef0; color: #1f2124;\n  white-space: nowrap; user-select: none;\n  font-size: 16px;   \/* same as the input, so the two halves sit on one baseline *\/\n}\n.sg-prefixed-input input {\n  border: 0; flex: 1; min-width: 0;\n  padding: 5px 10px;\n  background: #fff; color: #1f2124;\n  font: inherit;\n  \/* 16px is LOAD-BEARING, not cosmetic. Below 16px, iOS Safari zooms the page\n     when an input takes focus \u2014 and this field is focused PROGRAMMATICALLY the\n     moment the select changes, so it fires without the user tapping anything.\n     The zoom then fights the card's `transform: scale()` preview. Elementor's\n     own fields are 15px and already have this bug; do not copy them here.\n     (Declared after `font: inherit`, which would otherwise reset it.) *\/\n  font-size: 16px;\n  line-height: 1.4;\n  min-height: 38px;  \/* + the wrapper's 1px borders = Elementor's 40px *\/\n}\n.sg-prefixed-input input:focus { outline: none; }\n\/* Elementor's own placeholder treatment, so the ghost text matches too. *\/\n.sg-prefixed-input input::placeholder { color: inherit; opacity: .6; }\n\/* The injected inputs are type=\"search\" so the generic input[type=\"text\"] sync\n   loop cannot sweep them up. That gives WebKit\/Blink a native clear \"x\"; hide it. *\/\n.sg-custom-field input::-webkit-search-cancel-button,\n.sg-custom-field input::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }\n\/* #9C9C9C matches the Elementor field labels. No opacity on top of it \u2014 the old\n   rule stacked 0.7 opacity onto an already-dimmed colour. *\/\n\/* ---- Blocked-word state ----\n   The mask tier is silent by design; only the block tier speaks up. Message\n   colour matches the form's own error colour. *\/\n.sg-blocked-msg { display: none; margin-top: 4px; font-size: 0.85em; color: #FA3E3E; }\n.sg-blocked-msg.is-visible { display: block; }\n.sg-custom-field input.sg-input-blocked { box-shadow: inset 0 0 0 2px #FA3E3E; }\n.sg-prefixed-input:has(input.sg-input-blocked) { border-color: #FA3E3E; }\n\/* The submit button is disabled in JS; this just makes that visible, since\n   Elementor's own button styling has no disabled state. *\/\n.elementor-button.sg-btn-blocked,\nbutton.sg-btn-blocked, input.sg-btn-blocked { opacity: 0.45; cursor: not-allowed; }\n\n.sg-charcount { font-size: 0.8em; color: #9C9C9C; margin-top: 4px; display: block; }\n\n\/* ---- Headline on the card ----\n   Deliberately NO line-height here: this selector carries an ID and would beat\n   Elementor's own per-widget typography rule, silently overriding whatever\n   line-height is set in the Elementor UI. 'lines' mode copes with\n   line-height: normal on its own. *\/\n#imageContainer1 .headline :is(p, .elementor-heading-title) { margin: 0; }\n\/* One span per line, each kept on a single line. The <br> between them fixes\n   the line count, so the fitter only has to shrink until the widest line fits.\n   Without nowrap a long line would wrap and silently become a third line.\n\n   The descendant selector is NOT redundant. white-space is inherited, but modern\n   browsers split it into text-wrap-mode + text-wrap-style, and this theme sets\n   `text-wrap: pretty` on headings AND their spans. That shorthand resets\n   text-wrap-mode to `wrap` on .sg-headline-accent, so the accent span wrapped\n   inside a nowrap parent and produced a third line. Measured before the fix:\n   \"the After Party\" rendered 219px tall (three lines); after, 110px (two).\n   Targeting the descendants explicitly re-asserts nowrap past that reset. *\/\n\/* ---- Brush-stroke underline under the headline's accent word ----\n   The <svg> is injected inside .sg-headline-accent by renderHeadline. It is an\n   inline element rather than a background because CSS backgrounds do not render\n   in the snapdom export on iOS Safari.\n\n   position:absolute keeps it OUT OF FLOW, which matters: the headline fitter\n   measures each line's offsetWidth, and an in-flow underline would inflate that\n   and make the text shrink to make room for its own decoration. *\/\n\/* display:inline-block is load-bearing for iOS, not cosmetic. A `position:\n   relative` INLINE element gives an absolutely-positioned child a containing\n   block built from its line boxes, which is the murkiest corner of CSS\n   positioning and where Safari diverges from Chrome \u2014 the underline rendered\n   well below the word in the iOS export while looking correct everywhere else.\n   inline-block makes it an ordinary box, so `top: 100%` and `width: 90%` have\n   one unambiguous meaning. Safe here because the line is already nowrap, so\n   turning the span into an atomic inline cannot change where it breaks. *\/\n#imageContainer1 .headline .sg-headline-accent {\n  position: relative;\n  display: inline-block;\n}\n#imageContainer1 .headline .sg-hl-underline {\n  position: absolute;\n  left: 0;                 \/* aligned to the start of the accent word *\/\n  \/* One positioning property, not `top: 100%` plus a negative margin-top.\n     snapdom rasterises through an SVG foreignObject and the underline landed\n     too low in the iOS export while being correct in the iOS preview \u2014 so the\n     CSS is fine and it is the serialisation that mis-resolves it. Combining a\n     percentage offset with a margin gives it two things to get right instead of\n     one; calc() collapses them into a single computed value. *\/\n  \/* Gap between the word and the stroke. One knob \u2014 raise it to drop the\n     underline further, lower it (negative) to tuck it up under the glyphs.\n     In em so it tracks the headline as the fitter resizes it. *\/\n  --sg-underline-offset: 0.06em;\n  top: calc(100% + var(--sg-underline-offset));\n  width: 90%;              \/* of the accent word, per the brief *\/\n  height: auto;            \/* the viewBox ratio sets the natural thickness *\/\n  \/* Floor on the thickness, so a short word does not get a hairline. The SVG\n     carries preserveAspectRatio=\"none\" precisely so this works: with the\n     default (meet) the path would letterbox inside a taller box and look\n     identical. Only strokes BELOW the floor are stretched \u2014 a long word's\n     natural height already exceeds it, so it is left undistorted. *\/\n  min-height: 6px;\n  \/* No fill here \u2014 the path uses currentColor, and the span already carries the\n     scheme's accent colour, so the stroke follows it with no per-scheme rule. *\/\n  overflow: visible;\n  pointer-events: none;\n}\n\n#imageContainer1 .headline .sg-hl-line,\n#imageContainer1 .headline .sg-hl-line * { white-space: nowrap; }\n\n\/* ---- Reasons row on the card ----\n   One row, pills hug their content. min-width:0 on the text is what lets a pill\n   shrink and wrap its text BESIDE the emoji instead of underneath it. *\/\n#imageContainer1 .reasons {\n  display: flex;\n  flex-direction: row;\n  flex-wrap: nowrap;\n  justify-content: flex-start;\n  align-items: stretch;\n  gap: 6px;\n  width: 65%;\n}\n#imageContainer1 .reasons:empty { display: none; }\n#imageContainer1 .reason {\n  display: flex;\n  flex-direction: row;\n  \/* Emoji and text centred against each other. With text that wraps to two\n     lines the emoji sits centred on the block; min-width:0 on the text below\n     is still what keeps wrapped lines BESIDE the emoji rather than under it. *\/\n  align-items: center;\n  flex: 0 1 auto;\n  min-width: 0;\n  gap: 5px;\n  \/* A touch more on the right than the left: the emoji sits hard against\n     the left padding while the text ends on a glyph edge, so equal padding\n     reads as tighter on the right. *\/\n  padding: 4px 10px 4px 8px;\n  border-radius: 999px;\n  border: 1px solid currentColor;\n  \/* ONE knob for the whole pill. Neither the emoji (.reason i) nor the text\n     (.reason em) sets its own size, so both inherit this -- change it here and\n     they scale together and stay optically matched. In em, so it tracks the\n     card as the preview is scaled.\n     Raising it eats into the row's 65% width: the pills are `flex: 0 1 auto` in\n     a nowrap row, so three long reasons at a larger size will squeeze rather\n     than overflow, wrapping their text onto a second line inside the pill. Test\n     with three long reasons, not one short one. *\/\n  font-size: 0.72em;\n  line-height: 1.25;\n}\n\/* The emoji's own box, centred against the text block.\n   align-items:center on the pill centres this ELEMENT, but the glyph inside it\n   still sits wherever the inherited line-height (1.25) puts it -- emoji have\n   taller ascenders than the surrounding text, so the box is padded unevenly and\n   the glyph reads low. line-height:1 collapses the box onto the glyph, and the\n   inner flex centring then aligns the glyph itself rather than its line box.\n   Matters most when the text wraps to two lines, where any offset is obvious\n   against the taller block. *\/\n#imageContainer1 .reason i {\n  flex: none;\n  font-style: normal;\n  display: flex;\n  align-items: center;\n  align-self: center;\n  line-height: 1;\n}\n#imageContainer1 .reason em { flex: 1; min-width: 0; font-style: normal; overflow-wrap: break-word; }\n\n\/* ---- Reasons form UI ---- *\/\n.sg-reason-limit { font-size: 0.85em; color: #9C9C9C; margin-top: 6px; display: none; }\n.sg-reason-limit.is-visible { display: block; }\n.elementor-field-option.sg-disabled { opacity: 0.4; }\n\n\/* ---- Custom reason: emoji button + picker grid ----\n   The row itself (.sg-prefixed-input above) already lays the two controls out\n   inline: the button is a fixed-width flex item, the input keeps `flex: 1`.\n   The input's 16px font-size is set there and is LOAD-BEARING for the same\n   reason it is on the headline field \u2014 below 16px iOS Safari zooms the page on\n   focus, and this input is focused PROGRAMMATICALLY when the checkbox is\n   ticked, so it fires without a tap and fights the card's transform: scale(). *\/\n\n\/* Positioning context for the popover. It deliberately is NOT on\n   .sg-prefixed-input: that row is overflow:hidden (it clips the borderless\n   input's square corners to the rounded border) and would crop the grid to a\n   40px strip. Nothing between here and the form clips either \u2014 checked:\n   .elementor-field-group, .elementor-field-subgroup, .elementor-field-option,\n   form#downloadForm and the containing .e-con are all overflow: visible. *\/\n#sg-reason-custom { position: relative; }\n\n\/* Only what a <button> needs on top of .sg-prefix; the padding, background,\n   colour and centring all come from that shared rule. *\/\n.sg-prefixed-input button.sg-prefix {\n  flex: 0 0 48px;          \/* fixed width \u2014 the input flexes into the rest *\/\n  justify-content: center;\n  border: 0;\n  border-right: 1px solid #69727d;   \/* the seam the chip gets from its fill *\/\n  cursor: pointer;\n  font-family: inherit;\n  font-size: 18px;         \/* the emoji is the button's whole face; 16px reads\n                              undersized in a 48px control. Not an input, so it\n                              has no baseline to share and no iOS zoom risk. *\/\n  line-height: 1;\n}\n.sg-prefixed-input button.sg-prefix:hover { background: #dfe3e6; }\n\/* The wrapper's :focus-within halo sits outside the row, so it does not show\n   which HALF has focus. Give the button its own inset ring (WCAG 2.4.7);\n   :focus-visible keeps it off a plain mouse click. *\/\n.sg-prefixed-input button.sg-prefix:focus-visible {\n  outline: 2px solid #1f2124;\n  outline-offset: -3px;\n}\n\n.sg-emoji-grid {\n  position: absolute;\n  left: 0;\n  \/* Below the whole field (row + character counter) rather than a hard-coded\n     offset from the button: the row's height comes from the input's\n     min-height, and a magic number would drift if that ever changes. *\/\n  top: calc(100% + 4px);\n  z-index: 30;             \/* over the following field group, which is only\n                              spaced by margin and would otherwise show through *\/\n  display: grid;\n  grid-template-columns: repeat(8, 1fr);   \/* 8 per row \u2014 matches the source array *\/\n  gap: 2px;\n  width: 100%;\n  box-sizing: border-box;\n  padding: 6px;\n  background: #fff;        \/* solid, not translucent: it overlaps live form controls *\/\n  border: 1px solid #69727d;\n  border-radius: 3px;\n  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);\n}\n\/* display:grid would beat the `hidden` attribute's UA display:none. *\/\n.sg-emoji-grid[hidden] { display: none; }\n\n.sg-emoji-option {\n  border: 0;\n  border-radius: 3px;\n  background: transparent;\n  padding: 0;\n  min-height: 34px;        \/* with the 8-column width, a comfortable tap target *\/\n  font-size: 20px;\n  line-height: 1;\n  cursor: pointer;\n}\n.sg-emoji-option:hover { background: #eceef0; }\n.sg-emoji-option[aria-selected=\"true\"] { background: #d8dce0; }\n.sg-emoji-option:focus-visible {\n  outline: 2px solid #1f2124;\n  outline-offset: -2px;\n}\n<\/style>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-0e0b394 e-flex e-con-boxed e-con e-parent\" data-id=\"0e0b394\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t<div class=\"elementor-element elementor-element-bbc8f3d e-con-full e-flex e-con e-child\" data-id=\"bbc8f3d\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t<div class=\"elementor-element elementor-element-84c7be6 e-con-full e-flex e-con e-child\" data-id=\"84c7be6\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;background_background&quot;:&quot;classic&quot;}\">\n\t\t\t\t<div class=\"elementor-element elementor-element-f4762d9 elementor-invisible elementor-widget elementor-widget-heading\" data-id=\"f4762d9\" data-element_type=\"widget\" data-e-type=\"widget\" data-settings=\"{&quot;_animation&quot;:&quot;bounceInDown&quot;}\" data-widget_type=\"heading.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<h2 class=\"elementor-heading-title elementor-size-default\">Enter Your Details<\/h2>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-7e7faae elementor-button-align-stretch elementor-widget elementor-widget-form\" data-id=\"7e7faae\" data-element_type=\"widget\" data-e-type=\"widget\" data-settings=\"{&quot;step_next_label&quot;:&quot;Next&quot;,&quot;step_previous_label&quot;:&quot;Previous&quot;,&quot;button_width&quot;:&quot;100&quot;,&quot;step_type&quot;:&quot;number_text&quot;,&quot;step_icon_shape&quot;:&quot;circle&quot;}\" data-widget_type=\"form.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t<form class=\"elementor-form\" method=\"post\" id=\"downloadForm\" name=\"downloadForm\" aria-label=\"downloadForm\">\n\t\t\t<input type=\"hidden\" name=\"post_id\" value=\"16316\"\/>\n\t\t\t<input type=\"hidden\" name=\"form_id\" value=\"7e7faae\"\/>\n\t\t\t<input type=\"hidden\" name=\"referer_title\" value=\"Create Your Social Badge - PI LIVE Europe, 19-21 October 2026\" \/>\n\n\t\t\t\t\t\t\t<input type=\"hidden\" name=\"queried_id\" value=\"16316\"\/>\n\t\t\t\n\t\t\t<div class=\"elementor-form-fields-wrapper elementor-labels-above\">\n\t\t\t\t\t\t\t\t<div class=\"elementor-field-type-select elementor-field-group elementor-column elementor-field-group-headline elementor-col-100 elementor-field-required\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label for=\"form-field-headline\" class=\"elementor-field-label\">\n\t\t\t\t\t\t\t\tHeadline\t\t\t\t\t\t\t<\/label>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field elementor-select-wrapper remove-before \">\n\t\t\t<div class=\"select-caret-down-wrapper\">\n\t\t\t\t<i aria-hidden=\"true\" class=\"eicon-caret-down\"><\/i>\t\t\t<\/div>\n\t\t\t<select name=\"form_fields[headline]\" id=\"form-field-headline\" class=\"elementor-field-textual elementor-size-sm\" required=\"required\">\n\t\t\t\t\t\t\t\t\t<option value=\"I&#039;m Here for the Deals\">I&#039;m Here for the Deals<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"I&#039;m Here for the Networking\">I&#039;m Here for the Networking<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"I&#039;m Here for the Knowledge\">I&#039;m Here for the Knowledge<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"I&#039;m Here for the Opportunities\">I&#039;m Here for the Opportunities<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"I&#039;m Here for the Drama\">I&#039;m Here for the Drama<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"I&#039;m Here for the After Party\">I&#039;m Here for the After Party<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"I&#039;m Here for AIP Summit\">I&#039;m Here for AIP Summit<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"I&#039;m Here for IC Summit\">I&#039;m Here for IC Summit<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"I&#039;m Here to Speak on Stage\">I&#039;m Here to Speak on Stage<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"Write your own\u2026\">Write your own\u2026<\/option>\n\t\t\t\t\t\t\t<\/select>\n\t\t<\/div>\n\t\t\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field-type-text elementor-field-group elementor-column elementor-field-group-firstname elementor-col-100 elementor-field-required\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label for=\"form-field-firstname\" class=\"elementor-field-label\">\n\t\t\t\t\t\t\t\tFull Name\t\t\t\t\t\t\t<\/label>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<input size=\"1\" type=\"text\" name=\"form_fields[firstname]\" id=\"form-field-firstname\" class=\"elementor-field elementor-size-sm  elementor-field-textual\" required=\"required\">\n\t\t\t\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field-type-text elementor-field-group elementor-column elementor-field-group-lastname elementor-col-100 elementor-field-required\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label for=\"form-field-lastname\" class=\"elementor-field-label\">\n\t\t\t\t\t\t\t\tCompany or Handle\t\t\t\t\t\t\t<\/label>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<input size=\"1\" type=\"text\" name=\"form_fields[lastname]\" id=\"form-field-lastname\" class=\"elementor-field elementor-size-sm  elementor-field-textual\" required=\"required\">\n\t\t\t\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field-type-upload elementor-field-group elementor-column elementor-field-group-headshot elementor-col-100\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label for=\"form-field-headshot\" class=\"elementor-field-label\">\n\t\t\t\t\t\t\t\tUpload Headshot <em>(optional)<\/em>\t\t\t\t\t\t\t<\/label>\n\t\t\t\t\t\t\t\t<input type=\"file\" name=\"form_fields[headshot]\" id=\"form-field-headshot\" class=\"elementor-field elementor-size-sm  elementor-upload-field\">\n\n\t\t\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field-type-checkbox elementor-field-group elementor-column elementor-field-group-reasons elementor-col-100\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label for=\"form-field-reasons\" class=\"elementor-field-label\">\n\t\t\t\t\t\t\t\tPick Your Reasons\t\t\t\t\t\t\t<\/label>\n\t\t\t\t\t\t<div class=\"elementor-field-subgroup  \"><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\ud83e\udd1d Networking\" id=\"form-field-reasons-0\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-0\">\ud83e\udd1d Networking<\/label><\/span><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\ud83c\udfc6 The GPMAs\" id=\"form-field-reasons-1\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-1\">\ud83c\udfc6 The GPMAs<\/label><\/span><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\ud83c\udfa4 The sessions\" id=\"form-field-reasons-2\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-2\">\ud83c\udfa4 The sessions<\/label><\/span><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\ud83c\udf78 The After Party\" id=\"form-field-reasons-3\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-3\">\ud83c\udf78 The After Party<\/label><\/span><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\ud83e\udd47 Meeting new partners\" id=\"form-field-reasons-4\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-4\">\ud83e\udd47 Meeting new partners<\/label><\/span><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\ud83d\udca1 AIP Summit\" id=\"form-field-reasons-5\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-5\">\ud83d\udca1 AIP Summit<\/label><\/span><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\ud83d\udca1 IC Summit\" id=\"form-field-reasons-6\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-6\">\ud83d\udca1 IC Summit<\/label><\/span><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\ud83d\udca1 Discovering new tech\" id=\"form-field-reasons-7\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-7\">\ud83d\udca1 Discovering new tech<\/label><\/span><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\u2615 The coffee\" id=\"form-field-reasons-8\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-8\">\u2615 The coffee<\/label><\/span><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\ud83d\udc65 Meet brands\" id=\"form-field-reasons-9\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-9\">\ud83d\udc65 Meet brands<\/label><\/span><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\ud83d\udce2 Meet publishers\" id=\"form-field-reasons-10\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-10\">\ud83d\udce2 Meet publishers<\/label><\/span><span class=\"elementor-field-option\"><input type=\"checkbox\" value=\"\u270f\ufe0f Something else\" id=\"form-field-reasons-11\" name=\"form_fields[reasons][]\"> <label for=\"form-field-reasons-11\">\u270f\ufe0f Something else<\/label><\/span><\/div>\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field-type-select elementor-field-group elementor-column elementor-field-group-cta elementor-col-100\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label for=\"form-field-cta\" class=\"elementor-field-label\">\n\t\t\t\t\t\t\t\tCall to Action\t\t\t\t\t\t\t<\/label>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field elementor-select-wrapper remove-before \">\n\t\t\t<div class=\"select-caret-down-wrapper\">\n\t\t\t\t<i aria-hidden=\"true\" class=\"eicon-caret-down\"><\/i>\t\t\t<\/div>\n\t\t\t<select name=\"form_fields[cta]\" id=\"form-field-cta\" class=\"elementor-field-textual elementor-size-sm\">\n\t\t\t\t\t\t\t\t\t<option value=\"Come meet me\">Come meet me<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"Join my session\">Join my session<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"See my demo\">See my demo<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"Meet me at\u2026\">Meet me at\u2026<\/option>\n\t\t\t\t\t\t\t\t\t<option value=\"Say hello at\u2026\">Say hello at\u2026<\/option>\n\t\t\t\t\t\t\t<\/select>\n\t\t<\/div>\n\t\t\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field-type-html elementor-field-group elementor-column elementor-field-group-field_c3275bc elementor-col-100\">\n\t\t\t\t\t<label for=\"form-field-cta\" class=\"elementor-field-label\">Colour Scheme<\/label>\n<span id=\"color-scheme\"><\/span>\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field-type-hidden elementor-field-group elementor-column elementor-field-group-headline_custom elementor-col-100\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<input size=\"1\" type=\"hidden\" name=\"form_fields[headline_custom]\" id=\"form-field-headline_custom\" class=\"elementor-field elementor-size-sm  elementor-field-textual\">\n\t\t\t\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field-type-hidden elementor-field-group elementor-column elementor-field-group-reason_custom elementor-col-100\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<input size=\"1\" type=\"hidden\" name=\"form_fields[reason_custom]\" id=\"form-field-reason_custom\" class=\"elementor-field elementor-size-sm  elementor-field-textual\">\n\t\t\t\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field-type-hidden elementor-field-group elementor-column elementor-field-group-cta_custom elementor-col-100\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<input size=\"1\" type=\"hidden\" name=\"form_fields[cta_custom]\" id=\"form-field-cta_custom\" class=\"elementor-field elementor-size-sm  elementor-field-textual\">\n\t\t\t\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<div class=\"elementor-field-group elementor-column elementor-field-type-submit elementor-col-100 e-form__buttons\">\n\t\t\t\t\t<button class=\"elementor-button elementor-size-sm\" type=\"submit\">\n\t\t\t\t\t\t<span class=\"elementor-button-content-wrapper\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span class=\"elementor-button-text\">Download Image<\/span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<\/span>\n\t\t\t\t\t<\/button>\n\t\t\t\t<\/div>\n\t\t\t<\/div>\n\t\t<\/form>\n\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-cc4ce8e elementor-widget elementor-widget-html\" data-id=\"cc4ce8e\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<script>\n  \/* ===================== SG: pure string logic =====================\n     Pure string helpers: no DOM access, no side effects, no state. Unit-tested\n     from sg-dev\/test-sg.js -- run: node --test sg-dev\/test-sg.js\n     Called from the DOM code below: escapeHtml, splitHeadline and\n     splitCustomHeadline (headline block). splitLeadingEmoji, isCtaCustomTrigger\n     and composeCta are not wired up yet -- the reasons \/ CTA blocks land in\n     later tasks.\n     ================================================================= *\/\n  const SG = (function () {\n    'use strict';\n\n    const APOS = \"['\u2019]\";\n    const CONNECTOR = '(?:to|for)';\n    const HEADLINE_RE = new RegExp('^(I' + APOS + 'm\\\\s+here\\\\s+' + CONNECTOR + ')\\\\s+(.+)$', 'i');\n    const CUSTOM_RE = new RegExp('^(' + CONNECTOR + ')\\\\s+(.+)$', 'i');\n    const CUSTOM_LEAD = \"I'm here\";\n\n    \/\/ SOURCE OF TRUTH: these must match the option labels on the Elementor Form's\n    \/\/ `cta` select (its field_options) character for character. A mismatch\n    \/\/ silently disables the custom-text field -- there is no error path.\n    const CTA_CUSTOM_TRIGGERS = ['Meet me at', 'Say hello at'];\n\n    \/\/ Leading emoji cluster: keycap | flag (regional-indicator pair) | pictographic\n    \/\/ base with an optional skin-tone modifier OR VS16 (mutually exclusive on the\n    \/\/ same base), ZWJ-joined any number of times.\n    \/\/ VS16 (\\uFE0F), ZWJ (\\u200D) and the keycap mark (\\u20E3) are written as \\u\n    \/\/ escapes deliberately: this file is pasted through the WordPress admin into an\n    \/\/ Elementor field, and any sanitiser or copy-paste round trip that normalises\n    \/\/ invisible characters would corrupt a raw-byte regex silently.\n    \/\/ Verified to agree with Intl.Segmenter across skin tones, ZWJ sequences,\n    \/\/ flags and keycaps -- see sg-dev\/test-sg.js.\n    const EMOJI_SEQ_RE = \/^(?:[0-9#*]\\uFE0F?\\u20E3|\\p{RI}\\p{RI}|\\p{Extended_Pictographic}(?:\\p{Emoji_Modifier}|\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\p{Emoji_Modifier}|\\uFE0F)?)*)\/u;\n    \/\/ Belt and braces: every EMOJI_SEQ_RE branch already implies this, but it stops\n    \/\/ a future edit to that pattern leaking a non-emoji cluster through.\n    \/\/ NOT \\p{Emoji}: digits, # and * are all Emoji=Yes, so \"5 minutes\" would split\n    \/\/ a bare \"5\" off as an emoji.\n    const EMOJI_GUARD_RE = \/\\p{Extended_Pictographic}|\\p{RI}|\\u20E3\/u;\n\n    function tidy(v) {\n      return String(v == null ? '' : v).trim().replace(\/\\s+\/g, ' ');\n    }\n\n    \/\/ Escapes & < > only. Safe for element content and for quoted attribute\n    \/\/ values; NOT safe for unquoted attribute values (no quote\/space escaping).\n    function escapeHtml(s) {\n      return String(s == null ? '' : s)\n        .replace(\/&\/g, '&amp;').replace(\/<\/g, '&lt;').replace(\/>\/g, '&gt;');\n    }\n\n    \/\/ \"I'm here to party\" -> { prefix: \"I'm here to\", tail: \"party\" }\n    \/\/ Echoes the input's own casing rather than canonicalising it: the input is a\n    \/\/ curated select option, so its casing is intentional and should survive.\n    \/\/ (splitCustomHeadline, whose input is free text, does the opposite.)\n    function splitHeadline(value) {\n      const v = tidy(value);\n      if (!v) return { prefix: '', tail: '' };\n      const m = v.match(HEADLINE_RE);\n      if (m) return { prefix: m[1], tail: m[2] };\n      return { prefix: '', tail: v };\n    }\n\n    \/\/ Custom field: the user completes \"I'm here ___\".\n    \/\/ \"to party\" -> { prefix: \"I'm here to\", tail: \"party\" }\n    \/\/ \"partying\" -> { prefix: \"I'm here\",    tail: \"partying\" }\n    \/\/ Canonicalises the connector to lower case -- it is free text, so \"To party\"\n    \/\/ and \"to party\" must render identically.\n    function splitCustomHeadline(typed) {\n      const t = tidy(typed);\n      if (!t) return { prefix: CUSTOM_LEAD, tail: '' };\n      const m = t.match(CUSTOM_RE);\n      if (m) return { prefix: CUSTOM_LEAD + ' ' + m[1].toLowerCase(), tail: m[2] };\n      return { prefix: CUSTOM_LEAD, tail: t };\n    }\n\n    \/\/ Split a leading emoji grapheme off a checkbox label.\n    function splitLeadingEmoji(label) {\n      const s = tidy(label);\n      if (!s) return { emoji: '', text: '' };\n      const m = s.match(EMOJI_SEQ_RE);\n      if (!m) return { emoji: '', text: s };\n      const first = m[0];\n      if (!EMOJI_GUARD_RE.test(first)) return { emoji: '', text: s };\n      return { emoji: first, text: s.slice(first.length).trim() };\n    }\n\n    \/\/ The headline's accent span should highlight the KEY WORD, not a leading\n    \/\/ article: \"the networking\" renders as  the <span>networking<\/span>.\n    \/\/ Only \"the\" is treated this way; \"a\"\/\"an\"\/\"my\" are left inside the span.\n    \/\/ Casing is preserved as written, so \"the Deals\" keeps its capital D.\n    var ARTICLE_RE = \/^(the)\\s+(.+)$\/i;\n    function splitLeadingArticle(text) {\n      var t = tidy(text);\n      if (!t) return { article: '', rest: '' };\n      var m = t.match(ARTICLE_RE);\n      if (m) return { article: m[1], rest: m[2] };\n      return { article: '', rest: t };\n    }\n\n    \/* ---------- Profanity: two tiers ----------\n       These values are typed by the public and end up on a branded graphic that\n       gets downloaded and posted, so they are handled on the way OUT (card and\n       lead fields) rather than as the user types \u2014 rewriting an input under\n       someone's cursor is hostile and makes legitimate words impossible to type.\n\n       MASK  = ordinary swearing. Starred out for politeness; the graphic still\n               renders and the download still works.\n       BLOCK = slurs and hate terms. Deliberately NOT masked: \"n*****\" still\n               reads as the slur, so starring it out would put it on a branded\n               graphic anyway. The value is refused, the card renders without it,\n               and the download is disabled until it is removed.\n\n       Matching is WHOLE-TOKEN in both tiers, never substring. Substring matching\n       produces the Scunthorpe problem: \"class\", \"assassin\", \"cockpit\",\n       \"Scunthorpe\", \"Wallsend\" and \"shiitake\" all contain a listed word and\n       would be wrongly caught. A short suffix list covers plain plurals and\n       -ing\/-ed forms without opening that door \u2014 \"assassin\" is not \"ass\" plus\n       any suffix. Add variants to the list itself rather than loosening this. *\/\n    var PROFANITY_MASK = [\n      \/\/ Intentionally EMPTY. Everything that used to be masked is now refused\n      \/\/ outright via PROFANITY_BLOCK \u2014 a starred-out word still reads as the\n      \/\/ word, and this graphic carries event branding.\n      \/\/\n      \/\/ The machinery is kept because it is useful for a softer tier later:\n      \/\/ add lowercase base words here and they will be starred out (\"asshole\"\n      \/\/ -> \"a******\") while the graphic still renders and the download still\n      \/\/ works. Same whole-token matching as the block list.\n    ];\n\n    \/\/ Slurs and hate terms. Refused outright \u2014 see the note above on why these\n    \/\/ are not simply starred out. Extend for your own context; the same\n    \/\/ whole-token rules apply, so list variants explicitly rather than relying\n    \/\/ on partial matches.\n    var PROFANITY_BLOCK = [\n      'nigger', 'nigga', 'niggers', 'niggas', 'coon', 'chink',\n      'gook', 'spic', 'wetback', 'paki', 'pakis', 'raghead',\n      'towelhead', 'wog', 'kaffir', 'kike', 'yid', 'gypo',\n      'gyppo', 'pikey', 'abo', 'darkie', 'darky', 'halfbreed',\n      'jigaboo', 'sambo', 'squaw', 'redskin', 'faggot', 'faggots',\n      'fag', 'fags', 'dyke', 'dykes', 'tranny', 'trannies',\n      'shemale', 'poof', 'poofter', 'batty', 'retard', 'retards',\n      'retarded', 'spastic', 'spaz', 'mong', 'mongoloid', 'cripple',\n      'window licker', 'rapist', 'rape', 'cunt', 'cunts', 'nonce',\n      'nonces', 'paedo', 'pedo', 'paedophile', 'pedophile', 'hitler',\n      'nazi', 'nazis', 'heil', 'kkk', 'isis', 'jihad',\n      'arse', 'arsehole', 'ass', 'asshole', 'bastard', 'bellend',\n      'bitch', 'bollocks', 'bugger', 'bullshit', 'cock', 'crap',\n      'damn', 'dick', 'dickhead', 'douche', 'douchebag', 'dumbass',\n      'feck', 'fuck', 'fucker', 'git', 'goddamn', 'jackass',\n      'jerk', 'knob', 'knobhead', 'minge', 'motherfucker', 'muppet',\n      'numpty', 'piss', 'plonker', 'prick', 'pussy', 'shag',\n      'shit', 'shite', 'shithead', 'slag', 'slapper', 'slut',\n      'tit', 'tits', 'tosser', 'turd', 'twat', 'wank',\n      'wanker', 'whore', 'wtf'\n    ];\n\n    var PROFANITY_SUFFIXES = ['s', 'es', 'ed', 'ing', 'er', 'ers', 'y', 'ies'];\n    \/\/ Common character substitutions, so sh1t and @ss are caught too.\n    \/\/ Deliberately NOT including \"!\" \u2014 it is ordinary punctuation far too often.\n    var LEET = { '@': 'a', '4': 'a', '3': 'e', '1': 'i', '0': 'o', '$': 's', '5': 's', '7': 't' };\n\n    function normaliseToken(tok) {\n      var out = '';\n      var lower = String(tok == null ? '' : tok).toLowerCase();\n      for (var i = 0; i < lower.length; i++) {\n        var ch = lower.charAt(i);\n        out += Object.prototype.hasOwnProperty.call(LEET, ch) ? LEET[ch] : ch;\n      }\n      return out.replace(\/[^a-z]\/g, '');\n    }\n\n    function matchesList(tok, list) {\n      var n = normaliseToken(tok);\n      if (!n) return false;\n      for (var i = 0; i < list.length; i++) {\n        var word = list[i].replace(\/[^a-z]\/g, '');\n        if (n === word) return true;\n        for (var j = 0; j < PROFANITY_SUFFIXES.length; j++) {\n          if (n === word + PROFANITY_SUFFIXES[j]) return true;\n        }\n      }\n      return false;\n    }\n\n    function isProfane(tok) { return matchesList(tok, PROFANITY_MASK); }\n    function isBlocked(tok) { return matchesList(tok, PROFANITY_BLOCK); }\n\n    \/\/ Token = letters\/digits plus the substitution characters, so \"sh1t\" and\n    \/\/ \"@ss\" are single tokens. Punctuation around a word is left untouched.\n    var TOKEN_RE = \/[\\p{L}\\p{N}@$]+\/gu;\n\n    \/\/ \"asshole\" -> \"a******\". First character kept, length preserved, so the\n    \/\/ fitted line width does not jump when a word is masked.\n    function maskWord(tok) {\n      var chars = Array.from(String(tok == null ? '' : tok));\n      if (chars.length < 2) return chars.join('');\n      return chars[0] + new Array(chars.length).join('*');\n    }\n\n    function maskProfanity(text) {\n      return String(text == null ? '' : text)\n        .replace(TOKEN_RE, function (tok) { return isProfane(tok) ? maskWord(tok) : tok; });\n    }\n\n    \/\/ True when ANY token is on the block list. The caller refuses the whole\n    \/\/ value rather than editing it \u2014 partial redaction of a slur is still a slur.\n    function containsBlocked(text) {\n      var s = String(text == null ? '' : text);\n      var m = s.match(TOKEN_RE);\n      if (!m) return false;\n      for (var i = 0; i < m.length; i++) if (isBlocked(m[i])) return true;\n      return false;\n    }\n\n    function stripEllipsis(s) {\n      return tidy(s).replace(\/(?:\u2026|\\.{3,})\\s*$\/, '').trim();\n    }\n\n    function isCtaCustomTrigger(value) {\n      const base = stripEllipsis(value).toLowerCase();\n      if (!base) return false;\n      return CTA_CUSTOM_TRIGGERS.some(function (t) {\n        return stripEllipsis(t).toLowerCase() === base;\n      });\n    }\n\n    \/\/ \"Meet me at\u2026\" + \"Stand 42\" -> \"Meet me at Stand 42\"\n    function composeCta(phrase, custom) {\n      const p = tidy(phrase);\n      if (!isCtaCustomTrigger(p)) return p;\n      const c = tidy(custom);\n      if (!c) return p;\n      return stripEllipsis(p) + ' ' + c;\n    }\n\n\n    \/* ================= SOCIAL POST COPY =================\n       Prewritten copy offered in the post-submit popup, one variant per headline.\n\n       SOURCE OF TRUTH: every key must match a headline option's VALUE on the\n       Elementor form character for character. A mismatch is silent -- the lookup\n       falls through to the default and the user gets generic copy for a specific\n       headline, which nothing warns about. If you rename an option, rename it\n       here in the same edit.\n\n       Figures come from the event site and are held in named constants so a\n       number changes in ONE place rather than nine. Checked against\n       performancein.live\/europe: \"2,500+ attendees\", \"100+ expert speakers\",\n       \"25+ hours of curated networking\", \"16,500+ meetings\", 19-21 October 2026. *\/\n    const EVT = {\n      dates:    '19-21 October',\n      venue:    'Old Billingsgate',\n      people:   '2,500+',\n      speakers: '100+',\n      hours:    '25+',\n      meetings: '16,500+',\n      \/* The AIP and IC Summits are NOT at the main venue. Kept separate so a\n         venue change to one cannot silently move the other. *\/\n      summitVenue: 'The Steel Yard, London',\n      link:     'https:\/\/performancein.live\/europe\/',\n      tag:      '#PILIVE26'\n    };\n\n    const HEADLINE_SOCIAL_COPY = {\n      \"I'm Here for the Deals\":\n        'Three days, ' + EVT.people + ' people, and every partnership conversation I need to have '\n        + 'this year in one room. PI LIVE Europe, ' + EVT.dates + ', ' + EVT.venue + '.\\n\\n'\n        + \"Say hello if you're coming.\",\n\n      \"I'm Here for the Networking\":\n        EVT.hours + ' hours of curated networking and ' + EVT.meetings + ' meetings across three days. '\n        + \"If you work in partnerships, the people you need are all in one place. PI LIVE Europe, \"\n        + EVT.dates + ', ' + EVT.venue + '.\\n\\n'\n        + \"Let's get something in the diary.\",\n\n      \"I'm Here for the Knowledge\":\n        EVT.speakers + ' speakers and a programme built on what is actually working in partnerships '\n        + 'right now, not what worked three years ago. PI LIVE Europe, ' + EVT.dates + ', '\n        + EVT.venue + '.\\n\\n'\n        + 'Come and find me if you want to compare notes.',\n\n      \"I'm Here for the Opportunities\":\n        'Brands, agencies, publishers and creators \u2014 ' + EVT.people + ' of them, three days. '\n        + 'PI LIVE Europe, ' + EVT.dates + ', ' + EVT.venue + '.\\n\\n'\n        + 'Get in touch if you are looking for a partner.',\n\n      \"I'm Here for the Drama\":\n        'The panels where people say what they actually think about the state of partnerships. '\n        + 'PI LIVE Europe, ' + EVT.dates + ', ' + EVT.venue + '.\\n\\n'\n        \/\/ \\uD83D\\uDC4B is the waving-hand emoji. Written as an escape for the\n        \/\/ same reason as the emoji regexes above: this file is pasted through\n        \/\/ the WordPress admin, and a sanitiser that normalises characters would\n        \/\/ corrupt a raw one silently.\n        + 'See you there \\uD83D\\uDC4B',\n\n      \"I'm Here for the After Party\":\n        'Three days of partnerships talk, and the part everyone remembers. '\n        + 'See you at the After Party \\uD83D\\uDD7A\\n\\n'          \/\/ dancing man\n        + 'PI LIVE Europe, ' + EVT.dates + ', ' + EVT.venue + '.',\n\n      \"I'm Here for AIP Summit\":\n        \"The AIP Summit is where I will be spending my time this year, as part of \"\n        + \"PI LIVE's Festival of Partnerships at \" + EVT.summitVenue + \" on 21 October.\\n\\n\"\n        + \"Drop me a message if you're going to be there too!\",\n\n      \"I'm Here for IC Summit\":\n        \/\/ The two summits run on DIFFERENT days: AIP on the 21st, IC on the 20th.\n        \/\/ Marketing's draft had 21 October on both, which was a copy-paste slip\n        \/\/ from the AIP variant; Jon confirmed the 20th. Keep them distinct.\n        \"The IC Summit is where I will be spending my time this year, as part of \"\n        + \"PI LIVE's Festival of Partnerships at \" + EVT.summitVenue + \" on 20 October.\\n\\n\"\n        + 'Give me a shout if you are coming.',\n\n      \"I'm Here to Speak on Stage\":\n        'I am speaking at PI LIVE Europe this year, alongside ' + EVT.speakers + ' others across '\n        + 'three days at ' + EVT.venue + ', ' + EVT.dates + '.\\n\\n'\n        + 'Come and say hello.'\n    };\n\n    \/* Used for \"Write your own\u2026\" and for any option not in the map above. It has\n       to work without knowing the user's intent, so it stays on the facts. *\/\n    const HEADLINE_SOCIAL_COPY_DEFAULT =\n      'Three days, ' + EVT.people + ' people, and every partnership conversation I need to have '\n      + 'this year in one room. PI LIVE Europe, ' + EVT.dates + ', ' + EVT.venue + '.\\n\\n'\n      + \"Say hello if you're coming.\";\n\n    \/* The link and hashtag are appended rather than repeated in every variant,\n       so they can never drift apart between options. *\/\n    \/* Filename slug: lowercase, accents folded, anything not a letter or digit\n       becomes a single hyphen. Keeps downloads sortable and safe on every OS --\n       Windows rejects \\ \/ : * ? \" < > | outright, and a stray one silently\n       aborts the save rather than erroring. *\/\n    function fileSlug(value) {\n      return String(value == null ? '' : value)\n        .normalize('NFD')\n        .replace(\/[\\u0300-\\u036f]\/g, '')\n        .toLowerCase()\n        .replace(\/[^a-z0-9]+\/g, '-')\n        .replace(\/^-+|-+$\/g, '');\n    }\n\n    \/* opts.includeLink === false omits the URL. That exists for LinkedIn: any\n       URL in the composer text makes LinkedIn generate a link-preview card, and\n       that card occupies the media slot -- the user cannot attach their graphic\n       until they dismiss it. A LinkedIn post is a native image post OR a link\n       preview, not both. Everywhere else (the clipboard) keeps the link. *\/\n    function socialCopyFor(headlineValue, opts) {\n      const key = tidy(headlineValue);\n      const body = Object.prototype.hasOwnProperty.call(HEADLINE_SOCIAL_COPY, key)\n        ? HEADLINE_SOCIAL_COPY[key]\n        : HEADLINE_SOCIAL_COPY_DEFAULT;\n      const includeLink = !(opts && opts.includeLink === false);\n      return includeLink\n        ? body + '\\n\\n' + EVT.link + '\\n' + EVT.tag\n        : body + '\\n\\n' + EVT.tag;\n    }\n\n    return {\n      tidy: tidy,\n      escapeHtml: escapeHtml,\n      splitHeadline: splitHeadline,\n      splitCustomHeadline: splitCustomHeadline,\n      splitLeadingEmoji: splitLeadingEmoji,\n      splitLeadingArticle: splitLeadingArticle,\n      maskProfanity: maskProfanity,\n      \/\/ Replaces the mask list at runtime. Exists so the dormant mask tier stays\n      \/\/ covered by tests while PROFANITY_MASK is empty \u2014 and so a word can be\n      \/\/ tried from the console before committing it to the list.\n      setMaskList: function (list) {\n        PROFANITY_MASK = Array.isArray(list) ? list.slice() : [];\n      },\n      isProfane: isProfane,\n      isBlocked: isBlocked,\n      containsBlocked: containsBlocked,\n      stripEllipsis: stripEllipsis,\n      isCtaCustomTrigger: isCtaCustomTrigger,\n      composeCta: composeCta,\n      socialCopyFor: socialCopyFor,\n      fileSlug: fileSlug,\n      \/\/ Exposed so a test can assert the keys match the form's options.\n      headlineCopyKeys: function () { return Object.keys(HEADLINE_SOCIAL_COPY); }\n    };\n  })();\n\n  const eventName = 'CreatorFest ';\n\n  \/* ---------- Card text elements are NOT always <p> ----------\n     An Elementor Heading widget renders whatever tag its \"HTML Tag\" control is\n     set to. .firstname\/.lastname\/.cta happen to be set to `p`, but a heading\n     added with the default is an <h2> \u2014 and a selector of `.headline p` then\n     matches nothing and silently updates nothing, with no error.\n\n     Elementor applies class=\"elementor-heading-title\" to that element whatever\n     the tag is, so target the class. The `p` fallback covers a plain non-widget\n     element; firstElementChild is the last resort. *\/\n  const cardTextEl = (container) => container && (\n    container.querySelector('.elementor-heading-title') ||\n    container.querySelector('p') ||\n    container.firstElementChild\n  );\n  const cardTextEls = (containerSelector) =>\n    Array.prototype.map.call(document.querySelectorAll(containerSelector), cardTextEl)\n      .filter(Boolean);\n\n  document.addEventListener('DOMContentLoaded', function () {\n    const form = document.getElementById('downloadForm');\n    if (!form) { console.error(\"Form with ID 'downloadForm' not found.\"); return; }\n\n    \/\/ No-op stub so the headline \/ reasons \/ CTA blocks can call it today.\n    \/\/ Task 11 must ASSIGN to this binding -- `syncHiddenFields = () => {...}` --\n    \/\/ NOT redeclare it. A second `let`\/`const`\/`function syncHiddenFields` in\n    \/\/ this same scope is a SyntaxError, which kills the entire widget script:\n    \/\/ no card, no picker, no download, and nothing in the page to hint why.\n    \/\/ Every free-text value the public types is masked on the way out \u2014 card AND\n    \/\/ lead fields. Masking here rather than in the input leaves the user's own\n    \/\/ typing untouched; see SG.maskProfanity for why substring matching is\n    \/\/ deliberately avoided.\n    \/\/ Free text is handled on the way out, never in the input itself.\n    \/\/ Mask tier  -> starred out, still renders.\n    \/\/ Block tier -> the whole value is refused: nothing renders, a message shows\n    \/\/               beside the field, and the download is disabled until it goes.\n    \/\/               Partially redacting a slur still leaves a slur on the card.\n    const injectedTextInputs = [];   \/\/ filled as each field is built\n    const cleanTyped = (el) => {\n      if (!el) return '';\n      if (SG.containsBlocked(el.value)) return '';\n      return SG.maskProfanity(el.value);\n    };\n\n    \/\/ Message element per field, created lazily next to its character counter.\n    const blockedMsgFor = (input) => {\n      if (!input) return null;\n      let msg = input.__sgBlockedMsg;\n      if (!msg) {\n        msg = document.createElement('div');\n        msg.className = 'sg-blocked-msg';\n        msg.setAttribute('role', 'alert');\n        msg.textContent = 'That word can\\u2019t be used on the graphic. Please change it.';\n        const field = input.closest('.sg-custom-field') || input.parentNode;\n        field.appendChild(msg);\n        input.__sgBlockedMsg = msg;\n      }\n      return msg;\n    };\n\n    \/\/ Recompute the block state across every injected field and reflect it on\n    \/\/ the submit button. Called after any free-text change and again on submit.\n    let downloadBlocked = false;\n    const updateBlockedState = () => {\n      let anyBlocked = false;\n      injectedTextInputs.forEach((input) => {\n        if (!input) return;\n        const visible = !!(input.offsetParent || input.getClientRects().length);\n        const bad = visible && SG.containsBlocked(input.value);\n        const msg = blockedMsgFor(input);\n        if (msg) msg.classList.toggle('is-visible', bad);\n        input.classList.toggle('sg-input-blocked', bad);\n        if (bad) anyBlocked = true;\n      });\n      downloadBlocked = anyBlocked;\n      const btn = form.querySelector('button[type=\"submit\"], input[type=\"submit\"]');\n      if (btn) {\n        btn.disabled = anyBlocked;\n        btn.classList.toggle('sg-btn-blocked', anyBlocked);\n      }\n      return anyBlocked;\n    };\n\n    let syncHiddenFields = () => {};\n\n    \/\/ No-op stub so resyncInjectedFields -- which is defined ABOVE the reasons\n    \/\/ block -- can reconcile the checkbox group. The reasons block ASSIGNs to\n    \/\/ this binding; it must never redeclare it, for exactly the reason above.\n    \/\/ Needed because Elementor's post-submit form.reset() unchecks every box\n    \/\/ WITHOUT firing a single change event, so nothing else repaints them.\n    let reconcileReasons = () => {};\n\n    \/* ---------- Responsive preview scaling ---------- *\/\n    const scaleCard = document.getElementById('imageContainer1');\n    if (scaleCard && scaleCard.parentNode) {\n      const DESIGN_W = 600;\n      const outer = document.createElement('div'); outer.className = 'sg-scale-outer';\n      const scaler = document.createElement('div'); scaler.className = 'sg-scale-wrap';\n      scaleCard.parentNode.insertBefore(outer, scaleCard);\n      outer.appendChild(scaler);\n      scaler.appendChild(scaleCard);\n\n      outer.style.width = '100%';\n      outer.style.overflow = 'hidden';\n      scaler.style.width = DESIGN_W + 'px';\n      scaler.style.transformOrigin = 'top left';\n\n    \/* Largest the on-screen preview is allowed to get, as a fraction of the\n       600px design width. 1 = show it at its true 600px when the column has\n       room; anything narrower still scales down automatically, because the\n       scale is min(MAX_SCALE, available \/ 600).\n\n       Capped at 1 deliberately: the card IS a 600px design, so going beyond\n       would enlarge it past the proportions everything else is tuned against.\n       The export is unaffected either way \u2014 capture always renders at\n       1080 \/ container.offsetWidth, and offsetWidth stays 600 whatever this is. *\/\n    const MAX_SCALE = 1;\n      const fitCard = () => {\n        const vw = document.documentElement.clientWidth || window.innerWidth;\n        let available = outer.clientWidth;\n        \/\/ Clamp to the real viewport: outer's parent can stretch to fit the\n        \/\/ forced-600 card, so outer.clientWidth alone can wrongly report >= 600.\n        if (!available || (vw && available > vw)) available = vw;\n        \/\/ Both viewport readings can legitimately be 0 \u2014 a backgrounded tab, an\n        \/\/ orientation change mid-flight, layout queried before first paint. The\n        \/\/ old code turned that into scale(0) and the card vanished until the\n        \/\/ next resize. A meaningless measurement means \"don't touch it\".\n        if (!available || available <= 0) return;\n        const s = Math.min(MAX_SCALE, available \/ DESIGN_W);\n        if (!(s > 0)) return;\n        scaler.style.transform = 'scale(' + s + ')';\n        const visualW = DESIGN_W * s;\n        scaler.style.marginLeft = Math.max(0, (available - visualW) \/ 2) + 'px';\n        outer.style.height = (scaler.offsetHeight * s) + 'px';\n      };\n      window.addEventListener('resize', fitCard);\n      window.addEventListener('load', fitCard);\n      fitCard();\n      setTimeout(fitCard, 300);\n    }\n\n    \/* ---------- Text field sync (firstname \/ lastname \/ etc.) ---------- *\/\n    const setFirstnameLines = (value) => {\n      const v = (value || '').trim();\n      const i = v.indexOf(' ');\n      cardTextEls('#imageContainer1 .firstname').forEach(p => {\n        p.style.whiteSpace = 'nowrap';\n        if (i > -1) p.innerHTML = SG.escapeHtml(v.slice(0, i)) + '<br>' + SG.escapeHtml(v.slice(i + 1));\n        else p.textContent = v;\n      });\n    };\n\n    const inputFields = form.querySelectorAll('input[type=\"text\"]');\n    inputFields.forEach(input => {\n      input.addEventListener('input', (event) => {\n        const inputIdSuffix = event.target.id.split('form-field-').pop();\n        const newValue = event.target.value;\n        if (inputIdSuffix === 'firstname') {\n          setFirstnameLines(newValue);\n          scaleTextToFit('.firstname', FIT_MAX_EM.firstname, FIT_MIN_EM.firstname);\n        } else {\n          cardTextEls(`#imageContainer1 .${inputIdSuffix}`).forEach(textElement => {\n            textElement.textContent = newValue;\n            scaleTextToFit(`.${inputIdSuffix}`, FIT_MAX_EM[inputIdSuffix], FIT_MIN_EM[inputIdSuffix]);\n          });\n        }\n      });\n    });\n\n    \/* ---------- Shared: injected conditional free-text field ----------\n       Three fields have this exact shape: the custom headline (Task 7), the\n       custom reason with its emoji picker (Task 9) and the CTA free text\n       (Task 10). Each hangs off a \"trigger\" control (a select, or a checkbox)\n       and appears only while that trigger says so. Builds:\n\n         <div class=\"sg-custom-field\" id=OPTS.id hidden>\n           <div class=\"sg-prefixed-input\">\n             <span class=\"sg-prefix\">PREFIX<\/span>          (omitted if prefix is '')\n             <input type=\"search\" id=\"OPTS.id-input\" aria-describedby=\"OPTS.id-count\">\n           <\/div>\n           <span class=\"sg-charcount\" id=\"OPTS.id-count\">0 \/ MAX<\/span>\n         <\/div>\n\n       opts: { id, prefix, placeholder, maxLength, ariaLabel, isActive, onChange }\n       returns { wrap, input, sync }.\n\n       WHY type=\"search\" AND NOT type=\"text\": the generic sync loop above does\n       form.querySelectorAll('input[type=\"text\"]') and derives a card CSS class\n       from each element's id, so a text input here would be swept into it and\n       would try to paint a \".sg-headline-custom-input\" block on the card.\n       BUT NOTE: the post-submit fieldSnapshot restore loop in the submit\n       handler DELIBERATELY DOES cover these -- it excludes only\n       file\/submit\/button\/hidden, so `search` is included and their values\n       survive Elementor's reset. Adding 'search' to that exclusion list would\n       silently break restore for all three fields.\n\n       No aria-live on the counter: it would announce on every keystroke.\n       aria-describedby gives a screen reader the limit on focus instead. *\/\n    const injectedFields = [];\n\n    const makeInjectedField = (trigger, opts) => {\n      \/\/ The one place the limit is written down. It drives the maxlength\n      \/\/ attribute, the initial counter and every later counter update.\n      const MAX = opts.maxLength;\n      const inputId = opts.id + '-input';\n      const countId = opts.id + '-count';\n\n      const wrap = document.createElement('div');\n      wrap.className = 'sg-custom-field';\n      wrap.id = opts.id;\n      wrap.hidden = true;\n\n      const row = document.createElement('div');\n      row.className = 'sg-prefixed-input';\n      if (opts.prefixEl) {\n        \/\/ A caller-supplied NODE in place of the static text chip -- the custom\n        \/\/ reason passes a <button> that opens the emoji picker. It is given the\n        \/\/ same .sg-prefix class so both variants sit identically in the row;\n        \/\/ everything the node needs beyond that is the caller's business.\n        opts.prefixEl.classList.add('sg-prefix');\n        row.appendChild(opts.prefixEl);\n      } else if (opts.prefix) {\n        const chip = document.createElement('span');\n        chip.className = 'sg-prefix';\n        \/\/ No trailing &nbsp;: .sg-prefix already carries `padding: 0 10px`.\n        chip.textContent = opts.prefix;\n        row.appendChild(chip);\n      }\n\n      \/\/ Built with DOM properties, not an innerHTML string: SG.escapeHtml does\n      \/\/ not escape quotes, so an opts value containing one would break out of\n      \/\/ an attribute. Nothing to escape this way.\n      const input = document.createElement('input');\n      input.type = 'search';\n      input.id = inputId;\n      input.maxLength = MAX;\n      input.autocomplete = 'off';\n      if (opts.placeholder) input.placeholder = opts.placeholder;\n      if (opts.ariaLabel) input.setAttribute('aria-label', opts.ariaLabel);\n      input.setAttribute('aria-describedby', countId);\n      row.appendChild(input);\n\n      const counter = document.createElement('span');\n      counter.className = 'sg-charcount';\n      counter.id = countId;\n\n      wrap.appendChild(row);\n      wrap.appendChild(counter);\n\n      \/\/ INSERTION POINT. NOT trigger.parentNode: Elementor Pro wraps a select in\n      \/\/   .elementor-field-group > .elementor-field.elementor-select-wrapper > select\n      \/\/ and the wrapper also holds .select-caret-down-wrapper, which is\n      \/\/ position:absolute; top:50%; translateY(-50%). Inserting inside the\n      \/\/ select wrapper grows it and floats the caret down over our input.\n      \/\/ Append to the FIELD GROUP instead, as its last child, so the injected\n      \/\/ field is a sibling row under the select. The || fallback keeps this\n      \/\/ degrading rather than throwing if Elementor's markup ever changes.\n      const host = trigger.closest('.elementor-field-group') || trigger.parentNode;\n      host.appendChild(wrap);\n\n      \/\/ Re-derives BOTH pieces of state from their sources. Anything that can\n      \/\/ change the trigger or the input value behind our back -- Elementor's\n      \/\/ post-submit form.reset(), the browser's own form restoration on a\n      \/\/ back\/forward navigation, a programmatic set -- must be followed by a\n      \/\/ sync() or the field and its counter drift out of step with reality.\n      \/\/ Enforce the cap in code as well as via the maxlength attribute.\n      \/\/ maxlength only constrains TYPING -- it does nothing about a value set\n      \/\/ programmatically, and the post-submit restore loop does exactly that\n      \/\/ (`el.value = value`). Without this a longer value renders in full and\n      \/\/ the counter reads a nonsense \"44 \/ 20\".\n      const clampToMax = () => {\n        if (input.value.length > MAX) {\n          const at = input.selectionStart;\n          input.value = input.value.slice(0, MAX);\n          if (at != null) { try { input.setSelectionRange(Math.min(at, MAX), Math.min(at, MAX)); } catch (e) {} }\n        }\n      };\n\n      const sync = () => {\n        wrap.hidden = !opts.isActive();\n        clampToMax();\n        counter.textContent = input.value.length + ' \/ ' + MAX;\n        \/\/ A hidden field must not keep the download disabled \u2014 updateBlockedState\n        \/\/ only counts inputs that are actually on screen.\n        updateBlockedState();\n      };\n\n      input.addEventListener('input', () => {\n        clampToMax();\n        counter.textContent = input.value.length + ' \/ ' + MAX;\n        updateBlockedState();\n        if (opts.onChange) opts.onChange();\n      });\n      trigger.addEventListener('change', () => {\n        sync();\n        if (!wrap.hidden) input.focus();\n        updateBlockedState();\n        if (opts.onChange) opts.onChange();\n      });\n\n      sync();\n      const field = { wrap: wrap, input: input, sync: sync, onChange: opts.onChange };\n      injectedFields.push(field);\n      injectedTextInputs.push(input);\n      return field;\n    };\n\n    \/\/ Reconcile every injected field with the live DOM and repaint the card.\n    const resyncInjectedFields = () => {\n      injectedFields.forEach(f => {\n        f.sync();\n        if (f.onChange) f.onChange();\n      });\n      \/\/ The checkboxes need the same treatment and cannot get it from the loop\n      \/\/ above: form.reset() unchecks them without firing change, so the pills,\n      \/\/ the disabled states and the \"n of 3\" message would all stay frozen on\n      \/\/ the pre-submit selection. Assigned by the reasons block further down.\n      reconcileReasons();\n      refitAll();\n    };\n\n    \/* ---------- Headline select + custom entry ---------- *\/\n    const headlineSelect = document.getElementById('form-field-headline');\n    let headlineCustomInput = null;\n\n    \/\/ The custom option is the LAST option in the select -- positional by\n    \/\/ design, so the option's wording can be edited in Elementor without\n    \/\/ touching this file. Guarded at fewer than 2 options: a select that is\n    \/\/ empty, or still has only its placeholder, has no meaningful \"last\"\n    \/\/ option and must never be read as \"the user chose custom\".\n    const isHeadlineCustom = () => {\n      if (!headlineSelect || headlineSelect.options.length < 2) return false;\n      return headlineSelect.selectedIndex === headlineSelect.options.length - 1;\n    };\n\n    \/\/ Takes the {prefix, tail} object both callers already hold. Two positional\n    \/\/ strings invited a silent swap -- nothing downstream can tell them apart.\n    \/* Brush-stroke underline drawn beneath the headline's accent word.\n\n       INLINE <svg>, not a background-image or mask-image: CSS backgrounds do\n       not render in the snapdom export on iOS Safari, and this has to appear in\n       the downloaded PNG, not just the preview.\n\n       fill=\"currentColor\" is doing the recolouring \u2014 the accent span already\n       carries the scheme's accent colour, so the stroke inherits it and no\n       per-scheme rule is needed. Swapping the artwork means replacing the path\n       and its viewBox here; keep fill=\"currentColor\" and add no width\/height,\n       as the CSS sizes it. *\/\n    const SG_UNDERLINE_SVG = '<svg class=\"sg-hl-underline\" viewBox=\"0 0 2123.518 57.897\" preserveAspectRatio=\"none\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" d=\"M1674.997,47.566c-16.258-.779-30.619-2.039-45.687,1.377-8.721-3.736-20.607-3.933-30.588-.508-6.462-1.105-13.936-.707-20.156.295-8.729,1.407-15.858-2.06-21.753-2.023-11.296.07-23.778,3.626-34.808-1.803-3.948.805-7.895,1.612-11.842,2.418-36.544-.519-73.09-.984-109.638-1.396-73.631-1.027-147.273-1.837-220.922-2.431-7.199-4.795-16.706-1.605-23.906-2.204-13.077-1.087-23.15.427-35.971.136-21.319-.482-42.641-.946-63.965-1.391-7.215-.151-15.586.816-21.124,1.346-12.594-2.106-21.666-.218-34.057-.131-10.538.075-21.076.154-31.614.238-28.986.23-57.336,2.464-85.893.423-16.002-1.143-33.465.039-50.158.222-36.378.398-72.754.85-109.129,1.354-11.673.142-23.346.29-35.018.443-15.547.651-31.092,1.313-46.635,1.983-26.728-.633-52.347.558-78.48,1.117-14.274.306-28.547.619-42.819.941-11.076.55-22.152,1.104-33.227,1.664-12.396.26-24.79.526-37.185.798-71.307,2.206-142.59,4.615-213.847,7.227-28.309.034-56.62.099-84.933.198-32.914.113-64.463.127-97.268-2.492-14.491-1.158-32.556-3.581-42.878-8.588-2.716-2.234-3.315-7.993-11.496-7.859,8.468-8.61,23.145-12.682,40.563-14.195,82.951-7.202,165.305-10.238,249.241-11.847,10.961-.477,21.922-.95,32.884-1.417,34.322-.966,68.648-1.884,102.977-2.756,92.775-2.276,185.576-4.209,278.399-5.799,99.922-1.366,199.862-2.334,299.812-2.905,134.208.468,268.398,1.653,402.555,3.555,137.254,2.696,274.451,6.142,411.572,10.337,84.06,4.033,168.063,8.348,252.007,12.943,10.489.574,19.719-.103,28.24,2.611-3.328,3.435-6.61.835-9.866.693-58.465-2.565-116.95-4.993-175.454-7.284,1.32,1.204,2.64,2.408,3.959,3.612-4.137.017-8.437-.466-13.07,1.371,33.043,1.741,66.075,3.525,99.098,5.352,31.506,2.328,63,4.696,94.481,7.104,1.517.116,3.543-.715,4.663.571,3.438,3.951-12.233-.329-25.295,2.248,15.422,1.398,30.452.631,46.75,4.742-6.629,3.108-13.39-.324-19.534-.516-16.137-.502-32.275-.992-48.414-1.473,8.002,4.22,17.435,1.991,25.642,2.745-.004.462-.009.925-.013,1.387-9.302-.375-18.603-.747-27.905-1.115,2.928-2.304.955-3.083-1.86-3.304-3.496-.274-5.053.287-5.424,2.648-14.537-.349-29.074-.689-43.611-1.021-.569.107-1.432.451-1.646,1.13,21.491,1.285,42.979,2.589,64.461,3.912-.087.53-.174,1.059-.261,1.589-8.232-.274-16.465-.545-24.697-.813-15.862,1.197-30.645,2.999-46.928.558-3.52,1.189-7.663,4.014-11.814,3.588-.959-.621-2.267-3.363-3.402-3.133-2.298.465-4.595.931-6.893,1.396-12.292-.299-26.731,2.197-38.332-.281-7.907-1.689-12.111-1.266-19.637-1.585-10.925-.462-23.074-5.567-35.685-1.614.141.689-.143,3.09-1.264,2.887-2.767-.501-5.534-1.002-8.302-1.503-8.104.292-17.247,1.586-26.286-.206-6.294.023-12.589.046-18.883.072-6.161-2.094-13.83-.909-19.891-2.092-13.564-2.646-28.144-5.467-41.406,1.462-2.874-4.787-10.175-1.557-13.47-2.015-14.816-2.058-27.246-5.957-42.231-1.601-9.2-2.168-19.471-1.774-30.833-1.388Z\"\/><\/svg>';\n\n    const renderHeadline = (parts) => {\n      const prefix = (parts && parts.prefix) || '';\n      const tail = (parts && parts.tail) || '';\n      \/\/ Two lines, always: \"I'm here for\" on top, the rest beneath. A <br>\n      \/\/ rather than two elements, so both lines share one font-size and the\n      \/\/ fitter sizes them together.\n      const a = SG.splitLeadingArticle(tail);\n      const tailHtml = (a.article ? SG.escapeHtml(a.article) + ' ' : '')\n        + (a.rest\n            ? '<span class=\"sg-headline-accent\">' + SG.escapeHtml(a.rest)\n              + SG_UNDERLINE_SVG + '<\/span>'\n            : '');\n      \/\/ Each line is its own nowrap span. That makes the line count structural\n      \/\/ rather than something the fitter has to infer from geometry, and lets\n      \/\/ it fit by measuring the widest line's width \u2014 see scaleTextToFit's\n      \/\/ 'lines' mode for why counting line boxes could not be made to work.\n      const line = (inner) => '<span class=\"sg-hl-line\">' + inner + '<\/span>';\n      const html = (prefix ? line(SG.escapeHtml(prefix)) : '')\n        + (prefix && tailHtml ? '<br>' : '')\n        + (tailHtml ? line(tailHtml) : '');\n      \/\/ Scoped to the card: `.headline` is a plausible theme class name, and an\n      \/\/ unscoped match elsewhere on the page would get its innerHTML replaced.\n      cardTextEls('#imageContainer1 .headline').forEach(p => { p.innerHTML = html; });\n      scaleTextToFit('#imageContainer1 .headline', FIT_MAX_EM.headline, FIT_MIN_EM.headline,\n                     { mode: 'lines', maxLines: 2 });\n    };\n\n    const applyHeadline = () => {\n      if (!headlineSelect) return;\n      renderHeadline(isHeadlineCustom()\n        ? SG.splitCustomHeadline(cleanTyped(headlineCustomInput))\n        : SG.splitHeadline(headlineSelect.value));\n      syncHiddenFields();\n    };\n\n    if (headlineSelect) {\n      headlineCustomInput = makeInjectedField(headlineSelect, {\n        id: 'sg-headline-custom',\n        prefix: \"I'm here\",\n        placeholder: 'to party',\n        maxLength: 24,\n        ariaLabel: \"Complete the sentence: I'm here\u2026\",\n        isActive: isHeadlineCustom,\n        onChange: applyHeadline\n      }).input;\n      applyHeadline();\n    } else {\n      console.warn(\"Headline select '#form-field-headline' not found \u2014 skipping headline sync.\");\n    }\n\n    \/* ---------- CTA select + custom entry ----------\n       Lives BELOW makeInjectedField, not up with the other field sync near the\n       top of this handler: makeInjectedField is a `const` in this same scope, so\n       calling it from above would hit the temporal dead zone and throw.\n\n       applyCta takes NO argument. It re-reads both of its sources every time,\n       because either one can change without the other: the select fires change,\n       the injected input fires input, and the post-submit restore path changes\n       both behind our backs. A value passed in from one call site would go stale\n       against the other. *\/\n    const ctaSelect = document.getElementById('form-field-cta');\n    let ctaCustomInput = null;\n\n    const applyCta = () => {\n      if (!ctaSelect) return;\n      \/\/ composeCta owns the whole rule: a non-trigger phrase ignores any text\n      \/\/ still sitting in the (hidden) input, and a trigger phrase with nothing\n      \/\/ typed keeps its ellipsis, so the card reads \"Meet me at\u2026\" as a prompt.\n      const text = SG.composeCta(ctaSelect.value, cleanTyped(ctaCustomInput));\n      \/\/ Scoped to the card: `.cta` is a very plausible theme class name, and an\n      \/\/ unscoped match elsewhere on the page would get its text replaced. Same\n      \/\/ reasoning as renderHeadline and the reasons mount above.\n      cardTextEls('#imageContainer1 .cta').forEach(p => { p.textContent = text; });\n      \/\/ The old block never re-fitted, so a composed phrase simply overflowed\n      \/\/ (.cta is white-space: nowrap in the CSS widget).\n      scaleTextToFit('#imageContainer1 .cta', FIT_MAX_EM.cta, FIT_MIN_EM.cta);\n      syncHiddenFields();\n    };\n\n    if (ctaSelect) {\n      \/\/ No prefix chip. The other two fields need one because the card shows a\n      \/\/ sentence the user is completing and the field alone would be meaningless;\n      \/\/ here the chosen phrase is already visible on the card AND in the select\n      \/\/ directly above, so a chip would just say it a third time.\n      ctaCustomInput = makeInjectedField(ctaSelect, {\n        id: 'sg-cta-custom',\n        placeholder: 'Stand 42',\n        maxLength: 25,\n        ariaLabel: 'Where to meet you \u2014 for example, Stand 42',\n        isActive: () => SG.isCtaCustomTrigger(ctaSelect.value),\n        onChange: applyCta\n      }).input;\n      \/\/ Once at init: the browser restores a select's value on a back\/forward\n      \/\/ navigation without firing change, so the card must be painted from\n      \/\/ whatever the select already says.\n      applyCta();\n    } else {\n      console.warn(\"CTA select '#form-field-cta' not found \u2014 skipping CTA sync.\");\n    }\n\n    \/* ---------- Reasons checkboxes + card pills ----------\n       Elementor renders a checkbox group as\n         .elementor-field-group > .elementor-field-subgroup\n             > .elementor-field-option > input + label\n       and names every box `form_fields[reasons][]`. The id-prefix selector is a\n       fallback for the case where the field's Elementor NAME is edited but its\n       ID is not (or vice versa) -- either one alone is enough to find them. *\/\n    const MAX_REASONS = 3;\n    let reasonBoxes = Array.prototype.slice.call(\n      form.querySelectorAll('input[type=\"checkbox\"][name^=\"form_fields[reasons]\"]'));\n    if (!reasonBoxes.length) {\n      reasonBoxes = Array.prototype.slice.call(\n        form.querySelectorAll('input[type=\"checkbox\"][id^=\"form-field-reasons\"]'));\n    }\n\n    \/\/ Scoped to the card. `.reasons` \/ `.reason` are plausible theme class\n    \/\/ names; an unscoped match elsewhere on the page would get emptied on every\n    \/\/ render. Same reasoning as renderHeadline above.\n    const reasonsMount = document.querySelector('#imageContainer1 .reasons');\n\n    \/\/ The custom (free-text) reason is the LAST checkbox in the group --\n    \/\/ positional by design, so its wording can be edited in Elementor without\n    \/\/ touching this file. Mirrors isHeadlineCustom's \"last option\" convention.\n    \/\/ Guarded at fewer than 2 boxes: a group with one option (or none) has no\n    \/\/ meaningful \"last\" entry and must never be read as \"this one is custom\".\n    const customReasonBox = reasonBoxes.length >= 2\n      ? reasonBoxes[reasonBoxes.length - 1]\n      : null;\n\n    \/\/ SEAM FOR TASK 9. The custom reason's pill content does not come from its\n    \/\/ label -- it comes from the emoji picker plus the typed text. Task 9 must\n    \/\/ ASSIGN to this binding (`getCustomReasonParts = () => ({...})`), never\n    \/\/ redeclare it, and can then just call renderReasons() to repaint.\n    \/\/ Returning null (or empty parts) renders NO pill for the custom reason,\n    \/\/ which is the whole of Task 8's behaviour for it.\n    let getCustomReasonParts = () => null;\n\n    \/\/ The VALUE attribute is the source of truth for a preset reason, not the\n    \/\/ visible label.\n    \/\/\n    \/\/ WordPress's emoji script (wp-emoji-release.min.js) rewrites emoji\n    \/\/ characters in the DOM into <img class=\"emoji\"> tags. label.textContent\n    \/\/ therefore returns the text with the emoji MISSING, while the value\n    \/\/ attribute is left alone because WordPress does not rewrite attributes.\n    \/\/ Measured on production: value \"\ud83e\udd1d Networking\" (U+1F91D \u2026) against label\n    \/\/ \" Networking\" (leading space, no emoji) \u2014 so every preset pill lost its\n    \/\/ emoji while the custom one, whose emoji comes from a JS array, kept it.\n    \/\/ It does not reproduce locally, where that script is not running.\n    \/\/\n    \/\/ Elementor builds both from the same option string, so the value carries\n    \/\/ the author's text verbatim. The label is only a fallback, and its emoji\n    \/\/ is recovered from the alt text of any <img class=\"emoji\"> WordPress left\n    \/\/ behind.\n    const reasonLabelText = (box) => {\n      const fromValue = SG.tidy(box.value);\n      if (fromValue) return fromValue;\n      const lab = box.id ? form.querySelector('label[for=\"' + box.id + '\"]') : null;\n      if (!lab) return '';\n      let out = '';\n      lab.childNodes.forEach((n) => {\n        if (n.nodeType === Node.TEXT_NODE) out += n.nodeValue;\n        else if (n.nodeName === 'IMG') out += (n.getAttribute('alt') || '');\n        else out += (n.textContent || '');\n      });\n      return SG.tidy(out);\n    };\n\n    \/\/ Built with DOM properties, not an innerHTML string: the label text is\n    \/\/ author-supplied and reaches the card verbatim, and textContent cannot be\n    \/\/ parsed as markup at all. Same pattern as makeInjectedField.\n    const buildReasonPill = (parts) => {\n      const pill = document.createElement('span');\n      pill.className = 'reason';\n      \/\/ No empty <i> when there is no emoji: it would still take the flex gap\n      \/\/ and push the text off-centre.\n      if (parts.emoji) {\n        const icon = document.createElement('i');\n        icon.textContent = parts.emoji;\n        pill.appendChild(icon);\n      }\n      const text = document.createElement('em');\n      text.textContent = parts.text;\n      pill.appendChild(text);\n      return pill;\n    };\n\n    const renderReasons = () => {\n      if (!reasonsMount) return;\n      \/\/ textContent = '' leaves NO whitespace text node behind, which is what\n      \/\/ keeps the `.reasons:empty { display: none }` rule working.\n      reasonsMount.textContent = '';\n      reasonBoxes.forEach(box => {\n        if (!box.checked) return;\n        const parts = (box === customReasonBox)\n          ? getCustomReasonParts()\n          : SG.splitLeadingEmoji(reasonLabelText(box));\n        if (!parts || (!parts.emoji && !parts.text)) return;\n        reasonsMount.appendChild(buildReasonPill(parts));\n      });\n      \/\/ The pills use a fixed font-size and are deliberately NOT text-fitted.\n      syncHiddenFields();\n    };\n\n    \/\/ At the limit, every UNCHECKED box is disabled so the cap is enforced by\n    \/\/ the control itself rather than by an error message after the fact.\n    \/\/ Unticking any one re-enables the rest, because `checked` drops below MAX.\n    const reasonLimitMsg = document.createElement('div');\n    reasonLimitMsg.className = 'sg-reason-limit';\n    reasonLimitMsg.setAttribute('role', 'status');\n\n    const enforceReasonLimit = () => {\n      const checked = reasonBoxes.filter(b => b.checked).length;\n      const atLimit = checked >= MAX_REASONS;\n      reasonBoxes.forEach(b => {\n        const off = atLimit && !b.checked;\n        b.disabled = off;\n        \/\/ parentNode fallback: the option wrapper is Elementor's markup, not\n        \/\/ ours, and a theme or a future Elementor release could drop it.\n        const option = (b.closest && b.closest('.elementor-field-option')) || b.parentNode;\n        if (option && option.classList) option.classList.toggle('sg-disabled', off);\n      });\n      reasonLimitMsg.textContent = checked + ' of ' + MAX_REASONS + ' selected';\n      reasonLimitMsg.classList.toggle('is-visible', atLimit);\n    };\n\n    if (reasonBoxes.length) {\n      if (!reasonsMount) {\n        console.warn(\"Reasons mount '#imageContainer1 .reasons' not found \u2014 pills will not render.\");\n      }\n      const subgroup = reasonBoxes[0].closest\n        ? reasonBoxes[0].closest('.elementor-field-subgroup')\n        : null;\n      const msgAnchor = subgroup\n        || (reasonBoxes[0].closest && reasonBoxes[0].closest('.elementor-field-group'))\n        || reasonBoxes[0].parentNode;\n      if (msgAnchor && msgAnchor.insertAdjacentElement) {\n        msgAnchor.insertAdjacentElement('afterend', reasonLimitMsg);\n      }\n      reasonBoxes.forEach(b => b.addEventListener('change', () => {\n        enforceReasonLimit();\n        renderReasons();\n      }));\n      \/\/ Fills in the stub declared at the top of this scope, so the\n      \/\/ post-submit restore path can put the whole group back in step.\n      reconcileReasons = () => { enforceReasonLimit(); renderReasons(); };\n      \/\/ Once at init: the browser restores checkbox state on a back\/forward\n      \/\/ navigation without firing a single change event.\n      reconcileReasons();\n    } else {\n      console.warn(\"Reasons checkboxes not found \u2014 skipping reasons sync.\");\n    }\n\n    \/* ---------- Custom reason: emoji picker + free text ----------\n       Fills the SEAM above by ASSIGNING to getCustomReasonParts. The pill for\n       this one reason is built from the picked emoji plus the typed text, not\n       from the checkbox's label. *\/\n    if (customReasonBox) {\n      \/\/ EDIT THIS LIST to change the picker. One entry per emoji,\n      \/\/ `emoji|accessible name`; the name is what a screen reader announces.\n      \/\/ Eight per line here because the grid is eight columns wide, so the\n      \/\/ source layout is the picker layout. Keep the length a multiple of 8.\n      const REASON_EMOJI = [\n        '\u26a1|the energy',   '\ud83d\udcf8|photos',    '\ud83c\udf99\ufe0f|podcast',   '\u270f\ufe0f|writing',   '\ud83c\udfac|editing',   '\ud83e\udde0|learning',    '\ud83d\udcdd|notes',     '\ud83e\uddd1\u200d\ud83d\udcbb|working',\n        '\ud83e\udd1d|networking','\ud83d\udcac|chatting',  '\ud83e\udef6|community', '\ud83d\udc4b|saying hi', '\ud83d\ude4c|celebrate', '\ud83e\udd73|partying',  '\ud83d\udc83|dancing',   '\ud83c\udf89|the party',\n        '\u2708\ufe0f|flying',    '\ud83c\udf0d|travel',    '\ud83d\udc40|the sights', '\ud83d\ude80|launching', '\ud83d\uddfa\ufe0f|exploring', '\ud83c\udfe8|the hotel', '\ud83d\udcb7|the deals', '\ud83c\udf9f\ufe0f|tickets',\n        '\u2615|coffee',    '\ud83c\udf55|pizza',     '\ud83c\udf79|cocktails', '\ud83e\udd42|toasting',  '\u2b50|the stars', '\ud83d\udca1|ideas',     '\ud83d\udd25|the hype',  '\u2764\ufe0f|the love'\n      ];\n      const DEFAULT_EMOJI = '\ud83d\ude42';\n\n      let chosenEmoji = DEFAULT_EMOJI;\n\n      \/\/ The picker trigger. It stands where the headline field's static text\n      \/\/ chip stands, which is the whole reason makeInjectedField grew prefixEl.\n      const emojiBtn = document.createElement('button');\n      emojiBtn.type = 'button';\n      emojiBtn.className = 'sg-emoji-btn';\n      emojiBtn.textContent = chosenEmoji;\n      emojiBtn.id = 'sg-emoji-btn';\n      \/\/ Not aria-haspopup=\"true\" \u2014 that maps to \"menu\". This popover is a plain\n      \/\/ group of buttons, so the honest signal is aria-expanded + aria-controls.\n      emojiBtn.setAttribute('aria-haspopup', 'dialog');\n      emojiBtn.setAttribute('aria-expanded', 'false');\n      emojiBtn.setAttribute('aria-controls', 'sg-emoji-grid');\n      emojiBtn.setAttribute('aria-label', 'Choose an emoji for your own reason');\n\n      const emojiGrid = document.createElement('div');\n      emojiGrid.id = 'sg-emoji-grid';\n      emojiGrid.className = 'sg-emoji-grid';\n      emojiGrid.hidden = true;\n      \/\/ Deliberately NOT role=\"listbox\". A listbox contract promises a single tab\n      \/\/ stop with roving tabindex and arrow-key navigation; these are 32 real\n      \/\/ <button>s, each its own tab stop. Claiming listbox would make the\n      \/\/ semantics fight the behaviour for a screen-reader user. A labelled group\n      \/\/ of buttons describes exactly what this is, and buttons are natively\n      \/\/ operable by keyboard.\n      emojiGrid.setAttribute('role', 'group');\n      emojiGrid.setAttribute('aria-label', 'Choose an emoji');\n\n      const setGridOpen = (open) => {\n        emojiGrid.hidden = !open;\n        emojiBtn.setAttribute('aria-expanded', open ? 'true' : 'false');\n      };\n      \/\/ refocus is for the keyboard paths (Escape, choosing an emoji): focus\n      \/\/ must come back to the trigger, or it falls to <body> and the tab order\n      \/\/ restarts at the top of the page. An outside CLICK moved focus itself,\n      \/\/ so stealing it back there would fight the user.\n      const closeGrid = (refocus) => {\n        if (emojiGrid.hidden) return;\n        setGridOpen(false);\n        if (refocus) emojiBtn.focus();\n      };\n\n      const chooseEmoji = (char) => {\n        chosenEmoji = char;\n        emojiBtn.textContent = char;\n        Array.prototype.forEach.call(\n          emojiGrid.querySelectorAll('.sg-emoji-option'),\n          o => o.setAttribute('aria-pressed', o.textContent === char ? 'true' : 'false'));\n        closeGrid(true);\n        renderReasons();\n      };\n\n      REASON_EMOJI.forEach(entry => {\n        \/\/ No emoji contains a pipe, so a plain split is safe. A missing name\n        \/\/ falls back to the character itself rather than announcing nothing.\n        const bits = String(entry).split('|');\n        const char = bits[0];\n        const opt = document.createElement('button');\n        opt.type = 'button';\n        opt.className = 'sg-emoji-option';\n        \/\/ textContent, not innerHTML: this list is author-edited copy and must\n        \/\/ never be parsed as markup. Same rule as everywhere else in here.\n        opt.textContent = char;\n        opt.setAttribute('aria-label', bits[1] || char);\n        \/\/ aria-pressed, not aria-selected: selected is only meaningful inside a\n        \/\/ listbox\/grid. pressed is valid on a button and says the same thing.\n        opt.setAttribute('aria-pressed', char === chosenEmoji ? 'true' : 'false');\n        opt.addEventListener('click', () => chooseEmoji(char));\n        emojiGrid.appendChild(opt);\n      });\n\n      const customReasonField = makeInjectedField(customReasonBox, {\n        id: 'sg-reason-custom',\n        prefixEl: emojiBtn,\n        placeholder: 'Your own reason',\n        maxLength: 20,\n        ariaLabel: 'Your own reason for coming',\n        isActive: () => !!customReasonBox && customReasonBox.checked,\n        onChange: () => renderReasons()\n      });\n\n      \/\/ The grid hangs off the FIELD WRAPPER, not off .sg-prefixed-input: that\n      \/\/ row is overflow:hidden (it clips the borderless input's square corners\n      \/\/ to the rounded border) and would crop the popover to a 40px strip.\n      customReasonField.wrap.appendChild(emojiGrid);\n\n      \/\/ THE SEAM. Assignment, never a redeclaration -- see the note upstream.\n      \/\/ Empty text returns null, and renderReasons then draws no pill at all,\n      \/\/ which is what keeps an unfinished custom reason off the card.\n      getCustomReasonParts = () => {\n        if (!customReasonBox.checked) return null;\n        const text = SG.tidy(cleanTyped(customReasonField.input));\n        if (!text) return null;\n        return { emoji: chosenEmoji, text: text };\n      };\n\n      emojiBtn.addEventListener('click', () => {\n        if (emojiGrid.hidden) setGridOpen(true); else closeGrid(true);\n      });\n\n      \/\/ Outside click. The button's own handler has already run by the time\n      \/\/ this fires (it bubbles), so both the button and the grid are excluded\n      \/\/ or opening it would immediately close it again.\n      document.addEventListener('click', (e) => {\n        if (emojiGrid.hidden) return;\n        if (emojiBtn.contains(e.target) || emojiGrid.contains(e.target)) return;\n        closeGrid(false);\n      });\n\n      document.addEventListener('keydown', (e) => {\n        if (emojiGrid.hidden) return;\n        if (e.key === 'Escape' || e.key === 'Esc') {\n          e.stopPropagation();\n          closeGrid(true);\n        }\n      });\n\n      \/\/ Unticking hides the field; the grid is a sibling of that field and is\n      \/\/ NOT inside it, so it would otherwise be left hanging over the form.\n      \/\/ Registered after makeInjectedField's own change listener, so sync()\n      \/\/ has already run and wrap.hidden is current.\n      customReasonBox.addEventListener('change', () => {\n        if (!customReasonBox.checked) closeGrid(false);\n      });\n\n      \/\/ The group already painted once above, before this seam was filled.\n      \/\/ Repaint so a browser-restored ticked box shows its pill.\n      renderReasons();\n    }\n\n    \/* ---------- Hidden fields: get the typed values into the lead email ----------\n       THE SEAM DECLARED AT THE TOP OF THIS HANDLER. Assignment, never a\n       redeclaration -- a second `let`\/`const`\/`function syncHiddenFields` in\n       this scope is a SyntaxError that kills the whole widget.\n\n       Placed HERE, below all three blocks, because it reads bindings each of\n       them owns -- isHeadlineCustom \/ headlineCustomInput, getCustomReasonParts,\n       ctaSelect \/ ctaCustomInput. Those are `const`s and `let`s in this same\n       scope, so an assignment above them would run before their initialisers\n       and hit the temporal dead zone. The blocks only CALL the binding (from\n       inside applyHeadline \/ applyCta \/ renderReasons, which run on events),\n       so they are happy to be defined above it and still see the real function.\n\n       Three real Elementor Hidden fields carry the values Elementor cannot see\n       for itself: the headline \/ reason \/ CTA custom controls are injected by\n       this script, are not Elementor fields, and are therefore absent from the\n       lead email -- which otherwise records `headline: Write your own\u2026` and\n       loses the typed text entirely.\n\n       EVERY FIELD IS OPTIONAL. They only exist once Jon has added them in the\n       Elementor UI, and a widget that throws because one is missing takes the\n       card, the picker and the download with it. Each write is guarded. *\/\n    const setHidden = (name, value) => {\n      const el = document.getElementById('form-field-' + name);\n      if (!el) return;\n      el.value = value;\n    };\n\n    syncHiddenFields = () => {\n      \/\/ Headline. Empty for a preset -- Elementor already emails the chosen\n      \/\/ option, so repeating it here would just be noise -- and empty for the\n      \/\/ custom option with nothing typed, which would otherwise post a bare\n      \/\/ \"I'm here\" that reads like a real answer.\n      let headlineOut = '';\n      if (headlineSelect && isHeadlineCustom()) {\n        const parts = SG.splitCustomHeadline(cleanTyped(headlineCustomInput));\n        if (parts.tail) headlineOut = parts.prefix + ' ' + parts.tail;\n      }\n      setHidden('headline_custom', headlineOut);\n\n      \/\/ Custom reason. getCustomReasonParts is the single source of truth --\n      \/\/ it already returns null when the box is unticked or nothing is typed,\n      \/\/ and re-reading the DOM here would be a second copy of that rule to\n      \/\/ keep in step. tidy() collapses the join, so no emoji yields the bare\n      \/\/ text rather than a leading space.\n      const parts = getCustomReasonParts();\n      setHidden('reason_custom',\n        (parts && parts.text) ? SG.tidy((parts.emoji || '') + ' ' + parts.text) : '');\n\n      \/\/ CTA. composeCta returns the phrase UNCHANGED when it is not a trigger,\n      \/\/ and returns the trigger with its ellipsis intact when nothing is typed;\n      \/\/ both of those are values Elementor already emails from the select, so\n      \/\/ the hidden field only carries a genuinely composed line.\n      let ctaOut = '';\n      if (ctaSelect && SG.isCtaCustomTrigger(ctaSelect.value)) {\n        const typed = SG.tidy(cleanTyped(ctaCustomInput));\n        if (typed) ctaOut = SG.composeCta(ctaSelect.value, typed);\n      }\n      setHidden('cta_custom', ctaOut);\n    };\n\n    \/\/ Once at init: the blocks above each called the no-op stub while painting\n    \/\/ themselves, so nothing has written a hidden value yet -- and a browser\n    \/\/ that restored a custom selection on a back\/forward navigation needs the\n    \/\/ fields to match what the card already shows.\n    syncHiddenFields();\n\n    \/* ---------- Headshot: CSS cover crop (no offscreen canvas) ---------- *\/\n    let uploadedHeadshotSrc = null;\n    const headshotInput = document.getElementById('form-field-headshot');\n\n    let headshotNameEl = null;\n    if (headshotInput) {\n      headshotNameEl = document.createElement('div');\n      headshotNameEl.id = 'sg-headshot-filename';\n      headshotInput.insertAdjacentElement('afterend', headshotNameEl);\n    }\n    const setHeadshotName = (name) => {\n      if (!headshotNameEl) return;\n      if (name) { headshotNameEl.textContent = '\u2713 ' + name; headshotNameEl.style.display = 'block'; }\n      else { headshotNameEl.textContent = ''; headshotNameEl.style.display = 'none'; }\n    };\n    const headshotWidgetContainer = document.querySelector('#imageContainer1 .headshot .elementor-widget-container');\n    const headshotImageElement = headshotWidgetContainer ? headshotWidgetContainer.querySelector('img') : null;\n\n    if (headshotInput && headshotWidgetContainer && headshotImageElement) {\n      const originalSrc = headshotImageElement.src;\n\n      \/\/ Headshot is optional. With no upload the whole circle is hidden and\n      \/\/ .textWrapper reflows to the full 360px, so re-fit every text block.\n      const headshotBox = document.querySelector('#imageContainer1 .headshot');\n      const setHeadshotVisible = (visible) => {\n        if (!headshotBox) return;\n        headshotBox.classList.toggle('sg-hidden', !visible);\n        refitAll();\n      };\n      setHeadshotVisible(false);\n\n      const showLoader = () => {\n        hideLoader();\n        if (getComputedStyle(headshotWidgetContainer).position === 'static') {\n          headshotWidgetContainer.style.position = 'relative';\n        }\n        const loader = document.createElement('div');\n        loader.className = 'headshot-loader';\n        loader.style.cssText = 'position:absolute;box-sizing:border-box;top:50%;left:50%;transform:translate(-50%,-50%);border:4px solid #f3f3f3;border-top:4px solid #3498db;border-radius:50%;width:30px;height:30px;animation:spinHeadshotLoader 1s linear infinite;z-index:10;';\n        if (!document.getElementById('headshotLoaderKeyframes')) {\n          const s = document.createElement('style');\n          s.id = 'headshotLoaderKeyframes';\n          s.innerText = '@keyframes spinHeadshotLoader{0%{transform:translate(-50%,-50%) rotate(0)}100%{transform:translate(-50%,-50%) rotate(360deg)}}';\n          document.head.appendChild(s);\n        }\n        headshotWidgetContainer.appendChild(loader);\n      };\n      const hideLoader = () => {\n        const l = headshotWidgetContainer.querySelector('.headshot-loader');\n        if (l) l.remove();\n      };\n      const resetHeadshot = () => {\n        headshotImageElement.src = originalSrc || '';\n        headshotInput.value = '';\n        uploadedHeadshotSrc = null;\n        setHeadshotName('');\n        hideLoader();\n        setHeadshotVisible(false);\n      };\n\n      headshotInput.addEventListener('change', function (event) {\n        const file = event.target.files[0];\n        if (!file) {\n          if (!uploadedHeadshotSrc) resetHeadshot();\n          return;\n        }\n        if (file.type !== 'image\/jpeg' && file.type !== 'image\/png') {\n          alert('Invalid file type. Please select a JPG or PNG image.');\n          resetHeadshot(); return;\n        }\n        showLoader();\n        const reader = new FileReader();\n        reader.onload = (e) => {\n          headshotImageElement.removeAttribute('srcset');\n          headshotImageElement.removeAttribute('sizes');\n          headshotImageElement.onload = () => hideLoader();\n          headshotImageElement.onerror = () => {\n            hideLoader();\n            alert('Could not load the image. Please try a different file.');\n            resetHeadshot();\n          };\n          uploadedHeadshotSrc = e.target.result;\n          headshotImageElement.src = e.target.result;\n          setHeadshotName(file.name);\n          setHeadshotVisible(true);\n          headshotInput.removeAttribute('required');\n          headshotInput.removeAttribute('aria-required');\n          \/* Release the file from the input now that it has been read.\n             The photo lives in uploadedHeadshotSrc as a data URI and is never\n             needed server-side \u2014 the graphic is composed entirely in the\n             browser. Leaving it on the input meant the surrounding Elementor\n             form POSTed it on submit, so a phone photo (routinely 3-8MB)\n             collided with PHP's upload_max_filesize (2MB on this host) and\n             took the lead-capture submission down with it. Clearing the value\n             does not fire another change event, and the filename indicator\n             above is what tells the user their photo is still attached. *\/\n          headshotInput.value = '';\n        };\n        reader.onerror = () => {\n          hideLoader();\n          alert('Error reading the selected file.');\n          resetHeadshot();\n        };\n        reader.readAsDataURL(file);\n      });\n    } else {\n      console.error('--- Headshot Script Initial Setup FAILED ---');\n    }\n\n    \/* ---------- Colour-scheme picker (injected circles) ---------- *\/\n    const schemeMount = document.getElementById('color-scheme');\n    const schemeContainer = document.getElementById('imageContainer1');\n    if (schemeMount && schemeContainer) {\n      \/\/ Swatch colours \u2014 must match the --bg values in the CSS widget, in order.\n      \/\/ 2026 palette, in picker order: Pink \/ Orange \/ Purple \/ White.\n      \/\/ A white swatch on a white form background needs a visible edge; see\n      \/\/ .scheme-swatch in the CSS widget.\n      const SCHEMES = ['#EE0978', '#FF6A00', '#43285C', '#FFFFFF'];\n      const applyScheme = (n) => {\n        for (let i = 1; i <= SCHEMES.length; i++) schemeContainer.classList.remove('scheme-' + i);\n        schemeContainer.classList.add('scheme-' + n);\n        schemeMount.querySelectorAll('.scheme-swatch').forEach((b, i) =>\n          b.classList.toggle('active', (i + 1) === n));\n      };\n      SCHEMES.forEach((bg, i) => {\n        const btn = document.createElement('span');\n        btn.className = 'scheme-swatch';\n        btn.style.background = bg;\n        btn.setAttribute('role', 'button');\n        btn.setAttribute('tabindex', '0');\n        btn.setAttribute('aria-label', 'Colour scheme ' + (i + 1));\n        btn.addEventListener('click', () => applyScheme(i + 1));\n        btn.addEventListener('keydown', (e) => {\n          if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); applyScheme(i + 1); }\n        });\n        schemeMount.appendChild(btn);\n      });\n      applyScheme(1);\n    } else {\n      console.warn(\"Colour scheme mount '#color-scheme' or container not found \u2014 skipping picker.\");\n    }\n\n    \/* ---------- Post-submit popup: prewritten social copy ----------\n       The popup itself is an Elementor popup, triggered by the form submission.\n       This block only fills in the text and wires the copy button.\n\n       Two element IDs are the contract, set in Elementor's Advanced tab:\n         #sg-social-copy  - the text widget the copy is written into\n         #sg-copy-btn     - the button that copies it\n\n       TIMING: an Elementor popup is not in the DOM until it opens, so neither\n       ID exists at page load. Nothing here may assume they do. *\/\n    const SOCIAL_COPY_TARGET = 'sg-social-copy';\n    \/* Set the CSS ID sg-linkedin-btn on a button in the popup to enable this. *\/\n    const SOCIAL_LINKEDIN_BUTTON = 'sg-linkedin-btn';\n\n    \/* ONE SWITCH for every route: the popup preview, the clipboard and the\n       LinkedIn composer all use it, so what the user reads is exactly what they\n       get. An earlier version applied it to LinkedIn only, which left the popup\n       previewing a link the LinkedIn button then stripped -- and the Copy button\n       handing someone a URL that would trigger the very preview card we were\n       avoiding, if they pasted it into LinkedIn.\n\n       true  -> the event URL is included. On LinkedIn that produces a preview\n                card, which occupies the media slot: the user must dismiss it\n                before the photo control appears.\n       false -> no URL. No preview card, photo control available immediately.\n                The hashtag still carries discovery.\n\n       To go back to a split (link in the clipboard, none on LinkedIn) pass\n       { includeLink: true } at the clipboard call sites only. *\/\n    const POST_TEXT_INCLUDES_LINK = false;   \/\/ Marketing's decision, Sept 2026\n    const SOCIAL_COPY_BUTTON = 'sg-copy-btn';\n\n    \/* The copy tracks the CURRENT headline, not the one selected when the form\n       was submitted. That matters because the form only submits ONCE per page\n       load -- afterwards `leadCaptured` short-circuits it -- so a user who\n       changes their headline and downloads again gets a new graphic with no new\n       submission. Reading the live value at fill time keeps the two in step. *\/\n    \/* Defaults to the shared switch, so every caller agrees unless it explicitly\n       asks for something else. *\/\n    const currentSocialCopy = (opts) => {\n      const settings = Object.assign({ includeLink: POST_TEXT_INCLUDES_LINK }, opts || {});\n      const sel = document.getElementById('form-field-headline');\n      if (!sel) return SG.socialCopyFor('', settings);\n      \/\/ The last option is \"Write your own\u2026\"; its own text is the user's, not a\n      \/\/ key in the map, so let socialCopyFor fall through to the default.\n      return SG.socialCopyFor(isHeadlineCustom() ? '' : sel.value, settings);\n    };\n\n    const fillSocialCopy = () => {\n      const targets = document.querySelectorAll('#' + SOCIAL_COPY_TARGET);\n      if (!targets.length) return false;\n      const target = targets[targets.length - 1];   \/\/ the visible clone is last\n      \/* textContent, not innerHTML: this is plain text destined for a clipboard\n         and a social box, and the copy is authored here rather than by a user --\n         but writing it as text keeps it that way even if someone later edits a\n         variant to include a stray angle bracket. *\/\n      \/* The CSS ID sits on the Elementor WIDGET WRAPPER, not on the paragraph\n         Elementor renders inside it. Writing textContent on the wrapper deletes\n         .elementor-widget-container and the <p> with it -- the text still shows,\n         so it looks fine, but the widget's own typography is gone and any later\n         Elementor re-render fights us. Write into the innermost text element\n         when there is one. *\/\n      const sink = target.querySelector('.elementor-widget-container p')\n        || target.querySelector('.elementor-widget-container')\n        || target;\n      const next = currentSocialCopy();\n      \/* The equality guard is LOAD-BEARING, not an optimisation. The observer\n         below watches childList on the whole body, and assigning textContent\n         replaces this element's child nodes -- which is a childList mutation.\n         Writing unconditionally therefore retriggers the observer, which writes\n         again, forever: the tab locks up hard. Comparing first means the second\n         pass mutates nothing and the loop terminates after one iteration. *\/\n      if (sink.textContent !== next) sink.textContent = next;\n      return true;\n    };\n\n    \/* Last-resort copy: a hidden textarea plus execCommand. Deprecated, but it\n       is the only route that works when the async clipboard API is unavailable\n       OR refuses. *\/\n    const legacyCopy = (text) => new Promise((resolve, reject) => {\n        const ta = document.createElement('textarea');\n        ta.value = text;\n        ta.setAttribute('readonly', '');\n        \/\/ Off-screen rather than display:none \u2014 a hidden element cannot be selected.\n        ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0;';\n        document.body.appendChild(ta);\n        ta.focus();     \/\/ execCommand copies the FOCUSED selection, not any selection\n        ta.select();\n        let ok = false;\n        try { ok = document.execCommand('copy'); } catch (e) { ok = false; }\n        document.body.removeChild(ta);\n        ok ? resolve() : reject(new Error('copy command rejected'));\n      });\n\n    const copyToClipboard = (text) => {\n      \/* Two independent reasons the modern API fails, and both need the legacy\n         path:\n           - it does not exist at all outside a secure context (plain-http\n             staging, some admin previews);\n           - it EXISTS and rejects -- most commonly NotAllowedError \"Document is\n             not focused\", which an Elementor popup can trigger by moving focus\n             as it opens.\n         The original version only handled the first, so a rejection went\n         straight to the error message and nothing was ever copied. Chain them:\n         try the good one, fall back on any rejection. *\/\n      if (navigator.clipboard && window.isSecureContext) {\n        return navigator.clipboard.writeText(text).catch((err) => {\n          console.warn('[social-generator] clipboard API refused, trying fallback:', err && err.name);\n          return legacyCopy(text);\n        });\n      }\n      return legacyCopy(text);\n    };\n\n    \/* ---- The copy control ----\n       We build our own button rather than relying on the Elementor widget.\n       Three rounds of debugging on the live page established that the widget's\n       handler was attached (dataset.sgWired === '1') to an element that did\n       contain an anchor, yet a real click produced nothing at all -- not even\n       the failure branch. Rather than keep guessing at what consumes that\n       event, this owns the whole control: created here, wired here, impossible\n       for another script's button behaviour to interfere with.\n\n       The Elementor button, if still present, is ALSO wired -- harmless, and it\n       keeps working if it turns out to be fine on some other page. *\/\n    \/* ---- The copy control ----\n       ONE delegated listener on document, in the CAPTURE phase. Not a listener\n       per button, which is what the previous three attempts did and why they\n       all failed on the live page:\n\n       Elementor clones popup content into the modal when it opens, and\n       cloneNode does NOT copy event listeners. Every button we wired was the\n       ORIGINAL node; the one actually on screen was an inert copy of it. That\n       is why the element inspected fine (dataset.sgWired === '1') while a real\n       click produced nothing at all -- two different nodes.\n\n       Delegation is immune: the listener lives on document, so it fires for any\n       matching element no matter how many times the popup is cloned, reopened\n       or rebuilt. Capture phase means nothing downstream can swallow it first. *\/\n    const COPY_SELECTOR = '#sg-copy-btn';\n    const LINKEDIN_SELECTOR = '#' + SOCIAL_LINKEDIN_BUTTON;\n    let copyBusy = false;\n\n    document.addEventListener('click', (e) => {\n      const control = e.target && e.target.closest && e.target.closest(COPY_SELECTOR);\n      if (!control) return;\n      e.preventDefault();\n      e.stopPropagation();\n      if (copyBusy) return;\n      copyBusy = true;\n      setTimeout(() => { copyBusy = false; }, 50);\n\n      \/\/ Label lives on whichever node was actually clicked, not on a remembered one.\n      const labelEl = control.querySelector('.elementor-button-text')\n        || control.querySelector('a, button')\n        || control;\n      const original = labelEl.textContent;\n      const flash = (msg, ms) => {\n        labelEl.textContent = msg;\n        setTimeout(() => { labelEl.textContent = original; }, ms);\n      };\n\n      try { window.focus(); } catch (err) { \/* framed contexts may refuse *\/ }\n      copyToClipboard(currentSocialCopy())\n        .then(() => flash('Copied', 2000))\n        .catch((err) => {\n          console.warn('[social-generator] clipboard write failed:', err);\n          flash('Press Ctrl\/Cmd+C', 3000);\n        });\n    }, true);\n\n    \/* ---- Share to LinkedIn ----\n       Opens LinkedIn's composer with the post text prefilled. The GRAPHIC cannot\n       be attached from here: LinkedIn only accepts images through its own\n       composer UI or an authenticated API, never from a URL parameter, and our\n       image never leaves the browser so it has no URL to share. The user\n       attaches the downloaded PNG themselves.\n\n       The clipboard is written FIRST, with the full link-bearing copy, so that\n       if LinkedIn ignores the parameter -- it is undocumented and can change\n       without notice -- the user still has the text ready to paste rather than\n       an empty composer and nothing to put in it.\n\n       Delegated and in capture phase for the same reason as the copy button:\n       Elementor clones popup content, and a listener bound to the original node\n       does not survive the clone. *\/\n    const LINKEDIN_COMPOSER = 'https:\/\/www.linkedin.com\/feed\/?shareActive=true&text=';\n\n    document.addEventListener('click', (e) => {\n      const control = e.target && e.target.closest && e.target.closest(LINKEDIN_SELECTOR);\n      if (!control) return;\n      e.preventDefault();\n      e.stopPropagation();\n\n      const text = currentSocialCopy();\n      \/* Copy as well as open. If LinkedIn ever ignores the undocumented text\n         parameter, the user lands in an empty composer with the text already on\n         the clipboard rather than with nothing. *\/\n      copyToClipboard(text).catch(() => { \/* non-fatal *\/ });\n\n      const url = LINKEDIN_COMPOSER + encodeURIComponent(text);\n      \/* noopener: the opened tab must not get a handle on this window. Without\n         it, window.opener is live and the destination can navigate us. *\/\n      const win = window.open(url, '_blank', 'noopener');\n      if (!win) {\n        console.warn('[social-generator] LinkedIn window was blocked; the text is on the clipboard.');\n      }\n    }, true);\n\n    \/* Nothing to attach: the delegated listener above already covers every copy\n       button, including clones Elementor makes when the popup reopens. This\n       only makes the control read as clickable, and it runs over ALL matching\n       nodes so a freshly cloned popup is covered too. *\/\n    const decorateCopyButton = () => {\n      [SOCIAL_COPY_BUTTON, SOCIAL_LINKEDIN_BUTTON].forEach((id) => {\n        document.querySelectorAll('#' + id).forEach((el) => { el.style.cursor = 'pointer'; });\n      });\n    };\n\n    \/* Elementor identifies a popup by the post ID of the popup template. We do\n       not hardcode it: the first time the popup appears we read it off the modal\n       element, so this keeps working if the popup is rebuilt or swapped. *\/\n    let socialPopupId = null;\n    const rememberPopupId = () => {\n      if (socialPopupId) return;\n      const target = document.getElementById(SOCIAL_COPY_TARGET);\n      const modal = target && target.closest('.elementor-popup-modal');\n      if (!modal) return;\n      const fromData = modal.querySelector('[data-elementor-id]');\n      const m = \/elementor-popup-modal-(\\d+)\/.exec(modal.id || '');\n      socialPopupId = (fromData && fromData.getAttribute('data-elementor-id'))\n        || (m && m[1]) || null;\n    };\n\n    \/* Re-open the popup for downloads after the first. The form submits only\n       ONCE per page load, so Elementor never sees a second submission and its\n       own popup trigger never fires again. *\/\n    const showSocialPopup = () => {\n      if (!socialPopupId) return false;\n      const api = window.elementorProFrontend\n        && window.elementorProFrontend.modules\n        && window.elementorProFrontend.modules.popup;\n      if (!api || typeof api.showPopup !== 'function') return false;\n      try { api.showPopup({ id: socialPopupId }); return true; }\n      catch (e) { console.warn('[social-generator] could not reopen the popup', e); return false; }\n    };\n\n    const refreshSocialPopup = () => {\n      if (!fillSocialCopy()) return;\n      rememberPopupId();\n      decorateCopyButton();\n    };\n\n    \/\/ Elementor announces popups through jQuery. Preferred route: it fires once,\n    \/\/ after the popup content is in the DOM.\n    if (window.jQuery) {\n      window.jQuery(document).on('elementor\/popup\/show', refreshSocialPopup);\n    }\n    \/* Fallback for when that event does not fire -- a different popup mechanism,\n       jQuery loaded after this script, or the widgets being present inline on the\n       page rather than in a popup. Watching subtree additions is cheap here\n       because the callback bails immediately when the target is absent. *\/\n    new MutationObserver(refreshSocialPopup)\n      .observe(document.body, { childList: true, subtree: true });\n    \/\/ And in case the popup is already open (or inline) when this runs.\n    refreshSocialPopup();\n\n    \/* ---------- Submit: lead capture (first time) + snapdom image (every time) ---------- *\/\n    let leadCaptured = false;\n    const successObserver = new MutationObserver(() => {\n      if (form.querySelector('.elementor-message-success')) leadCaptured = true;\n    });\n    successObserver.observe(form, { childList: true, subtree: true });\n\n    const submitButton = form.querySelector('button[type=\"submit\"], input[type=\"submit\"]');\n    const setSubmitLoading = (loading) => {\n      if (!submitButton) return;\n      if (loading) {\n        submitButton.classList.add('sg-btn-loading');\n        if (!submitButton.querySelector('.sg-btn-spinner')) {\n          const sp = document.createElement('span');\n          sp.className = 'sg-btn-spinner';\n          submitButton.appendChild(sp);\n        }\n      } else {\n        submitButton.classList.remove('sg-btn-loading');\n        const sp = submitButton.querySelector('.sg-btn-spinner');\n        if (sp) sp.remove();\n      }\n    };\n\n    const generateImage = () => {\n      const container = document.getElementById('imageContainer1');\n      if (!container) { console.error(\"Container 'imageContainer1' not found.\"); return; }\n\n      const ownSpinner = leadCaptured;\n      if (ownSpinner) setSubmitLoading(true);\n\n      \/\/ Export size. Social formats are exact, so these are stated rather than\n      \/\/ derived \u2014 a stray layout pixel must never produce a 1349px-tall image.\n      \/\/ But the CARD's shape comes from `aspect-ratio` in #imageContainer1's\n      \/\/ Elementor custom CSS, so the two can drift apart. If they do, the card\n      \/\/ is squashed or stretched into the target and nothing warns you, so:\n      \/\/ change the ratio in BOTH places, and this check will tell you if not.\n      const TARGET_W = 1080, TARGET_H = 1350;   \/\/ 4:5\n      const cardW = container.offsetWidth || 600;\n      const cardH = container.offsetHeight || 0;\n      if (cardH) {\n        const cardRatio = cardW \/ cardH;\n        const targetRatio = TARGET_W \/ TARGET_H;\n        if (Math.abs(cardRatio - targetRatio) \/ targetRatio > 0.01) {\n          console.warn(\n            '[social-generator] Card aspect ratio ' + cardRatio.toFixed(4) +\n            ' does not match the export ratio ' + targetRatio.toFixed(4) +\n            ' (' + TARGET_W + 'x' + TARGET_H + '). The image will be stretched. ' +\n            'Update `aspect-ratio` in #imageContainer1\\'s Elementor custom CSS, ' +\n            'or TARGET_W\/TARGET_H here, so the two agree.');\n        }\n      }\n      const scale = TARGET_W \/ cardW;\n      \/* Named after the user so a downloads folder of these is navigable.\n         Falls back to the bare event name when the field is empty. *\/\n      const nameSlug = SG.fileSlug((document.getElementById('form-field-firstname') || {}).value);\n      const fileName = nameSlug ? 'pi-live-2026-' + nameSlug + '.png' : 'pi-live-2026.png';\n\n      \/* ---------- Capture options, tried in order ----------\n         embedFonts: Oswald is served from fonts.googleapis.com, and a\n           cross-origin stylesheet's cssRules are CORS-blocked, so snapdom cannot\n           read the @font-face rule. Without this the export silently falls back\n           to sans-serif \u2014 the preview looks right and only the PNG is wrong.\n         reconcile: snapdom warns that text in inline elements \"kept its natural\n           width and may re-wrap under font-fallback rasterization\". That re-wrap\n           is what pushed each reason pill onto a second line in the download\n           while its border stayed at one-line height. reconcile gives\n           pixel-exact layout, so it fixes that symptom even when the font\n           embedding does not succeed.\n\n         Why a LIST rather than one call: embedFonts has been seen to throw from\n         inside snapdom (\"$e is not a function\") on some pages, and a failed\n         download is far worse than an imperfect one. Each entry is tried in\n         turn and the first that succeeds wins, so the export degrades in\n         quality rather than failing outright. The console says which was used \u2014\n         anything below the first is worth reporting. *\/\n      const CAPTURE_ATTEMPTS = [\n        { label: 'embedFonts+reconcile', opts: { embedFonts: true, reconcile: true } },\n        { label: 'reconcile only (font will fall back)', opts: { reconcile: true } },\n        { label: 'bare (font and layout may both differ)', opts: {} }\n      ];\n\n      \/* snapdom's minified bundle declares its internal helpers as GLOBALS on\n         window \u2014 `typeof window.$e === \"function\"` straight after it loads.\n         Any other script on the page that defines a global of the same name\n         silently clobbers one, and embedFonts then dies with\n         \"TypeError: $e is not a function\". Confirmed by experiment: setting\n         window.$e to a string reproduces it exactly, and re-running the bundle\n         repairs it. Elementor's editor preview, GTM and Hotjar all load extra\n         scripts that the published page does not, which is why this showed up\n         in preview only.\n\n         Re-injecting the script re-declares those globals. The URL is read from\n         the page so it follows whatever version the snapdom widget pins. *\/\n      const reloadSnapdom = () => new Promise((resolve, reject) => {\n        const tag = document.querySelector('script[src*=\"snapdom\"]');\n        const src = tag && tag.src;\n        if (!src) { reject(new Error('snapdom script tag not found')); return; }\n        const el = document.createElement('script');\n        el.src = src;\n        el.onload = () => { el.remove(); resolve(); };\n        el.onerror = () => { el.remove(); reject(new Error('could not reload snapdom')); };\n        document.head.appendChild(el);\n      });\n\n      const captureWithFallback = async () => {\n        let repaired = false;\n        let lastErr;\n        for (let i = 0; i < CAPTURE_ATTEMPTS.length; i++) {\n          const attempt = CAPTURE_ATTEMPTS[i];\n          const opts = Object.assign({ scale: scale, backgroundColor: null }, attempt.opts);\n          for (let pass = 0; pass < 2; pass++) {\n            try {\n              const cv = await snapdom.toCanvas(container, opts);\n              if (i > 0) {\n                console.warn('[social-generator] capture fell back to \"' + attempt.label +\n                  '\" after \"' + CAPTURE_ATTEMPTS[i - 1].label + '\" failed:', lastErr);\n              }\n              return cv;\n            } catch (e) {\n              lastErr = e;\n              \/\/ One repair attempt, before giving up on the best options and\n              \/\/ degrading to a worse capture.\n              if (pass === 0 && !repaired) {\n                repaired = true;\n                try {\n                  await reloadSnapdom();\n                  console.warn('[social-generator] snapdom globals were clobbered by ' +\n                    'another script; reloaded it and retrying.', e);\n                  continue;   \/\/ retry this same attempt\n                } catch (reloadErr) { \/* fall through to the next attempt *\/ }\n              }\n              break;          \/\/ this attempt is done; try the next set of options\n            }\n          }\n        }\n        throw lastErr;\n      };\n\n      captureWithFallback()\n        .then(rawCanvas => {\n          const out = document.createElement('canvas');\n          out.width = TARGET_W;\n          out.height = TARGET_H;\n          const ctx = out.getContext('2d');\n          ctx.drawImage(rawCanvas, 0, 0, rawCanvas.width, rawCanvas.height, 0, 0, TARGET_W, TARGET_H);\n\n          return new Promise(resolve => {\n            out.toBlob(async (blob) => {\n              if (!blob) { resolve(); return; }\n              const file = new File([blob], fileName, { type: 'image\/png' });\n              const canShareFile = navigator.canShare && navigator.canShare({ files: [file] });\n              const isTouch = (navigator.maxTouchPoints || 0) > 0;\n              if (isTouch && canShareFile) {\n                try { await navigator.share({ files: [file], title: fileName }); resolve(); return; }\n                catch (e) { \/* cancelled or blocked \u2192 fall through to download *\/ }\n              }\n              const url = URL.createObjectURL(blob);\n              const a = document.createElement('a');\n              a.href = url;\n              a.download = fileName;\n              \/* Stop the synthetic click bubbling to document.\n                 An Elementor popup closes on any click outside itself, and this\n                 anchor is appended to <body> -- so triggering the download was\n                 dismissing the post-submit popup the instant it appeared. The\n                 anchor stays in the DOM rather than being clicked detached:\n                 older Firefox will not action `download` on an element that is\n                 not in the document, and a silently missing download is a worse\n                 bug than a popup that closes. *\/\n              a.addEventListener('click', (ev) => ev.stopPropagation());\n              document.body.appendChild(a);\n              a.click();\n              a.remove();\n              setTimeout(() => URL.revokeObjectURL(url), 10000);\n              resolve();\n            }, 'image\/png');\n          });\n        })\n        .catch(err => console.error('Error generating image with snapdom:', err))\n        .finally(() => { if (ownSpinner) setSubmitLoading(false); });\n    };\n\n    form.addEventListener('submit', function (event) {\n      \/\/ Blocked words stop the download outright. Re-check here rather than\n      \/\/ trusting the button's disabled state: the value could have been set by\n      \/\/ a path that fires no `input` event, and a disabled button can be\n      \/\/ re-enabled from devtools. Capture phase, so this runs before Elementor.\n      if (updateBlockedState()) {\n        event.preventDefault();\n        event.stopImmediatePropagation();\n        const firstBad = injectedTextInputs.filter(\n          (i) => i && (i.offsetParent || i.getClientRects().length) && SG.containsBlocked(i.value))[0];\n        if (firstBad) firstBad.focus();\n        return;\n      }\n\n      \/\/ FIRST statement in the handler, before the leadCaptured branch and\n      \/\/ before anything reads the form: the hidden fields are derived state,\n      \/\/ and any path that changed a source without firing `input` (a\n      \/\/ programmatic set, a browser autofill, a restore tick) would otherwise\n      \/\/ let Elementor post a stale value. Re-deriving costs nothing and makes\n      \/\/ the posted values true by construction rather than by listener luck.\n      \/\/ It does NOT depend on the fieldSnapshot loop below -- that loop runs\n      \/\/ AFTER the lead is posted, and deliberately skips hidden inputs.\n      syncHiddenFields();\n\n      if (leadCaptured) {\n        event.preventDefault();\n        event.stopImmediatePropagation();\n        \/* Elementor will not fire its own popup trigger for this click, because\n           it never sees a submission. Re-open it ourselves, and refresh the copy\n           first so it matches the headline as it stands NOW rather than at the\n           original submit. Deferred a tick so it lands after generateImage()\n           below has kicked off, keeping the ordering the same as the first\n           download. *\/\n        setTimeout(() => { refreshSocialPopup(); showSocialPopup(); }, 0);\n      } else {\n        const fieldSnapshot = [];\n        form.querySelectorAll('input, select, textarea').forEach(el => {\n          if (['file', 'submit', 'button', 'hidden'].includes(el.type)) return;\n          fieldSnapshot.push({ el, value: el.value });\n        });\n        const headshotImg = document.querySelector('#imageContainer1 .headshot .elementor-widget-container img');\n        const restoreStart = Date.now();\n        const restoreTimer = setInterval(() => {\n          fieldSnapshot.forEach(({ el, value }) => {\n            if (value && el.value !== value) el.value = value;\n          });\n          if (uploadedHeadshotSrc && headshotImg && headshotImg.src !== uploadedHeadshotSrc) {\n            headshotImg.src = uploadedHeadshotSrc;\n          }\n          if (Date.now() - restoreStart > 3000) {\n            clearInterval(restoreTimer);\n            \/\/ The restore above is best-effort: Elementor's reset may land after\n            \/\/ the last tick, or a field may never come back. Whatever the DOM\n            \/\/ ended up as, reconcile the injected fields with it and repaint.\n            \/\/ Without this a timed-out restore can leave the select back on\n            \/\/ option 0 while the custom input is still visible -- and the next\n            \/\/ keystroke then renders the PRESET headline onto the card.\n            resyncInjectedFields();\n          }\n        }, 200);\n      }\n\n      const pageTitle = document.getElementsByTagName('h1')[0]?.textContent?.trim() || 'Social Graphic Generated';\n      if (typeof gtag !== 'undefined') {\n        gtag('event', pageTitle, { 'event_category': 'User Interaction', 'event_label': eventName });\n      }\n\n      generateImage();\n    }, true);\n\n    \/* Initial text fit *\/\n    refitAll();\n  });\n\n  \/* ---------- Text-fit sizing constants (em) ----------\n     The maxima are the sizes the OLD, broken fitter actually produced: it\n     measured a block <p>'s scrollWidth, which always equals the container's own\n     width, so every card -- two characters or forty -- was driven to its floor.\n     They are pinned here so everything that renders acceptably today renders\n     pixel-identically after the fix, and the only visible change is that\n     genuinely-too-long text now shrinks instead of overflowing. 1.7em is not\n     inherently correct; raise these once the fitter is seen working.\n\n     The floors are deliberately low. They only ever engage on input that is\n     already broken today -- with the pre-fix fitter such a name simply\n     overflowed the card -- and small-but-contained is a strictly better failure\n     mode for a graphic someone downloads and posts. Normal names never come\n     near the floor; they sit at the maximum. The pathological case is a long\n     single unbroken token, which .firstname cannot split because there is no\n     space to split on: that will render small, and the real remedy is a\n     maxlength on the Elementor name field, not a higher floor here. *\/\n  \/\/ cta: 1.9em is not a tuned figure -- it is exactly what `.cta` renders at\n  \/\/ today. The CTA was never fitted at all, so it has always sat at the 1.9em\n  \/\/ Elementor's own widget typography gives it. Pinning the maximum there\n  \/\/ means every CTA that fits today keeps rendering pixel-identically, and\n  \/\/ the only new behaviour is that a long composed phrase now shrinks instead\n  \/\/ of overflowing. Change it only from a decision about the design, not to\n  \/\/ make one long string look better.\n  const FIT_MAX_EM = { firstname: 1.7, lastname: 1.5, headline: 4, cta: 1.9 };\n  const DEFAULT_MAX_EM = 1.5;\n  \/\/ cta floor is 0.65, not 0.8: a 20-char ALL-CAPS value overflows at 0.8 and the\n  \/\/ maxlength is already 20, so there is no shorter input to fall back on.\n  \/\/ The headline floor is 0.6, not 0.9: the headline is REQUIRED to sit on two\n  \/\/ lines, so the fitter needs room to shrink a long second line rather than let\n  \/\/ it wrap onto a third. Long option copy therefore renders small \u2014 keep the\n  \/\/ text after \"I'm here to\/for\" short if you want big type.\n  const FIT_MIN_EM = { firstname: 0.6, lastname: 0.6, headline: 2, cta: 0.65 };\n  const DEFAULT_MIN_EM = 0.8;\n\n  \/* Optional cap on how much of the CARD's width a field may occupy, as a\n     fraction. Needed because the fitter measures against the widget, but what\n     actually constrains the CTA is the pink band in the card artwork \u2014 and the\n     code cannot see that. Measured: \"Say hello at The Partners Arm's\" rendered\n     470.9px wide inside a 600px card, so it \"fitted\" and never shrank, while\n     visually filling the band edge to edge.\n\n     0.72 -> the CTA never exceeds 432px of the 600px card, leaving margin each\n     side. Raise it for a wider band, lower it for more breathing room. Fields\n     not listed here are unconstrained. *\/\n  const FIT_MAX_PCT = { cta: 0.72 };\n\n  \/\/ A block-level <p> fills its container, so scrollWidth can never report the\n  \/\/ text's own extent. A Range over its contents can. .firstname deliberately\n  \/\/ splits on the first space with a <br>, so it legitimately yields two rects\n  \/\/ and the widest is the one that has to fit.\n  const measureTextWidth = (el) => {\n    const range = document.createRange();\n    range.selectNodeContents(el);\n    const rects = range.getClientRects();\n    let w = 0;\n    for (let i = 0; i < rects.length; i++) w = Math.max(w, rects[i].width);\n    return w;\n  };\n\n\n  \/* ---------- Shared: shrink text to fit its container ----------\n     mode 'width' (default): force one line, shrink until it fits the container width.\n     mode 'lines': the content is pre-broken into .sg-hl-line spans separated by\n       <br>, so the line COUNT is fixed by construction; shrink until the widest\n       of those lines fits the container width. *\/\n  const scaleTextToFit = (containerSelector, maxEm, minEm, opts) => {\n    const o = opts || {};\n    const mode = o.mode || 'width';\n    const maxLines = o.maxLines || 2;\n    document.querySelectorAll(containerSelector).forEach(container => {\n      const textElement = cardTextEl(container);\n      if (!textElement) return;\n      const min = (minEm != null ? minEm : DEFAULT_MIN_EM);\n      let fontSize = (maxEm != null ? maxEm : DEFAULT_MAX_EM);\n\n      \/\/ Hoisted above the mode branch so BOTH paths restore it. 'lines' mode\n      \/\/ returns early; without this it would leave white-space stuck at\n      \/\/ 'normal' and permanently clobber the nowrap that setFirstnameLines\n      \/\/ reapplies on every keystroke.\n      const originalWhiteSpace = textElement.style.whiteSpace;\n      \/\/ Shared by both modes: the production widget container carries\n      \/\/ padding: 0 10px, which clientWidth includes.\n      const buffer = 20;\n\n      \/\/ A widget with flex-basis:auto and no width SHRINK-WRAPS to its own\n      \/\/ content. Measuring the available width from such a box is circular:\n      \/\/ shrinking the text shrinks the box, so the target recedes in lockstep\n      \/\/ and the loop settles while the text still overflows. Measured on the\n      \/\/ real card at 3.8em -> widget 350 \/ text 355; at 3.3em -> widget 304 \/\n      \/\/ text 308. Force the box to fill its parent for the duration of the\n      \/\/ measurement so the constraint is the PARENT's width, which does not\n      \/\/ move, then restore. The widget then shrink-wraps to the fitted text,\n      \/\/ which now fits. No paint happens between these, so there is no flicker.\n      const originalInlineWidth = container.style.width;\n      container.style.width = '100%';\n      let avail = container.clientWidth - buffer;\n\n      \/\/ Clamp to the part of the container that is actually INSIDE the card.\n      \/\/ A widget can be positioned so some of it hangs off the edge -- the CTA\n      \/\/ is absolutely positioned at left:-30px on a 600px card -- and anything\n      \/\/ outside #imageContainer1 is cropped out of the export. Fitting to the\n      \/\/ widget's nominal width would then happily size text into the part that\n      \/\/ gets cut off. Only ever REDUCES avail, so a container that sits wholly\n      \/\/ inside the card (the headline's, in its rotated row) is unaffected.\n      const cardBox = container.closest('#imageContainer1');\n      if (cardBox && cardBox !== container) {\n        const cb = cardBox.getBoundingClientRect();\n        const wb = container.getBoundingClientRect();\n        const cardScale = cardBox.offsetWidth ? (cb.width \/ cardBox.offsetWidth) : 0;\n        if (cardScale > 0) {\n          const visible = (Math.min(wb.right, cb.right) - Math.max(wb.left, cb.left)) \/ cardScale;\n          if (visible > 0) avail = Math.min(avail, visible - buffer);\n\n          \/\/ Cap as a fraction of the CARD, for fields whose real constraint is\n          \/\/ artwork the code cannot measure. See FIT_MAX_PCT.\n          const pctKey = Object.keys(FIT_MAX_PCT).filter(\n            (k) => containerSelector.indexOf('.' + k) !== -1)[0];\n          if (pctKey) {\n            const capped = cardBox.clientWidth * FIT_MAX_PCT[pctKey];\n            if (capped > 0) avail = Math.min(avail, capped);\n          }\n\n          \/\/ Centred text is symmetric about the container's centre, so if that\n          \/\/ centre is not the card's centre the usable width is twice the\n          \/\/ SHORTER side, not the visible width. The CTA's centre sits at card\n          \/\/ x=270 on a 600px card, so centred text wider than 540 spills off the\n          \/\/ left however much of the widget is technically visible.\n          if (getComputedStyle(textElement).textAlign === 'center') {\n            const mid = ((wb.left + wb.right) \/ 2 - cb.left) \/ cardScale;\n            const symmetric = 2 * Math.min(mid, cardBox.clientWidth - mid);\n            if (symmetric > 0) avail = Math.min(avail, symmetric - buffer);\n          }\n        }\n      }\n      const restoreWidth = () => { container.style.width = originalInlineWidth; };\n\n      if (mode === 'lines') {\n        \/\/ Fit by WIDTH, not by counting line boxes.\n        \/\/\n        \/\/ Counting was tried twice and abandoned. Distinct rect tops failed the\n        \/\/ moment .sg-headline-accent was styled: a larger inline sits higher in\n        \/\/ the line box, so one line read as two. Grouping by vertical overlap\n        \/\/ then failed on the real card, where the heading's client rects run up\n        \/\/ to 20px TALLER than its line advance (95.1px vs 74.8px) \u2014 consecutive\n        \/\/ lines genuinely overlap, so the start of line 2 was absorbed into\n        \/\/ line 1 and the fitter never shrank.\n        \/\/\n        \/\/ renderHeadline emits one .sg-hl-line span per line with a hard <br>\n        \/\/ between, and the CSS keeps each span nowrap. The line count is\n        \/\/ therefore structural, and fitting reduces to the same width test\n        \/\/ width mode uses \u2014 the only measurement that has held up here.\n        textElement.style.whiteSpace = 'normal';   \/\/ let the <br> do its job\n        textElement.style.fontSize = `${fontSize}em`;\n        const parts = textElement.querySelectorAll('.sg-hl-line');\n        const targets = parts.length ? parts : [textElement];\n        const cRect = container.getBoundingClientRect();\n        const cScale = container.offsetWidth ? (cRect.width \/ container.offsetWidth) : 0;\n        \/\/ Hidden or zero-sized: measuring is meaningless, so leave the maximum\n        \/\/ rather than shrinking to the floor on a bad reading.\n        if (!(cScale > 0) || !(cRect.width > 0)) {\n          textElement.style.whiteSpace = originalWhiteSpace;\n          restoreWidth();\n          return;\n        }\n        \/\/ offsetWidth, NOT measureTextWidth. Two reasons, both load-bearing:\n        \/\/\n        \/\/ 1. measureTextWidth returns the WIDEST SINGLE RECT. A .sg-hl-line is\n        \/\/    built from several inline fragments side by side -- \"the \" plus\n        \/\/    <span>Networking<\/span> -- so the widest rect is just the span and\n        \/\/    the line's true width is missed. Measured on the real card: the\n        \/\/    line ran 102.6 -> 578.6 in card coordinates (476px) while the max\n        \/\/    rect said ~380, so the fitter saw it as fitting and it overflowed\n        \/\/    its 380px row by 78px. (That measure is still correct for width\n        \/\/    mode, where a <br> puts each fragment on its own line.)\n        \/\/ 2. offsetWidth is UNROTATED layout px. This row carries a rotateZ, and\n        \/\/    client rects are axis-aligned boxes of the rotated text, inflated\n        \/\/    by roughly W*cos+H*sin. offsetWidth is directly comparable with\n        \/\/    clientWidth and needs no descaling.\n        const widest = () => {\n          let w = 0;\n          for (let i = 0; i < targets.length; i++) {\n            w = Math.max(w, targets[i].offsetWidth);\n          }\n          return w;\n        };\n        let guard = 0;\n        while (widest() > avail && fontSize > min && guard++ < 200) {\n          fontSize = Math.max(min, Math.round((fontSize - 0.05) * 100) \/ 100);\n          textElement.style.fontSize = `${fontSize}em`;\n        }\n        textElement.style.whiteSpace = originalWhiteSpace;\n        restoreWidth();\n        return;\n      }\n\n      \/\/ Keep nowrap while measuring: sub-pixel rounding under the card's CSS\n      \/\/ transform can otherwise tip a borderline line onto a second row.\n      textElement.style.whiteSpace = 'nowrap';\n      textElement.style.fontSize = `${fontSize}em`;\n      \/\/ getClientRects() reports SCALED pixels -- the card sits inside\n      \/\/ .sg-scale-wrap's transform -- while clientWidth is unscaled layout\n      \/\/ pixels. Convert the measurement back before comparing, or the fit is\n      \/\/ wrong by whatever the preview scale happens to be and drifts with the\n      \/\/ viewport width.\n      const containerRect = container.getBoundingClientRect();\n      const scale = container.offsetWidth ? (containerRect.width \/ container.offsetWidth) : 1;\n      \/\/ A hidden or zero-sized element cannot be measured; leave the text at its\n      \/\/ maximum rather than shrinking it to the floor on a meaningless reading.\n      if (scale > 0 && containerRect.width > 0) {\n        let guard = 0;\n        while ((measureTextWidth(textElement) \/ scale) > avail\n               && fontSize > min && guard++ < 200) {\n          fontSize = Math.max(min, Math.round((fontSize - 0.05) * 100) \/ 100);\n          textElement.style.fontSize = `${fontSize}em`;\n        }\n      }\n      textElement.style.whiteSpace = originalWhiteSpace;\n      restoreWidth();\n    });\n  };\n\n  const refitAll = () => {\n    scaleTextToFit('.firstname', FIT_MAX_EM.firstname, FIT_MIN_EM.firstname);\n    scaleTextToFit('.lastname', FIT_MAX_EM.lastname, FIT_MIN_EM.lastname);\n    scaleTextToFit('#imageContainer1 .headline', FIT_MAX_EM.headline, FIT_MIN_EM.headline,\n                   { mode: 'lines', maxLines: 2 });\n    \/\/ Width mode (the default), NOT lines mode: the CTA is a single line by\n    \/\/ design -- the CSS widget sets white-space: nowrap on it -- so it has to\n    \/\/ shrink to the column, never wrap into a second row.\n    scaleTextToFit('#imageContainer1 .cta', FIT_MAX_EM.cta, FIT_MIN_EM.cta);\n  };\n  window.addEventListener('resize', refitAll);\n  \/\/ A web font swapping in changes every metric the fitter just measured, and\n  \/\/ 'lines' mode is the sensitive one: a headline fitted to exactly 2 lines in\n  \/\/ the fallback face can wrap to 3 in the real one. Re-fit once fonts settle.\n  if (document.fonts) document.fonts.ready.then(refitAll);\n<\/script>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-22b0cdf e-con-full e-transform e-flex e-con e-child\" data-id=\"22b0cdf\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;_transform_translateX_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_translateX_effect_tablet&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_translateX_effect_mobile&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_translateY_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_translateY_effect_tablet&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_translateY_effect_mobile&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]}}\">\n\t\t<div class=\"elementor-element elementor-element-cb548dc e-con-full e-flex e-con e-child\" data-id=\"cb548dc\" data-element_type=\"container\" data-e-type=\"container\" id=\"imageContainer1\" data-settings=\"{&quot;background_background&quot;:&quot;classic&quot;}\">\n\t\t<div class=\"elementor-element elementor-element-a367c24 e-con-full e-transform e-transform e-flex e-con e-child\" data-id=\"a367c24\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;position&quot;:&quot;absolute&quot;,&quot;_transform_rotateZ_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:3,&quot;sizes&quot;:[]},&quot;_transform_skewX_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:-2,&quot;sizes&quot;:[]},&quot;_transform_skewY_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:2.5,&quot;sizes&quot;:[]},&quot;_transform_rotateZ_effect_tablet&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_rotateZ_effect_mobile&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewX_effect_tablet&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewX_effect_mobile&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewY_effect_tablet&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewY_effect_mobile&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]}}\">\n\t\t\t\t<div class=\"elementor-element elementor-element-7b68cc9 elementor-widget elementor-widget-image\" data-id=\"7b68cc9\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"image.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<img fetchpriority=\"high\" decoding=\"async\" width=\"1955\" height=\"386\" src=\"https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/PI-LIVE-Inline-Dark-Backgrounds.svg\" class=\"attachment-large size-large wp-image-27399\" alt=\"\" \/>\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-ae1b28e e-transform elementor-widget elementor-widget-image\" data-id=\"ae1b28e\" data-element_type=\"widget\" data-e-type=\"widget\" data-settings=\"{&quot;_transform_translateX_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:0,&quot;sizes&quot;:[]},&quot;_transform_translateY_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:-3,&quot;sizes&quot;:[]},&quot;_transform_translateX_effect_tablet&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_translateX_effect_mobile&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_translateY_effect_tablet&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_translateY_effect_mobile&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]}}\" data-widget_type=\"image.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<img decoding=\"async\" width=\"355\" height=\"27\" src=\"https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/date-and-location.svg\" class=\"attachment-large size-large wp-image-27400\" alt=\"\" \/>\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-7f7a37d headline elementor-widget elementor-widget-heading\" data-id=\"7f7a37d\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"heading.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<h2 class=\"elementor-heading-title elementor-size-default\">I'm here for...<\/h2>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-61e7b56 e-con-full e-flex e-con e-child\" data-id=\"61e7b56\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t<div class=\"elementor-element elementor-element-bcfbb1f e-con-full headshot e-flex e-con e-child\" data-id=\"bcfbb1f\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;background_background&quot;:&quot;classic&quot;}\">\n\t\t\t\t<div class=\"elementor-element elementor-element-300a7ba elementor-widget elementor-widget-image\" data-id=\"300a7ba\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"image.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<img decoding=\"async\" src=\"https:\/\/performancein.live\/europe\/wp-content\/plugins\/elementor\/assets\/images\/placeholder.png\" title=\"\" alt=\"\" loading=\"lazy\" \/>\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-9aec32f e-con-full textWrapper e-flex e-con e-child\" data-id=\"9aec32f\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;background_background&quot;:&quot;classic&quot;}\">\n\t\t\t\t<div class=\"elementor-element elementor-element-9268216 elementor-widget__width-inherit firstname elementor-widget elementor-widget-heading\" data-id=\"9268216\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"heading.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<p class=\"elementor-heading-title elementor-size-default\"> [My Name]<\/p>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-7238123 elementor-widget__width-inherit lastname elementor-widget elementor-widget-heading\" data-id=\"7238123\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"heading.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<p class=\"elementor-heading-title elementor-size-default\"> [Company or Handle]<\/p>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-151ef9c e-con-full reasons e-transform e-transform e-flex e-con e-child\" data-id=\"151ef9c\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;position&quot;:&quot;absolute&quot;,&quot;_transform_rotateZ_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:3,&quot;sizes&quot;:[]},&quot;_transform_skewX_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:-2,&quot;sizes&quot;:[]},&quot;_transform_skewY_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:2.25,&quot;sizes&quot;:[]},&quot;_transform_rotateZ_effect_tablet&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_rotateZ_effect_mobile&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewX_effect_tablet&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewX_effect_mobile&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewY_effect_tablet&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewY_effect_mobile&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]}}\">\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-bbf5369 elementor-widget__width-inherit cta elementor-absolute e-transform e-transform elementor-widget elementor-widget-heading\" data-id=\"bbf5369\" data-element_type=\"widget\" data-e-type=\"widget\" data-settings=\"{&quot;_position&quot;:&quot;absolute&quot;,&quot;_transform_rotateZ_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:3,&quot;sizes&quot;:[]},&quot;_transform_skewX_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:-2,&quot;sizes&quot;:[]},&quot;_transform_skewY_effect&quot;:{&quot;unit&quot;:&quot;px&quot;,&quot;size&quot;:2,&quot;sizes&quot;:[]},&quot;_transform_rotateZ_effect_tablet&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_rotateZ_effect_mobile&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewX_effect_tablet&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewX_effect_mobile&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewY_effect_tablet&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]},&quot;_transform_skewY_effect_mobile&quot;:{&quot;unit&quot;:&quot;deg&quot;,&quot;size&quot;:&quot;&quot;,&quot;sizes&quot;:[]}}\" data-widget_type=\"heading.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<p class=\"elementor-heading-title elementor-size-default\">Book a meeting<\/p>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-3a78ef3 elementor-widget elementor-widget-image\" data-id=\"3a78ef3\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"image.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<img decoding=\"async\" width=\"2048\" height=\"2560\" src=\"https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/lanyard-recolour-full-scaled.webp\" class=\"attachment-full size-full wp-image-27398\" alt=\"\" srcset=\"https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/lanyard-recolour-full-scaled.webp 2048w, https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/lanyard-recolour-full-240x300.webp 240w, https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/lanyard-recolour-full-819x1024.webp 819w, https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/lanyard-recolour-full-768x960.webp 768w, https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/lanyard-recolour-full-1229x1536.webp 1229w, https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/lanyard-recolour-full-1638x2048.webp 1638w, https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/lanyard-recolour-full-72x90.webp 72w\" sizes=\"(max-width: 2048px) 100vw, 2048px\" \/>\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"<p>Enter Your Details I&#8217;m here for&#8230; [My Name] [Company or Handle] Book a meeting<\/p>\n","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"_acf_changed":false,"content-type":"","footnotes":""},"class_list":["post-16316","page","type-page","status-publish","hentry"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.0 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Create Your Social Badge - PI LIVE Europe, 19-21 October 2026<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/performancein.live\/europe\/create-your-social-badge\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Create Your Social Badge - PI LIVE Europe, 19-21 October 2026\" \/>\n<meta property=\"og:description\" content=\"Enter Your Details I&#8217;m here for&#8230; [My Name] [Company or Handle] Book a meeting\" \/>\n<meta property=\"og:url\" content=\"https:\/\/performancein.live\/europe\/create-your-social-badge\/\" \/>\n<meta property=\"og:site_name\" content=\"PI LIVE Europe\" \/>\n<meta property=\"article:modified_time\" content=\"2026-09-08T12:03:07+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/02\/default-social-2-2026.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1281\" \/>\n\t<meta property=\"og:image:height\" content=\"721\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data1\" content=\"2 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/performancein.live\\\/europe\\\/create-your-social-badge\\\/\",\"url\":\"https:\\\/\\\/performancein.live\\\/europe\\\/create-your-social-badge\\\/\",\"name\":\"Create Your Social Badge - PI LIVE Europe, 19-21 October 2026\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/performancein.live\\\/europe\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/performancein.live\\\/europe\\\/create-your-social-badge\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/performancein.live\\\/europe\\\/create-your-social-badge\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/performancein.live\\\/europe\\\/wp-content\\\/uploads\\\/sites\\\/10\\\/2026\\\/09\\\/PI-LIVE-Inline-Dark-Backgrounds.svg\",\"datePublished\":\"2025-06-02T15:47:40+00:00\",\"dateModified\":\"2026-09-08T12:03:07+00:00\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/performancein.live\\\/europe\\\/create-your-social-badge\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/performancein.live\\\/europe\\\/create-your-social-badge\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/performancein.live\\\/europe\\\/create-your-social-badge\\\/#primaryimage\",\"url\":\"https:\\\/\\\/performancein.live\\\/europe\\\/wp-content\\\/uploads\\\/sites\\\/10\\\/2026\\\/09\\\/PI-LIVE-Inline-Dark-Backgrounds.svg\",\"contentUrl\":\"https:\\\/\\\/performancein.live\\\/europe\\\/wp-content\\\/uploads\\\/sites\\\/10\\\/2026\\\/09\\\/PI-LIVE-Inline-Dark-Backgrounds.svg\",\"width\":1955,\"height\":386},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/performancein.live\\\/europe\\\/create-your-social-badge\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/performancein.live\\\/europe\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Create Your Social Badge\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/performancein.live\\\/europe\\\/#website\",\"url\":\"https:\\\/\\\/performancein.live\\\/europe\\\/\",\"name\":\"PI LIVE Europe\",\"description\":\"19-21 October 2026\",\"alternateName\":\"The Festival of Partnerships\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/performancein.live\\\/europe\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Create Your Social Badge - PI LIVE Europe, 19-21 October 2026","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/performancein.live\/europe\/create-your-social-badge\/","og_locale":"en_US","og_type":"article","og_title":"Create Your Social Badge - PI LIVE Europe, 19-21 October 2026","og_description":"Enter Your Details I&#8217;m here for&#8230; [My Name] [Company or Handle] Book a meeting","og_url":"https:\/\/performancein.live\/europe\/create-your-social-badge\/","og_site_name":"PI LIVE Europe","article_modified_time":"2026-09-08T12:03:07+00:00","og_image":[{"width":1281,"height":721,"url":"https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/02\/default-social-2-2026.png","type":"image\/png"}],"twitter_card":"summary_large_image","twitter_misc":{"Est. reading time":"2 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"WebPage","@id":"https:\/\/performancein.live\/europe\/create-your-social-badge\/","url":"https:\/\/performancein.live\/europe\/create-your-social-badge\/","name":"Create Your Social Badge - PI LIVE Europe, 19-21 October 2026","isPartOf":{"@id":"https:\/\/performancein.live\/europe\/#website"},"primaryImageOfPage":{"@id":"https:\/\/performancein.live\/europe\/create-your-social-badge\/#primaryimage"},"image":{"@id":"https:\/\/performancein.live\/europe\/create-your-social-badge\/#primaryimage"},"thumbnailUrl":"https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/PI-LIVE-Inline-Dark-Backgrounds.svg","datePublished":"2025-06-02T15:47:40+00:00","dateModified":"2026-09-08T12:03:07+00:00","breadcrumb":{"@id":"https:\/\/performancein.live\/europe\/create-your-social-badge\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/performancein.live\/europe\/create-your-social-badge\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/performancein.live\/europe\/create-your-social-badge\/#primaryimage","url":"https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/PI-LIVE-Inline-Dark-Backgrounds.svg","contentUrl":"https:\/\/performancein.live\/europe\/wp-content\/uploads\/sites\/10\/2026\/09\/PI-LIVE-Inline-Dark-Backgrounds.svg","width":1955,"height":386},{"@type":"BreadcrumbList","@id":"https:\/\/performancein.live\/europe\/create-your-social-badge\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/performancein.live\/europe\/"},{"@type":"ListItem","position":2,"name":"Create Your Social Badge"}]},{"@type":"WebSite","@id":"https:\/\/performancein.live\/europe\/#website","url":"https:\/\/performancein.live\/europe\/","name":"PI LIVE Europe","description":"19-21 October 2026","alternateName":"The Festival of Partnerships","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/performancein.live\/europe\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"}]}},"_links":{"self":[{"href":"https:\/\/performancein.live\/europe\/wp-json\/wp\/v2\/pages\/16316","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/performancein.live\/europe\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/performancein.live\/europe\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/performancein.live\/europe\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/performancein.live\/europe\/wp-json\/wp\/v2\/comments?post=16316"}],"version-history":[{"count":110,"href":"https:\/\/performancein.live\/europe\/wp-json\/wp\/v2\/pages\/16316\/revisions"}],"predecessor-version":[{"id":27688,"href":"https:\/\/performancein.live\/europe\/wp-json\/wp\/v2\/pages\/16316\/revisions\/27688"}],"wp:attachment":[{"href":"https:\/\/performancein.live\/europe\/wp-json\/wp\/v2\/media?parent=16316"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}