Bienestar laboral
El equipo de Fisify
·
30 July 2026
·
5
min de lectura
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <template id="__bundler_thumbnail"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><rect width="200" height="200" fill="#8B75EE"/><text x="100" y="112" font-size="64" text-anchor="middle" fill="#fff" font-family="sans-serif">F</text></svg> </template> </head> <body> <x-dc> <helmet> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin=""> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@200;300;400;500;600;700;800&amp;family=Philosopher:wght@400;700&amp;display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { margin: 0; } ::selection { background: #EBE6F7; } a { color: #8B75EE; text-decoration: none; } a:hover { color: #7660e0; } @keyframes floaty { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-14px); } } .prl-topbar { padding: 22px 24px; } .prl-hero-grid { grid-template-columns: 1.1fr 0.9fr; } .prl-hero-img { height: 340px; } .prl-diag-grid { grid-template-columns: 1.15fr 0.85fr; padding: 56px 56px; } .prl-diag-form-wrap { width: 468px; height: 621px; } .prl-diag-form-wrap iframe { height: 640px; } .prl-episodes-grid { grid-template-columns: repeat(3,1fr); } .prl-newsletter-grid { grid-template-columns: 1fr 1fr; } .prl-h1-portada { font-size: 44px; } .prl-h1-diag { font-size: 52px; } .prl-h2-campana { font-size: 40px; } .prl-h2-news { font-size: 38px; } @media (max-width: 900px) { .prl-hero-grid { grid-template-columns: 1fr; } .prl-hero-img { height: 280px; } .prl-diag-grid { grid-template-columns: 1fr; padding: 40px 28px; } .prl-diag-form-wrap { width: 100%; height: auto; } .prl-diag-form-wrap iframe { height: 560px; } .prl-episodes-grid { grid-template-columns: repeat(2,1fr); } .prl-newsletter-grid { grid-template-columns: 1fr; text-align: center !important; } .prl-h1-portada { font-size: 36px; } .prl-h1-diag { font-size: 38px; } .prl-h2-campana { font-size: 32px; } .prl-h2-news { font-size: 30px; } } @media (max-width: 640px) { .prl-topbar { padding: 16px 18px; } .prl-episodes-grid { grid-template-columns: 1fr; } .prl-hero-img { height: 220px; } .prl-h1-portada { font-size: 30px; } .prl-h1-diag { font-size: 30px; } .prl-h2-campana { font-size: 26px; } .prl-h2-news { font-size: 24px; } .prl-diag-form-wrap iframe { height: 480px; } } </style> <style> /* ========================================================================= Fisify Design Tokens Source: New Brandbook Fisify.pdf + product screenshots ========================================================================= */ /* ---- Font (Outfit — official brand face) ---- */ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@100;200;300;400;500;600;700;800;900&display=swap'); :root { /* ===== BRAND COLORS (spec names in quotes) ===== */ /* Main palette — only 4 primary colors in the brandbook */ --fis-black: #2A2B43; /* "Black" — deep navy ink; primary text + logo */ --fis-dark-purple: #3F3C63; /* "Dark purple" — headings, emphasis */ --fis-light-pink: #EBE6F7; /* "Light pink" — surfaces, lilac wash */ --fis-white: #FFFFFF; /* Purple accent scale (derived from brand gradient endpoints) */ --fis-purple-50: #F6F2FF; --fis-purple-100: #EBE6F7; /* = light-pink */ --fis-purple-200: #D6CCF4; --fis-purple-300: #C6B5F4; /* = "Main gradient" FROM */ --fis-purple-400: #A793FF; /* = "Secondary gradient" TO */ --fis-purple-500: #8B75EE; /* primary CTA fill (observed in app) */ --fis-purple-600: #7561D9; --fis-purple-700: #5B4BB8; /* Status colors — soft + saturated pairs */ --fis-red-soft: #FFE2E7; /* "Light red" */ --fis-red: #FFA6AB; /* "Red" */ --fis-yellow-soft: #FFFAEC; /* "Light yellow" */ --fis-yellow: #FDE8B1; /* "Yellow" */ --fis-green-soft: #F4FFF2; /* "Light green" */ --fis-green: #AFD7AE; /* "Green" */ /* Pink accent (live badge, warning gradient) */ --fis-pink-live: #FE93A7; /* Main gradient TO */ --fis-pink-warn: #F37C7C; /* Warning gradient TO */ --fis-peach-warn: #FFBE9A; /* Warning gradient FROM */ --fis-blue-bg: #BFD0FF; /* Secondary gradient FROM */ /* Neutrals (derived to harmonize with #2A2B43) */ --fis-ink-900: #2A2B43; --fis-ink-700: #3F3C63; --fis-ink-500: #6B6A85; --fis-ink-400: #8E8DA4; --fis-ink-300: #B4B3C4; --fis-ink-200: #DAD9E3; --fis-ink-100: #EDECF2; --fis-ink-50: #F7F6FA; /* ===== GRADIENTS (exact brandbook values) ===== */ --fis-gradient-main: linear-gradient(135deg, #C6B5F4 0%, #FE93A7 100%); --fis-gradient-background: linear-gradient(135deg, #FBF6F9 0%, #F6E8F7 100%); --fis-gradient-secondary: linear-gradient(135deg, #BFD0FF 0%, #A793FF 100%); --fis-gradient-warning: linear-gradient(135deg, #FFBE9A 0%, #F37C7C 100%); /* ===== SEMANTIC ALIASES ===== */ --fg-1: var(--fis-ink-900); /* primary text */ --fg-2: var(--fis-ink-700); /* secondary text */ --fg-3: var(--fis-ink-500); /* muted text */ --fg-4: var(--fis-ink-400); /* disabled / placeholder */ --fg-on-accent: #FFFFFF; --bg-1: #FFFFFF; /* page bg */ --bg-2: var(--fis-ink-50); /* subtle surface */ --bg-3: var(--fis-purple-50); /* tinted surface */ --border-1: var(--fis-ink-200); --border-2: var(--fis-ink-100); --accent: var(--fis-purple-500); --accent-hover: var(--fis-purple-600); --accent-soft: var(--fis-purple-100); --success: #4FA76F; --success-bg: var(--fis-green-soft); --warning: #B58400; --warning-bg: var(--fis-yellow-soft); --danger: #E35A68; --danger-bg: var(--fis-red-soft); --live: #FE93A7; /* ===== TYPOGRAPHY ===== */ --font-sans: 'Outfit', system-ui, -apple-system, 'Segoe UI', sans-serif; --font-display: 'Outfit', system-ui, -apple-system, sans-serif; --font-mono: ui-monospace, 'SF Mono', Menlo, monospace; /* Letter-spacing — brand default is -2% across headings and large body */ --ls-tight: -0.02em; --ls-default: -0.01em; --ls-wide: 0.02em; /* Type scale (mobile-first; px values) */ --fs-display: 64px; --fs-h1: 44px; --fs-h2: 32px; --fs-h3: 24px; --fs-h4: 20px; --fs-body-lg: 18px; --fs-body: 16px; --fs-body-sm: 14px; --fs-caption: 12px; --fs-micro: 10px; --lh-tight: 1.05; --lh-snug: 1.2; --lh-body: 1.5; --lh-loose: 1.7; /* ===== SPACING (4px baseline) ===== */ --sp-0: 0; --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --sp-8: 32px; --sp-10: 40px; --sp-12: 48px; --sp-16: 64px; --sp-20: 80px; --sp-24: 96px; /* ===== RADII ===== */ --r-sm: 8px; --r-md: 12px; --r-lg: 16px; --r-xl: 20px; --r-2xl: 24px; --r-3xl: 32px; --r-pill: 999px; /* ===== SHADOWS (soft, low-contrast — no hard drop shadows) ===== */ --shadow-xs: 0 1px 2px rgba(42,43,67,0.04); --shadow-sm: 0 2px 6px rgba(42,43,67,0.05); --shadow-md: 0 8px 24px rgba(42,43,67,0.06); --shadow-lg: 0 20px 48px rgba(42,43,67,0.08); --shadow-ring: 0 0 0 4px rgba(139,117,238,0.18); /* ===== MOTION ===== */ --ease-out: cubic-bezier(0.22, 1, 0.36, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --dur-fast: 120ms; --dur-base: 220ms; --dur-slow: 420ms; } /* ===== BASE SEMANTIC ELEMENTS ===== */ html { color: var(--fg-1); font-family: var(--font-sans); } body { margin: 0; background: var(--bg-1); font-size: var(--fs-body); line-height: var(--lh-body); letter-spacing: var(--ls-default); -webkit-font-smoothing: antialiased; } h1, .h1 { font: 600 var(--fs-h1)/var(--lh-tight) var(--font-display); letter-spacing: var(--ls-tight); color: var(--fg-1); margin: 0; } h2, .h2 { font: 600 var(--fs-h2)/var(--lh-snug) var(--font-display); letter-spacing: var(--ls-tight); color: var(--fg-1); margin: 0; } h3, .h3 { font: 600 var(--fs-h3)/var(--lh-snug) var(--font-sans); letter-spacing: var(--ls-tight); color: var(--fg-1); margin: 0; } h4, .h4 { font: 500 var(--fs-h4)/var(--lh-snug) var(--font-sans); letter-spacing: var(--ls-default); color: var(--fg-1); margin: 0; } p, .p { font: 400 var(--fs-body)/var(--lh-body) var(--font-sans); color: var(--fg-2); margin: 0; } .p-lg { font-size: var(--fs-body-lg); } .p-sm { font-size: var(--fs-body-sm); } .caption { font-size: var(--fs-caption); color: var(--fg-3); letter-spacing: var(--ls-wide); text-transform: none; } /* Display — brand hero style ("Diseñando la fisioterapia del futuro") */ .display { font: 600 var(--fs-display)/0.95 var(--font-display); letter-spacing: -0.03em; color: var(--fg-1); } .display em, .display-accent { font-weight: 200; font-style: normal; color: var(--fg-2); } code, kbd { font-family: var(--font-mono); font-size: 0.95em; background: var(--bg-2); padding: 2px 6px; border-radius: 6px; } </style> <script> // GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. "use strict"; (() => { var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); // src/react.ts function getReact() { const R = window.React; if (!R) throw new Error("dc-runtime: window.React is not available yet"); return R; } function getReactDOM() { const RD = window.ReactDOM; if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); return RD; } var h = ((...args) => getReact().createElement( ...args )); // src/parse.ts function parseDcDocument(doc) { const dc = doc.querySelector("x-dc"); if (!dc) return null; const scriptEl = doc.querySelector("script[data-dc-script]"); const { props, preview } = parseDataProps( scriptEl?.getAttribute("data-props") ?? null ); return { template: dc.innerHTML, js: scriptEl ? scriptEl.textContent || "" : "", props, preview }; } function parseDcText(src) { const openMatch = /<x-dc(?:\s[^>]*)?>/.exec(src); if (!openMatch) return null; const close = src.lastIndexOf("</x-dc>"); if (close === -1 || close < openMatch.index) return null; const template = src.slice(openMatch.index + openMatch[0].length, close); const doc = new DOMParser().parseFromString(src, "text/html"); const scriptEl = doc.querySelector("script[data-dc-script]"); const { props, preview } = parseDataProps( scriptEl?.getAttribute("data-props") ?? null ); return { template, js: scriptEl ? scriptEl.textContent || "" : "", props, preview }; } function parseDataProps(raw) { if (!raw) return { props: null, preview: null }; let parsed; try { parsed = JSON.parse(raw); } catch { return { props: null, preview: null }; } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return { props: null, preview: null }; } const obj = parsed; const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; const rest = {}; for (const k of Object.keys(obj)) { if (k[0] !== "$") rest[k] = obj[k]; } return { props: Object.keys(rest).length ? rest : null, preview }; } function dcNameFromPath(pathname) { let p = pathname || ""; try { p = decodeURIComponent(p); } catch { } const base = p.split("/").pop() || "Root"; return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; } // src/boot.ts var BASE_CSS = ` .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); border:1px solid color-mix(in srgb,currentColor 50%,transparent); border-radius:2px;box-sizing:border-box;overflow:hidden} @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} html.sc-dc-streaming .sc-placeholder, html.sc-dc-streaming .sc-interp.sc-missing{position:relative; background:color-mix(in srgb,currentColor 5%,transparent); border-color:transparent} html.sc-dc-streaming .sc-placeholder::before, html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; position:absolute;inset:0;pointer-events:none; background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); background-size:400% 100%;animation:sc-shine 1.4s ease infinite} html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; background:color-mix(in srgb,currentColor 8%,transparent)} .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); border-radius:2px;box-sizing:border-box;color:transparent; user-select:none} .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; color:color-mix(in srgb,currentColor 50%,transparent); background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; padding:0 3px} .sc-host.sc-has-error{position:relative} .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; border-radius:4px;white-space:pre-wrap;pointer-events:none} /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both in sync until dc-runtime regains a build step. */ @media print { @page { margin: 0.5cm; } figure, table { break-inside: avoid; } #dc-root, #dc-root > .sc-host { height: auto; } *, *::before, *::after { print-color-adjust: exact; -webkit-print-color-adjust: exact; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; animation-delay: -99s !important; animation-duration: .001s !important; animation-iteration-count: 1 !important; animation-fill-mode: both !important; animation-play-state: running !important; transition-duration: 0s !important; } } `; var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; function rootNameForDocument(doc, loc) { let bootPath = loc.pathname || ""; if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { try { bootPath = new URL(doc.baseURI || "/").pathname; } catch { } } return dcNameFromPath(bootPath); } function safeDecode(s) { try { return decodeURIComponent(s); } catch { return s; } } function boot(runtime, doc = document) { const parsed = parseDcDocument(doc); if (!parsed) return null; const React = getReact(); const rootName = rootNameForDocument(doc, location); runtime.markFetched(rootName); runtime.setRootName(rootName); runtime.adoptParsed(rootName, parsed); if (!window.__resources) { fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { const raw = t ? parseDcText(t) : null; if (raw?.template) runtime.updateHtml(rootName, raw.template); }).catch(() => { }); } const dc = doc.querySelector("x-dc"); const hostEl = doc.createElement("div"); hostEl.id = "dc-root"; dc.replaceWith(hostEl); if (!parsed.preview) { const s = doc.createElement("style"); s.textContent = FULL_PAGE_CSS; doc.head.appendChild(s); } const Root = runtime.getDC(rootName); const entry = runtime.registry.get(rootName); function StandaloneRoot() { const [, setTick] = React.useState(0); React.useEffect(() => { const sub = () => setTick((n) => n + 1); entry.subs.add(sub); return () => { entry.subs.delete(sub); }; }, []); const defaults = React.useMemo(() => { const d = {}; for (const k in entry.propsMeta || {}) { const v = entry.propsMeta?.[k]?.default; if (v !== void 0) d[k] = v; } return d; }, [entry.propsMeta]); return h(Root, { ...defaults, ...entry.propOverrides || {} }); } const ReactDOM = getReactDOM(); if (ReactDOM.createRoot) ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); else ReactDOM.render(h(StandaloneRoot), hostEl); return rootName; } // src/expr.ts var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; var NUMBER_RE = /^-?\d+(\.\d+)?$/; function resolve(vals, src) { const expr = String(src).trim(); if (!expr) return void 0; if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { return resolve(vals, expr.slice(1, -1)); } const eq = findTopLevelEquality(expr); if (eq) { const lv = resolve(vals, expr.slice(0, eq.index)); const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); switch (eq.op) { case "===": return lv === rv; case "!==": return lv !== rv; case "==": return lv == rv; default: return lv != rv; } } if (expr[0] === "!") return !resolve(vals, expr.slice(1)); if (expr === "true") return true; if (expr === "false") return false; if (expr === "null") return null; if (expr === "undefined") return void 0; if (NUMBER_RE.test(expr)) return Number(expr); if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { return expr.slice(1, -1); } return resolvePath(vals, expr); } function parensWrapWhole(expr) { let depth = 0; for (let i = 0; i < expr.length - 1; i++) { if (expr[i] === "(") depth++; else if (expr[i] === ")") { depth--; if (depth === 0) return false; } } return true; } function findTopLevelEquality(expr) { let depth = 0; for (let i = 0; i < expr.length; i++) { const c = expr[i]; if (c === "[" || c === "(") depth++; else if (c === "]" || c === ")") depth--; else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; if (!expr.slice(0, i).trim()) continue; const op = expr[i + 2] === "=" ? c + "==" : c + "="; return { index: i, op }; } } return null; } function resolvePath(vals, expr) { const head = expr.match(IDENT_RE); if (!head) return void 0; let cur = vals == null ? void 0 : vals[head[0]]; let i = head[0].length; while (i < expr.length) { if (expr[i] === ".") { const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); if (!m) return void 0; cur = cur == null ? void 0 : cur[m[0]]; i += 1 + m[0].length; } else if (expr[i] === "[") { let depth = 1; let j = i + 1; while (j < expr.length && depth > 0) { if (expr[j] === "[") depth++; else if (expr[j] === "]") { depth--; if (depth === 0) break; } j++; } if (depth !== 0) return void 0; const key = resolve(vals, expr.slice(i + 1, j)); cur = cur == null ? void 0 : cur[key]; i = j + 1; } else { return void 0; } } return cur; } // src/encode.ts var CAMEL_ATTR = "sc-camel-"; var INLINE_TEXT_TAGS = new Set( "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( " " ) ); var RAW_WRAP = { select: "sc-raw-select", table: "sc-raw-table", tbody: "sc-raw-tbody", thead: "sc-raw-thead", tfoot: "sc-raw-tfoot", tr: "sc-raw-tr", td: "sc-raw-td", th: "sc-raw-th", caption: "sc-raw-caption" }; var RAW_UNWRAP = Object.fromEntries( Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) ); var EVENT_MAP = { onclick: "onClick", onchange: "onChange", oninput: "onInput", onsubmit: "onSubmit", onkeydown: "onKeyDown", onkeyup: "onKeyUp", onkeypress: "onKeyPress", onmousedown: "onMouseDown", onmouseup: "onMouseUp", onmouseenter: "onMouseEnter", onmouseleave: "onMouseLeave", onfocus: "onFocus", onblur: "onBlur", ondoubleclick: "onDoubleClick", oncontextmenu: "onContextMenu", onmousemove: "onMouseMove", onmouseover: "onMouseOver", onmouseout: "onMouseOut", onpointerdown: "onPointerDown", onpointerup: "onPointerUp", onpointermove: "onPointerMove", onpointerenter: "onPointerEnter", onpointerleave: "onPointerLeave", onpointercancel: "onPointerCancel", onpointerover: "onPointerOver", onpointerout: "onPointerOut", ongotpointercapture: "onGotPointerCapture", onlostpointercapture: "onLostPointerCapture", ontouchstart: "onTouchStart", ontouchend: "onTouchEnd", ontouchmove: "onTouchMove", ontouchcancel: "onTouchCancel", ondragstart: "onDragStart", ondragend: "onDragEnd", ondragenter: "onDragEnter", ondragleave: "onDragLeave", ondragover: "onDragOver", onanimationstart: "onAnimationStart", onanimationend: "onAnimationEnd", onanimationiteration: "onAnimationIteration", ontransitionend: "onTransitionEnd" }; var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; var IMPORT_SELF_CLOSE_RE = new RegExp( "<(x-import|dc-import)(" + ATTRS + ")/>", "gi" ); var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; function encodeCamelAttrs(html) { return html.replace( CAMEL_ATTR_RE, (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq ); } function encodeCase(html) { html = html.replace( IMPORT_SELF_CLOSE_RE, (_, t, a) => "<" + t + a + "></" + t + ">" ); html = html.replace(/<helmet(\s|>)/gi, "<sc-helmet$1"); html = html.replace(/<\/helmet\s*>/gi, "</sc-helmet>"); html = encodeCamelAttrs(html); for (const [real, alias] of Object.entries(RAW_WRAP)) { html = html.replace( new RegExp("(</?)" + real + "(?=[\\s>])", "gi"), "$1" + alias ); } return html; } function kebabToCamel(s) { return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); } function cssToObj(css) { const o = {}; for (const decl of css.split(";")) { const i = decl.indexOf(":"); if (i < 0) continue; const prop = decl.slice(0, i).trim(); o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); } return o; } function compileAttr(raw) { const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); if (whole) { const path = whole[1]; return (vals) => resolve(vals, path); } if (raw.includes("{{")) { const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); } return () => raw; } // src/compile.ts function collectProps(node, kind, host) { const propGetters = []; const pseudoClasses = []; let hintSize = null; for (const { name, value } of [...node.attributes]) { if (name === "sc-name" || name === "data-dc-tpl") continue; let key = name; if (key.startsWith(CAMEL_ATTR)) key = kebabToCamel(key.slice(CAMEL_ATTR.length)); if (key === "hint-size") { hintSize = value; continue; } if (key.startsWith("style-")) { pseudoClasses.push(host.pseudoClass(key.slice(6), value)); continue; } if (kind !== "dom") { if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) key = kebabToCamel(key); } else { if (key === "class") key = "className"; else if (key === "for") key = "htmlFor"; else if (key.startsWith("on")) key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); } propGetters.push([key, compileAttr(value)]); } return { propGetters, pseudoClasses, hintSize }; } var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ "position", "left", "right", "top", "bottom", "inset", "width", "height", "z-index", "transform" ]); function hostPositionStyle(style) { const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; if (!all) return void 0; const out = {}; for (const [k, v] of Object.entries(all)) { const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; } return Object.keys(out).length ? out : void 0; } function compileTemplate(html, host) { const tpl = document.createElement("template"); //! nosemgrep: direct-inner-html-assignment tpl.innerHTML = encodeCase(html); let tplN = 0; (function stamp(node) { if (node.nodeType === Node.ELEMENT_NODE) { node.setAttribute("data-dc-tpl", String(tplN++)); } for (const c of node.childNodes) stamp(c); })(tpl.content); const builders = walkChildren(tpl.content, host); const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); render.__annotated = tpl.innerHTML; return render; } function walkChildren(node, host) { return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); } var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; function isDeckMountTag(el) { if (el.localName === "deck-stage") return true; return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; } function walkDeckChildren(el, host) { const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); const kids = pairs.map((p) => p.b); const seen = /* @__PURE__ */ new Set(); const wsSeen = /* @__PURE__ */ new Map(); const keys = []; const nextSlideId = new Array(pairs.length); { let upcoming = null; for (let j = pairs.length - 1; j >= 0; j--) { const n = pairs[j].c; if (n.nodeType === Node.ELEMENT_NODE) { const t = n.localName; upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; } nextSlideId[j] = upcoming; } } for (let j = 0; j < pairs.length; j++) { const { c } = pairs[j]; if (c.nodeType === Node.TEXT_NODE) { if ((c.nodeValue ?? "").trim() === "") { const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; const n = wsSeen.get(base) ?? 0; wsSeen.set(base, n + 1); keys.push(n === 0 ? base : base + ":" + n); continue; } return { kids, keys: null }; } if (c.nodeType !== Node.ELEMENT_NODE) { keys.push(j); continue; } const child = c; const tag = child.localName; if (DECK_AUX_RE.test(tag)) { keys.push(j); continue; } if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; const v = child.getAttribute("data-om-slide-id"); if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { return { kids, keys: null }; } seen.add(v); keys.push("omid:" + v); } return { kids, keys }; } function renderDeckKids(kids, kidKeys, vals, ctx) { return kids.map((b, j) => { const k = kidKeys ? kidKeys[j] : j; const out = b(vals, ctx, k); return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; }); } function walk(node, host) { if (node.nodeType === Node.TEXT_NODE) return walkText(node); if (node.nodeType !== Node.ELEMENT_NODE) return null; const el = node; const tag = el.tagName.toLowerCase(); if (tag === "sc-for") return walkFor(el, host); if (tag === "sc-if") return walkIf(el, host); if (tag === "x-import") return walkXImport(el, host); if (tag === "sc-helmet") return host.helmet(el); if (tag === "dc-import") return walkComponent(el, host); return walkElement(el, host); } var warnedHoles = /* @__PURE__ */ new Set(); function warnUnresolved(ctx, what) { const key = (ctx?.__name || "?") + "\0" + what; if (warnedHoles.has(key)) return; warnedHoles.add(key); console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); } function walkText(node) { const txt = node.nodeValue ?? ""; if (!txt.includes("{{")) { if (!txt.trim() && !txt.includes(" ")) return null; return () => txt; } const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); return (vals, ctx, key) => h( getReact().Fragment, { key }, ...parts.map((p, i) => { if (!(i & 1)) return p; const v = resolve(vals, p); if (v === void 0) { if (!ctx?.__streamingNow) { if (document.body?.hasAttribute("data-dc-editor-on")) { return h( "span", { key: i, className: "sc-interp sc-unresolved" }, "" ); } warnUnresolved( ctx, " never resolved \u2014 rendered as empty" ); return null; } return h( "span", { key: i, className: "sc-interp sc-missing" }, p.trim() ); } if (getReact().isValidElement(v) || Array.isArray(v)) { return h(getReact().Fragment, { key: i }, v); } if (v === null || typeof v === "boolean") return null; return h("span", { key: i, className: "sc-interp" }, String(v)); }) ); } function walkFor(el, host) { const listGet = compileAttr(el.getAttribute("list") || ""); const asName = el.getAttribute("as") || "item"; const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); const kids = walkChildren(el, host); const listSrc = el.getAttribute("list") || ""; return (vals, ctx, key) => { let list = listGet(vals); if (!Array.isArray(list)) { if (!ctx?.__streamingNow) { if (list !== void 0 && list !== null) { warnUnresolved( ctx, 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" ); } list = []; } else { list = hintN > 0 ? Array(hintN).fill(void 0) : []; } } return h( getReact().Fragment, { key }, list.map((item, i) => { const sub = { ...vals, [asName]: item, $index: i }; return h( getReact().Fragment, { key: i }, kids.map((b, j) => b(sub, ctx, j)) ); }) ); }; } function walkIf(el, host) { const valGet = compileAttr(el.getAttribute("value") || ""); const hintRaw = el.getAttribute("hint-placeholder-val"); const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; const kids = walkChildren(el, host); return (vals, ctx, key) => { let v = valGet(vals); if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); return v ? h( getReact().Fragment, { key }, kids.map((b, j) => b(vals, ctx, j)) ) : null; }; } function walkComponent(el, host) { const name = el.getAttribute("name") || el.getAttribute("component") || ""; el.removeAttribute("name"); el.removeAttribute("component"); const tplId = el.getAttribute("data-dc-tpl"); const styleRaw = el.getAttribute("style"); el.removeAttribute("style"); const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; const { propGetters, hintSize } = collectProps(el, "dc-import", host); const kids = walkChildren(el, host); return (vals, ctx, key) => { const props = { key, __hintSize: hintSize, __tplId: tplId, __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 }; for (const [k, g] of propGetters) { const v = g(vals); if (k === "dcProps") { if (v && typeof v === "object") Object.assign(props, v); continue; } props[k] = v; } if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); return h(host.component(name), props); }; } function walkXImport(el, host) { const globalNameGet = compileAttr( el.getAttribute("component-from-global-scope") || "" ); const exportNameGet = compileAttr( el.getAttribute("component") || el.getAttribute("name") || "" ); const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; const url = urls.length ? urls[urls.length - 1] : ""; const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; const tplId = el.getAttribute("data-dc-tpl"); const styleRaw = el.getAttribute("style"); el.removeAttribute("style"); const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; const wrap = tplId != null || styleGet != null; const { propGetters, hintSize } = collectProps(el, "x-import", host); const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; const kidKeys = deckKeyed?.keys ?? null; const urlBindable = fromRaw.includes("{{"); if (urls.length && !urlBindable) { let prev; for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); } const evalName = (g, vals) => { const v = g(vals); const s = v == null ? "" : String(v); return s.includes("{{") ? "" : s; }; return (vals, ctx, key) => { const globalName = evalName(globalNameGet, vals); const name = globalName || evalName(exportNameGet, vals); const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; const wrapper = wrap ? { key, className: "sc-host-x", "data-dc-tpl": tplId, style: hostStyle || { display: "contents" } } : null; if (!C) { const error = urlBindable ? "x-import `from` cannot contain \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); const ph = host.placeholder({ key: wrapper ? void 0 : key, name, hintSize, error }); return wrapper ? h("div", wrapper, ph) : ph; } const props = wrapper ? {} : { key }; let unresolvedHole = false; for (const [k, g] of propGetters) { if (k === "component" || k === "componentFromGlobalScope" || k === "from") { continue; } const v = g(vals); if (v === void 0) unresolvedHole = true; if (k === "dcProps") { if (v && typeof v === "object") Object.assign(props, v); continue; } props[k] = v; } if (unresolvedHole && ctx?.__htmlStreamingNow) { const ph = host.placeholder({ key: wrapper ? void 0 : key, name, hintSize, error: null }); return wrapper ? h("div", wrapper, ph) : ph; } if (kids.length) { props.children = renderDeckKids(kids, kidKeys, vals, ctx); } return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); }; } function contentKey(el) { const clone = el.cloneNode(true); for (const d of clone.querySelectorAll("*")) { while (d.attributes.length) d.removeAttribute(d.attributes[0].name); } const s = clone.innerHTML; let h2 = 5381; for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; return s.length + "." + (h2 >>> 0).toString(36); } var NEVER_CONTENT_KEYED = new Set( "script style textarea option title select canvas iframe video audio".split( " " ) ); var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; function walkElement(el, host) { const realTag = RAW_UNWRAP[el.localName] || el.localName; const tplId = el.getAttribute("data-dc-tpl"); const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; const { propGetters, pseudoClasses } = collectProps(el, "dom", host); const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); const kidKeys = deckKeyed?.keys ?? null; return (vals, ctx, key) => { const props = { key: key + keySuffix, "data-dc-tpl": tplId }; for (const [k, g] of propGetters) { let v = g(vals); if (k === "style" && typeof v === "string") v = cssToObj(v); if ((k === "value" || k === "checked") && v === void 0) { v = k === "checked" ? false : ""; } props[k] = v; } if (pseudoClasses.length) { props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); } return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); }; } // src/logic.ts var StreamableLogic = class { constructor(props) { __publicField(this, "props"); __publicField(this, "state", {}); /** Back-pointer to the wrapper component, installed after construction. */ __publicField(this, "__host"); this.props = props || {}; } setState(update, cb) { this.__host && this.__host.__setLogicState(update, cb); } forceUpdate() { this.__host && this.__host.forceUpdate(); } componentDidMount() { } componentDidUpdate(_prevProps) { } componentWillUnmount() { } /** The flat object the template renders against (merged over props). */ renderVals() { return {}; } }; function evalDcLogic(src) { //! nosemgrep: eval-and-function-constructor const fn = new Function( "DCLogic", "StreamableLogic", "React", src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' ); return fn(StreamableLogic, StreamableLogic, getReact()); } // src/component.ts function shallowEqual(a, b) { if (!b) return false; const ak = Object.keys(a).filter((k) => k !== "children"); const bk = Object.keys(b).filter((k) => k !== "children"); if (ak.length !== bk.length) return false; for (const k of ak) if (a[k] !== b[k]) return false; return true; } function Placeholder({ name, hintSize, streaming, error }) { const [w, hgt] = (hintSize || "100%,60px").split(","); return h( "div", { className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), style: { width: w.trim(), height: hgt && hgt.trim() }, title: name }, error ? h( "div", { className: "sc-placeholder-error" }, (name ? name + ": " : "") + error ) : null ); } function hintToMin(hint) { if (!hint) return void 0; const [w, hgt] = hint.split(","); return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; } function createComponentFactory(registry, ensureFetched) { const React = getReact(); const AncestorContext = React.createContext([]); class StreamableComponent extends React.Component { constructor(props) { super(props); __publicField(this, "__name"); __publicField(this, "__sub"); __publicField(this, "__needsDidMount", false); /** Snapshot of the registry's streaming flags taken at render time — * builders read it off the RenderCtx (this) to pick placeholder vs * render-nothing for unresolved values. */ __publicField(this, "__streamingNow", false); __publicField(this, "__htmlStreamingNow", false); /** When a construct throws, remember the (class, registry.ver, props) * triple so render-time reconcile doesn't re-attempt it on every parent * re-render. A registry bump (new class, template, external module * resolving via bumpAll) changes `ver` and breaks the memo so an * env-dependent constructor can self-heal. */ __publicField(this, "__failedLogic", null); __publicField(this, "__failedUserProps", null); __publicField(this, "__failedVer", -1); /** Per-instance constructor error — kept here (not on the registry entry) * so one instance's successful construct can't hide a sibling's failure, * and a construct can never wipe an eval error `updateJs` recorded on * `r.logicError`. */ __publicField(this, "__ctorError", null); __publicField(this, "logic"); this.__name = props.__name; this.state = { __v: 0, __err: null }; this.__sub = () => { if (this.state.__err) this.setState({ __err: null }); this.forceUpdate(); }; this.__makeLogic(registry.get(this.__name).Logic, null); ensureFetched(this.__name); } /** Error-boundary hook: a render crash anywhere in this DC's subtree * (its own template, an x-import'd component, a child DC without its * own deeper boundary) lands here instead of unmounting the page. */ static getDerivedStateFromError(e) { return { __err: e instanceof Error && e.message ? e.message : String(e) }; } componentDidCatch(e, info) { console.error( "[dc-runtime] render error in <" + this.__name + ">:", e, info?.componentStack || "" ); } /** Instantiate the logic class (or the no-op base) and adopt `prevState` * over its initial state — used both at mount and on hot-swap. */ __makeLogic(Logic, prevState) { const L = Logic || StreamableLogic; try { this.logic = new L(this.__userProps()); this.__failedLogic = null; this.__failedUserProps = null; this.__ctorError = null; } catch (e) { console.error(e); this.__failedLogic = Logic; this.__failedUserProps = this.__userProps(); this.__failedVer = registry.get(this.__name).ver; this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); this.logic = new StreamableLogic( this.__userProps() ); } this.logic.__host = this; if (prevState) this.logic.state = { ...this.logic.state || {}, ...prevState }; } /** The props the author's logic + template see — internal __-prefixed * wiring stripped. */ __userProps() { const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; return rest; } __setLogicState(update, cb) { const prev = this.logic.state; const patch = typeof update === "function" ? update(prev) : update; this.logic.state = { ...prev, ...patch }; this.setState((s) => ({ __v: s.__v + 1 }), cb); } /** Swap the logic instance when the registry's Logic class changed * (streaming completion, hot reload). State carries over; didMount * re-fires after the swap commits so refs exist. */ __reconcileLogic() { const r = registry.get(this.__name); const Next = r.Logic; const Cur = this.logic.constructor; if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { return; } if (!this.__needsDidMount) { try { this.logic.componentWillUnmount(); } catch (e) { console.error(e); } } this.__makeLogic(Next, this.logic.state); this.__needsDidMount = true; } componentDidMount() { registry.get(this.__name).subs.add(this.__sub); try { this.logic.componentDidMount(); } catch (e) { console.error(e); } } componentDidUpdate(prevProps) { this.logic.props = this.__userProps(); if (this.__needsDidMount) { if (this.state.__err || !registry.get(this.__name).tpl) return; this.__needsDidMount = false; try { this.logic.componentDidMount(); } catch (e) { console.error(e); } } else { try { this.logic.componentDidUpdate(prevProps); } catch (e) { console.error(e); } } } componentWillUnmount() { registry.get(this.__name).subs.delete(this.__sub); if (!this.__needsDidMount) { try { this.logic.componentWillUnmount(); } catch (e) { console.error(e); } } } render() { const r = registry.get(this.__name); const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; const hostBase = { className: cls, style: hostStyle, "data-sc-name": this.__name, "data-dc-tpl": this.props.__tplId }; const chain = Array.isArray(this.context) ? this.context : []; if (chain.includes(this.__name)) { const cycle = [ ...chain.slice(chain.indexOf(this.__name)), this.__name ].join(" \u2192 "); return h( "div", { ...hostBase, className: cls + " sc-has-error" }, h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize, error: "circular import: " + cycle }) ); } if (this.state.__err) { return h( "div", { ...hostBase, className: cls + " sc-has-error" }, h( "div", { className: "sc-logic-error", "data-omelette-chrome": "" }, this.__name + ": " + this.state.__err ), h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize, error: this.state.__err }) ); } this.__reconcileLogic(); if (!r.tpl) { return h( "div", hostBase, h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) ); } const userProps = this.__userProps(); this.logic.props = userProps; let vals = userProps; let renderErr = r.logicError || this.__ctorError; try { vals = { ...userProps, ...this.logic.renderVals() || {} }; } catch (e) { console.error(e); renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); } this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); this.__htmlStreamingNow = !!r.htmlStreaming; return h( "div", { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, renderErr && h( "div", { className: "sc-logic-error", "data-omelette-chrome": "" }, renderErr ), h( AncestorContext.Provider, { value: [...chain, this.__name] }, r.tpl(vals, this) ) ); } } __publicField(StreamableComponent, "contextType", AncestorContext); const named = /* @__PURE__ */ new Map(); function getDC(name) { const hit = named.get(name); if (hit) return hit; function Dispatcher(p) { const [, setTick] = React.useState(0); React.useEffect(() => { const sub = () => setTick((n) => n + 1); registry.get(name).subs.add(sub); return () => { registry.get(name).subs.delete(sub); }; }, []); ensureFetched(name); return h(StreamableComponent, { ...p, __name: name }); } Dispatcher.displayName = name; named.set(name, Dispatcher); return Dispatcher; } return { getDC, StreamableComponent }; } // src/bundled.ts function bundledBlob(url) { const blobs = window.__resourceBlobs; const b = blobs ? blobs[url.split("#")[0]] : void 0; return b instanceof Blob ? b : null; } // src/cdn.ts var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; function cdnScriptFor(url, sri) { const res = window.__resources; const v = res ? res[url] : void 0; return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; } // src/external.ts var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); function isRenderableType(g) { if (typeof g === "function") return !isElementClass(g); return typeof g === "object" && g !== null && typeof g.$typeof === "symbol"; } function resolveDottedPath(root, name) { let cur = root; for (const seg of name.split(".")) { if (cur == null) return void 0; cur = cur[seg]; } return cur; } var GLOBAL_POLL_INTERVAL_MS = 50; var GLOBAL_POLL_TIMEOUT_MS = 3e4; function createExternalModules(onResolved) { const cache = /* @__PURE__ */ new Map(); let babelLoading = null; const reportedMissing = /* @__PURE__ */ new Map(); const polling = /* @__PURE__ */ new Set(); function ensureBabel() { if (window.Babel) return Promise.resolve(); if (babelLoading) return babelLoading; const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); babelLoading = new Promise((res, rej) => { const s = document.createElement("script"); s.src = babel.src; if (babel.integrity) { s.integrity = babel.integrity; s.crossOrigin = "anonymous"; } s.onload = () => res(); s.onerror = rej; document.head.appendChild(s); }); return babelLoading; } const pending = /* @__PURE__ */ new Map(); function load(kind, url, after) { const existing = pending.get(url); if (existing) return existing; cache.set(url, null); console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); const ready = Promise.all([ kind === "jsx" ? ensureBabel() : Promise.resolve(), after ?? Promise.resolve() ]); const p = ready.then(() => { const pre = bundledBlob(url); if (pre) return pre.text(); return fetch(url).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); }); }).then((src) => { const code = kind === "jsx" ? window.Babel.transform(src, { filename: url, presets: ["react", "typescript"] }).code : src; const module = { exports: {} }; const before = new Set(Object.keys(window)); //! nosemgrep: eval-and-function-constructor new Function("React", "module", "exports", "require", code)( getReact(), module, module.exports, () => ({}) ); const globals = {}; for (const k of Object.keys(window)) { if (!before.has(k) && typeof window[k] === "function") { globals[k] = window[k]; } } cache.set(url, { mod: module.exports, globals }); console.info( "[dc-runtime] x-import: loaded", url, "\u2014 exports:", Object.keys(module.exports), "window globals:", Object.keys(globals) ); onResolved(); }).catch((e) => { cache.set(url, { mod: {}, globals: {}, error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) }); console.error( "[dc-runtime] x-import: FAILED to load", url, "(" + kind + ")", e ); onResolved(); }); pending.set(url, p); return p; } function resolve2(url, name) { const entry = cache.get(url); if (!entry) return null; const { mod, globals } = entry; const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; if (typeof C === "function") return C; const key = url + "\0" + name; if (!reportedMissing.has(key)) { reportedMissing.set( key, entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" ); console.error( "[dc-runtime] x-import: module", url, "loaded but has no component named", JSON.stringify(name), "\u2014 available exports:", Object.keys(mod), "window globals:", Object.keys(globals), ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." ); } return null; } function waitForGlobal(name) { if (polling.has(name)) return; polling.add(name); const started = Date.now(); const isCE = isCustomElementName(name); const tick = () => { const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); if (found) { polling.delete(name); onResolved(); return; } if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { console.warn( "[dc-runtime] x-import: global", JSON.stringify(name), "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" ); return; } setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); }; setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); } function resolveGlobal(url, name) { const isCE = isCustomElementName(name); if (!url) { if (isCE) { if (customElements.get(name)) return name; waitForGlobal(name); return null; } const g2 = resolveDottedPath(window, name); if (isRenderableType(g2)) return g2; waitForGlobal(name); return null; } const entry = cache.get(url); if (!entry) return null; if (isCE && customElements.get(name)) return name; const g = entry.globals[name] ?? resolveDottedPath(window, name); if (isRenderableType(g)) return g; if (name.includes(".")) return null; const key = url + "\0global\0" + name; if (!reportedMissing.has(key)) { reportedMissing.set(key, null); if (isCE && !customElements.get(name)) { console.warn( "[dc-runtime] x-import:", url, "loaded but no custom element", JSON.stringify(name), "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." ); } } return name; } function getError(url, name) { const entry = cache.get(url); if (entry?.error) return entry.error; return reportedMissing.get(url + "\0" + name) || null; } return { load, resolve: resolve2, resolveGlobal, getError }; } function isElementClass(g) { try { return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; } catch { return false; } } // src/atomics.ts var ATOMIC_CSS = ( // layout ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" ); // src/helmet.ts var DESIGN_DOC_MODE_RE = /<meta\b[^>]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; var CANVAS_BG_LIGHT = "#f0eee6"; var CANVAS_BG_DARK = "#2e2c26"; function createHelmetManager(doc, isStreaming) { const mounted = /* @__PURE__ */ new Set(); const live = /* @__PURE__ */ new Map(); let designDocMode = null; let canvasStyleEl = null; let appTheme = "light"; try { const ds = doc.documentElement.dataset.theme; appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( "theme" ) === "dark" ? "dark" : "light"; } catch { } function applyCanvasBg() { if (!canvasStyleEl) return; const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; } function postDesignMode(mode) { if (window.parent === window) return; try { window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); } catch { } } function setDesignDocMode(mode) { if (mode === designDocMode) return; designDocMode = mode; postDesignMode(mode); if (mode === "canvas") { doc.documentElement.setAttribute("data-dc-canvas", ""); canvasStyleEl = doc.createElement("style"); canvasStyleEl.setAttribute("data-dc-canvas", ""); applyCanvasBg(); doc.head.appendChild(canvasStyleEl); } else { doc.documentElement.removeAttribute("data-dc-canvas"); canvasStyleEl?.remove(); canvasStyleEl = null; } } window.addEventListener("message", (e) => { const type = e.data && e.data.type; if (type === "__dc_theme") { const t = e.data.theme; if (t === "light" || t === "dark") { appTheme = t; applyCanvasBg(); } return; } if (!designDocMode || type !== "__dc_probe") return; postDesignMode(designDocMode); }); function compile(node) { const raw = [...node.children]; const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { mounted.add("__dc-atomics"); const el = doc.createElement("style"); el.id = "__dc-atomics"; el.textContent = ATOMIC_CSS; doc.head.appendChild(el); } return (_vals, ctx) => { const name = ctx && ctx.__name || ""; const streaming = !!(name && isStreaming(name)); for (let i = 0; i < raw.length; i++) { const child = raw[i]; const tag = child.tagName; const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; if (tag === "SCRIPT") { if (mayBePartial) continue; const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); if (mounted.has(key)) continue; mounted.add(key); const el = doc.createElement("script"); for (const { name: an, value } of [...child.attributes]) el.setAttribute(an, value); if (child.textContent) el.textContent = child.textContent; doc.head.appendChild(el); } else if (tag === "LINK" || tag === "META") { if (mayBePartial) continue; const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); if (mounted.has(key)) continue; mounted.add(key); if (tag === "LINK") { const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); const href = (child.getAttribute("href") || "").trim(); const res = window.__resources; const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; if (blob) { const el = doc.createElement("style"); if (child.hasAttribute("disabled")) { el.setAttribute("media", "not all"); } else if (child.getAttribute("media")) { el.setAttribute("media", child.getAttribute("media")); } if (child.getAttribute("title")) el.setAttribute("title", child.getAttribute("title")); void blob.text().then((css) => { el.textContent = css; }); doc.head.appendChild(el); continue; } } doc.head.appendChild(child.cloneNode(true)); } else { const key = name + "|" + i; let el = live.get(key); if (!el || el.tagName !== tag) { if (el) el.remove(); el = doc.createElement(tag.toLowerCase()); live.set(key, el); doc.head.appendChild(el); } for (const { name: an, value } of [...child.attributes]) { if (el.getAttribute(an) !== value) el.setAttribute(an, value); } if (el.textContent !== child.textContent) el.textContent = child.textContent; } } return null; }; } return { compile, setDesignDocMode }; } // src/pseudo.ts function scanUnquotedUrl(css, i) { if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { return -1; } let j = i + 4; while (j < css.length && /\s/.test(css[j])) j++; if (css[j] === '"' || css[j] === "'") return -1; while (j < css.length && css[j] !== ")") { if (css[j] === "\\") j++; j++; } return j < css.length ? j + 1 : css.length; } function stripComments(css) { let out = ""; let quote = ""; for (let i = 0; i < css.length; i++) { const c = css[i]; if (quote) { if (c === "\\") { out += c + (css[i + 1] ?? ""); i++; continue; } if (c === quote) quote = ""; out += c; } else if (c === "'" || c === '"') { quote = c; out += c; } else if (c === "/" && css[i + 1] === "*") { const end = css.indexOf("*/", i + 2); i = end === -1 ? css.length : end + 1; out += " "; } else { const end = scanUnquotedUrl(css, i); if (end === -1) out += c; else { out += css.slice(i, end); i = end - 1; } } } return out; } function importantify(css) { css = stripComments(css); const decls = []; let start = 0; let depth = 0; let quote = ""; for (let i = 0; i < css.length; i++) { const c = css[i]; if (quote) { if (c === "\\") i++; else if (c === quote) quote = ""; } else if (c === "'" || c === '"') quote = c; else if (c === "(") depth++; else if (c === ")") depth = Math.max(0, depth - 1); else if (c === ";" && depth === 0) { decls.push(css.slice(start, i)); start = i + 1; } else { const end = scanUnquotedUrl(css, i); if (end !== -1) i = end - 1; } } decls.push(css.slice(start)); return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); } function createPseudoSheet(doc) { let el = null; const cache = /* @__PURE__ */ new Map(); let n = 0; return (pseudo, css) => { const k = pseudo + "|" + css; const hit = cache.get(k); if (hit) return hit; if (!el) { el = doc.createElement("style"); doc.head.appendChild(el); } const cls = "scp" + (n++).toString(36); const isPseudoElement = pseudo === "before" || pseudo === "after"; const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; el.sheet.insertRule( sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", el.sheet.cssRules.length ); cache.set(k, cls); return cls; }; } // src/registry.ts function createRegistry() { const entries = /* @__PURE__ */ Object.create(null); function get(name) { return entries[name] || (entries[name] = { html: "", tpl: null, Logic: null, jsStreaming: false, htmlStreaming: false, ver: 0, subs: /* @__PURE__ */ new Set(), fetched: false }); } function bump(name) { const r = get(name); r.ver++; for (const fn of r.subs) fn(); } return { entries, get, bump, bumpAll() { for (const n in entries) bump(n); } }; } // src/runtime.ts var COMPONENT_DIR = "."; function createRuntime(doc = document) { const registry = createRegistry(); const pseudoClass = createPseudoSheet(doc); const helmet = createHelmetManager( doc, (name) => registry.get(name).htmlStreaming ); const external = createExternalModules(() => registry.bumpAll()); const factory = createComponentFactory(registry, ensureFetched); const host = { component: (name) => factory.getDC(name), placeholder: (props) => h(Placeholder, props), helmet: (node) => helmet.compile(node), loadExternal: (kind, url, after) => external.load(kind, url, after), resolveExternal: (url, name) => external.resolve(url, name), resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), resolveExternalError: (url, name) => external.getError(url, name), pseudoClass }; function ensureFetched(name) { const r = registry.get(name); if (r.fetched) return; r.fetched = true; const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; const res = window.__resources; const pre = res ? res[url] : void 0; const target = typeof pre === "string" && pre ? pre : url; const blob = bundledBlob(target); (blob ? blob.text() : fetch(target).then((res2) => { if (!res2.ok) { console.error( '[dc-runtime] sibling fetch for "' + name + '" failed:', url, "returned", res2.status, "\u2014 the reference renders as an empty placeholder." ); return ""; } return res2.text(); })).then((t) => { if (!t) return; const parsed = parseDcText(t); if (!parsed) { console.error( '[dc-runtime] sibling fetch for "' + name + '":', url, "has no <x-dc> block \u2014 not a Design Component." ); return; } if (parsed.props) r.propsMeta = parsed.props; if (parsed.preview) r.preview = parsed.preview; if (parsed.template && !r.html) updateHtml(name, parsed.template); if (parsed.js && !r.Logic) updateJs(name, parsed.js); }).catch( (e) => console.error( '[dc-runtime] sibling fetch for "' + name + '" threw:', url, e ) ); } let rootName = null; function updateHtml(name, html) { const r = registry.get(name); r.html = html; if (name === rootName) { const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); } try { r.tpl = compileTemplate(html, host); } catch (e) { console.error("[dc-runtime] template compile FAILED for", name, e); } registry.bump(name); } function updateJs(name, src) { const r = registry.get(name); const seq = r.jsSeq = (r.jsSeq || 0) + 1; try { const Cls = evalDcLogic(src); if (r.jsSeq !== seq) return; if (typeof Cls !== "function") { r.logicError = name + ".dc.html: <script data-dc-script> must define `class Component extends DCLogic`"; } else { r.logicError = null; r.Logic = Cls; } } catch (e) { if (r.jsSeq !== seq) return; console.error( "[dc-runtime] logic class eval FAILED for", name, "\u2014 the template renders with props only.", e ); r.logicError = name + ": " + (e instanceof Error && e.message ? e.message : String(e)); } registry.bump(name); } function setStreaming(name, kind, on) { const r = registry.get(name); if (kind === "html") r.htmlStreaming = !!on; else r.jsStreaming = !!on; let any = false; for (const n in registry.entries) { const e = registry.entries[n]; if (e && (e.htmlStreaming || e.jsStreaming)) { any = true; break; } } doc.documentElement.classList.toggle("sc-dc-streaming", any); registry.bump(name); } function dcUpdate(name, kind, content, streaming) { if (streaming) registry.get(name).fetched = true; if (kind === "html") { setStreaming(name, "html", !!streaming); updateHtml(name, content); } else if (kind === "js") { setStreaming(name, "js", !!streaming); if (!streaming) updateJs(name, content); } else if (kind === "props") { const { props, preview } = parseDataProps(content); const r = registry.get(name); r.propsMeta = props ?? void 0; r.preview = preview; registry.bump(name); } } function setProps(name, overrides) { registry.get(name).propOverrides = overrides && typeof overrides === "object" ? { ...overrides } : null; registry.bump(name); } function adoptParsed(name, parsed) { if (!parsed) return; const r = registry.get(name); if (parsed.props) r.propsMeta = parsed.props; if (parsed.preview) r.preview = parsed.preview; if (parsed.template) updateHtml(name, parsed.template); if (parsed.js) updateJs(name, parsed.js); } return { registry, getDC: factory.getDC, updateHtml, updateJs, dcUpdate, setProps, adoptParsed, setRootName: (name) => { rootName = name; }, markFetched: (name) => { registry.get(name).fetched = true; }, annotatedTemplate: (name) => { const r = registry.get(name); return r.tpl && r.tpl.__annotated || null; }, templateSource: (name) => registry.get(name).html || null, StreamableLogic }; } // src/stream-state.ts function createStreamTracker(staleMs = 6e4, now = Date.now) { const since = /* @__PURE__ */ new Map(); const liveOne = (n) => { const t = since.get(n); if (t === void 0) return false; if (now() - t > staleMs) { since.delete(n); return false; } return true; }; return { push(name, streaming, viewportKey) { if (viewportKey === "dc-model") return; if (streaming) since.set(name, now()); else since.delete(name); }, live(name) { if (name !== void 0) return liveOne(name); for (const n of [...since.keys()]) if (liveOne(n)) return true; return false; } }; } // src/index.ts function hideRawTemplate() { const s = document.createElement("style"); s.textContent = "x-dc{display:none!important}"; document.head.appendChild(s); } function loadScript(src, integrity) { return new Promise((resolve2, reject) => { //! nosemgrep: create-script-element const s = document.createElement("script"); s.src = src; if (integrity) { s.integrity = integrity; s.crossOrigin = "anonymous"; } s.async = false; s.onload = () => resolve2(); s.onerror = () => reject(new Error(`failed to load ${src}`)); document.head.appendChild(s); }); } function loadReactUmd() { const w = window; if (w.React && w.ReactDOM) return Promise.resolve(); const react = cdnScriptFor(REACT_URL, REACT_SRI); const reactDom = cdnScriptFor(REACT_DOM_URL, REACT_DOM_SRI); return Promise.all([ loadScript(react.src, react.integrity), loadScript(reactDom.src, reactDom.integrity) ]).then(() => void 0); } function init() { const runtime = createRuntime(document); let rootName = "Root"; const baseCss = document.createElement("style"); baseCss.textContent = BASE_CSS; document.head.prepend(baseCss); const notifyHost = () => { if (window.parent === window) return; const r = runtime.registry.entries[rootName]; try { window.parent.postMessage( { type: "__dc_booted", rootName, propsMeta: r && r.propsMeta || null, preview: r && r.preview || null }, "*" ); } catch { } }; const streams = createStreamTracker(); const api = { __dcUpdate: (name, kind, content, streaming, viewportKey) => { streams.push(name, streaming, viewportKey); runtime.dcUpdate(name, kind, content, streaming); if (name === rootName && !streaming && kind === "props") notifyHost(); }, __dcStreaming: (name) => streams.live(name), __dcSetProps: (name, overrides) => runtime.setProps(name, overrides), /** Name of the component currently mounted as the page root — DC tools * push their template-stream here when targeting "the open page". */ __dcRootName: () => rootName, /** Editor bridge — the encoded, `data-dc-tpl`-annotated template source. * The host editor parses this into its own template DOM so it can map a * rendered node (carrying the same `data-dc-tpl`) back to the source * node that emitted it. Returns the encoded form (`sc-camel-*` attrs, * `<sc-raw-*>`/`<sc-helmet>` tags); the editor decodes on serialize. */ __dcAnnotatedTemplate: (name) => runtime.annotatedTemplate(name), /** Editor bridge — the *original* (decoded) template source. */ __dcTemplateSource: (name) => runtime.templateSource(name), __dcBoot: () => { rootName = boot(runtime, document) ?? rootName; notifyHost(); }, __dcRegistry: runtime.registry.entries, getDC: (name) => runtime.getDC(name), // `DCLogic` is the documented base class name; `StreamableLogic` is the // implementation alias kept for any project that already references it. DCLogic: runtime.StreamableLogic, StreamableLogic: runtime.StreamableLogic }; Object.assign(window, api); window.__dcContentKeyed = true; if (document.readyState !== "loading") api.__dcBoot(); else document.addEventListener("DOMContentLoaded", () => api.__dcBoot()); } hideRawTemplate(); loadReactUmd().then(init).catch((err) => { console.error("[dc] failed to load React or boot:", err); throw err; }); })(); </script> <script> // @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) // Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected). /* BEGIN USAGE */ /** * <image-slot> — user-fillable image placeholder. * * Drop this into a deck, mockup, or page wherever a design needs an image. * You control the slot's shape; it sizes to its container by default. When the search_stock_photos tool * is available, prefill the slot by default — write the photo's URL into * src (with credit/credit-href); the user can still fill or replace it * by dragging an image file onto it (or clicking to browse). The dropped * image persists across reloads via a .image-slots.state.json sidecar — * same read-via-fetch / write-via-window.omelette pattern as * design_canvas.jsx, so the filled slot shows on share links, downloaded * zips, and PPTX export. Outside the omelette runtime the slot is read-only. * * The sidecar is a SIBLING of the HTML file that uses this component: the * read is a document-relative fetch, and the host resolves the bridge's * sidecar writes into the previewed file's directory to match (same * contract as design_canvas.jsx). Pages in the same directory share one * sidecar; keep slot ids distinct across them. * * Attributes: * id Persistence key. REQUIRED for the drop to survive reload — * every slot on the page needs a distinct id. * shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded') * 'circle' applies 50% border-radius; on a non-square slot * that's an ellipse — set equal width and height for a true * circle. * radius Corner radius in px for 'rounded'. (default 12) * mask Any CSS clip-path value. Overrides `shape` — use this for * hexagons, blobs, arbitrary polygons. * fit Initial framing baseline: cover | contain. (default 'cover') * cover starts the image filling the frame (overflow cropped); * contain starts it fully visible (letterboxed). Either way the * user can always pan/scale from there — double-click, or the * Edit control, enters reframe mode (drag to move, scroll or * corner-handles to scale; Escape / click-out commits). The * crop persists alongside the image in the sidecar. * placeholder Empty-state caption. (default 'Drop an image') * src Optional initial/fallback image URL. Prefill it with a real * photo via search_stock_photos when that tool is available * (set credit/credit-href from the result). A user drop * overrides it; clearing the drop reveals src again. * credit Attribution text shown as a small overlay at the * bottom-left of the filled slot. REQUIRED whenever src * points at any Unsplash host (images.unsplash.com, * plus.unsplash.com, …): an Unsplash src with no credit * renders an error tile INSTEAD of the photo (Unsplash * terms forbid showing their photos unattributed). Use the * exact form 'Photo by {photographer name} on Unsplash' — * the overlay then links the name to credit-href and * 'Unsplash' to the Unsplash homepage, and links back to * unsplash.com automatically get the required utm referral * params appended at render time. The credit belongs to * the src image, so it only shows while src is what's * displayed — a user-dropped image hides it. * credit-href Link for the photographer's name in the credit overlay * (their Unsplash profile URL from the stock-photo search * results). http(s) URLs only — anything else renders the * name as plain text. * * Sizing: the slot fills its container by default (width/height 100%). * Put it in a sized wrapper — absolutely positioned, a grid cell, a fixed * frame — and it takes exactly that box. When the parent's height is * indefinite (ordinary flow), it falls back to full width at a 3:2 aspect * ratio instead of collapsing. In a shrink-to-fit parent (a float, * width:max-content, an unsized absolute wrapper), percentages have * nothing to resolve against — size the slot or its wrapper explicitly * there. For a fixed-size slot, set * width/height on the element itself (inline style), which overrides the * default. When * layering content above a slot (full-bleed layouts), make the overlay * click-through — pointer-events: none on scrims/text plates, re-enabled * on interactive children — so the slot's hover controls stay reachable. * Keep the slot's bottom-left corner visually clear as well: the credit * overlay renders there, and a dark fade or text plate covering it hides * the attribution Unsplash's terms require — end the fade above that * corner, or keep it nearly transparent where the credit sits. * * Usage: * <div style="position:relative;width:100%;height:100%"> <!-- full-bleed: --> * <image-slot id="bg" shape="rect"></image-slot> <!-- fills the wrapper --> * </div> * <image-slot id="hero" style="width:800px;height:450px" shape="rounded" radius="20" * placeholder="Drop a hero image"></image-slot> * <image-slot id="avatar" style="width:120px;height:120px" shape="circle"></image-slot> * <image-slot id="kite" style="width:300px;height:300px" * mask="polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"></image-slot> */ /* END USAGE */ (() => { const STATE_FILE = '.image-slots.state.json'; // Unsplash terms require visible attribution wherever their photos // display, and every link back to unsplash.com must carry utm referral // params. Two render-time rules enforce that here: // - an Unsplash-src slot with NO credit attribute renders an error // tile INSTEAD of the photo (an uncredited Unsplash photo on screen // is itself the terms violation, so it never renders bare); // - rendered credit links pointing at unsplash.com get the referral // params appended when absent (credit-href values live in page // content that can't be edited after the fact). // Keep the utm_source value in sync with UTM_SOURCE in // platform/web-agent/unsplash.ts — this file is a project-local // artifact and cannot import it (equality is pinned by tests). const UNSPLASH_HOMEPAGE_HREF = 'https://unsplash.com/?utm_source=claude_design&utm_medium=referral'; // Host rule mirrors the hotlink validator that admits Unsplash srcs into // pages in the first place (cdn$ in unsplash.ts: apex or any subdomain) // — Unsplash+ results serve from plus.unsplash.com, not just images.*, // and an admitted-but-uncredited photo must error whatever unsplash // host it rides on. // Trailing-dot FQDNs (images.unsplash.com.) are the same host to the // browser but would miss the regex — strip one dot so the check fails // CLOSED (unrecognized-but-real Unsplash srcs must error, not render). const isUnsplashHost = (u) => { try { return /(^|\.)unsplash\.com$/.test( new URL(u, document.baseURI).hostname.replace(/\.$/, '') ); } catch { return false; } }; // Render-time referral normalization for links back to Unsplash: // appends utm_source/utm_medium when absent, preserves every existing // query param, never overwrites an existing utm_source, and passes // non-Unsplash URLs through untouched. Input is an ABSOLUTE validated // http(s) URL (the credit render funnel resolves + validates first). const withReferral = (href) => { try { const u = new URL(href); if (!/(^|\.)unsplash\.com$/.test(u.hostname.replace(/\.$/, ''))) { return href; } if (!u.searchParams.has('utm_source')) { u.searchParams.set('utm_source', 'claude_design'); } if (!u.searchParams.has('utm_medium')) { u.searchParams.set('utm_medium', 'referral'); } return u.toString(); } catch (e) { return href; } }; // 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the // sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB. const MAX_DIM = 1200; // Raster formats only. SVG is excluded (can carry script; createImageBitmap // on SVG blobs is inconsistent). GIF is excluded because the canvas // re-encode keeps only the first frame, so an animated GIF would silently // go still — better to reject than surprise. const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif']; // ── Shared sidecar store ──────────────────────────────────────────────── // One fetch + immediate write-on-change for every <image-slot> on the // page. Reads via fetch() so viewing works anywhere the HTML and sidecar // are served together; writes go through window.omelette.writeFile, which // the host allowlists to *.state.json basenames only. const subs = new Set(); let slots = {}; // ids explicitly cleared before the sidecar fetch resolved — otherwise // the merge below can't tell "never set" from "just deleted" and would // resurrect the sidecar's stale value. const tombstones = new Set(); let loaded = false; let loadP = null; function load() { if (loadP) return loadP; loadP = fetch(STATE_FILE) .then((r) => (r.ok ? r.json() : null)) .then((j) => { // Merge: sidecar loses to any in-memory change that raced ahead of // the fetch (drop or clear) so neither is clobbered by hydration. if (j && typeof j === 'object') { const merged = Object.assign({}, j, slots); // A framing-only write that raced ahead of hydration must not // drop a user image that's only on disk — inherit u from the // sidecar for any in-memory entry that lacks one. for (const k in slots) { if (merged[k] && !merged[k].u && j[k]) { merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u; } } for (const id of tombstones) delete merged[id]; slots = merged; } tombstones.clear(); }) .catch(() => {}) .then(() => { loaded = true; subs.forEach((fn) => fn()); }); return loadP; } // Serialize writes so two near-simultaneous drops on different slots // can't reorder at the backend and leave the sidecar with only the // first. A save requested mid-flight just marks dirty and re-fires on // completion with the then-current slots. let saving = false; let saveDirty = false; // Unload-time flush: save()'s serialization defers a mid-RTT re-fire to a // .then that never runs in an unloading document, silently dropping a // pagehide commit. Post the current slots immediately instead — content // is a superset snapshot of any in-flight save's, the write is a // whole-file last-writer-wins replace, and postMessage FIFO delivers it // to the host after the in-flight one, so a backend-side reorder at // worst reproduces the dropped-commit outcome this flush improves on. // Guarded on the initial sidecar read: pre-hydration slots can miss // other slots' persisted entries, and flushing it would clobber them — // that narrow case stays best-effort (the in-memory merge in load() // cannot happen in an unloading document anyway). function flushNow() { if (!loaded) return; const w = window.omelette && window.omelette.writeFile; if (!w) return; try { Promise.resolve(w(STATE_FILE, JSON.stringify(slots))).catch(() => {}); } catch (e) {} } function save() { if (saving) { saveDirty = true; return; } const w = window.omelette && window.omelette.writeFile; if (!w) return; saving = true; Promise.resolve(w(STATE_FILE, JSON.stringify(slots))) .catch(() => {}) .then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } }); } const S_MAX = 5; const clampS = (s) => Math.max(1, Math.min(S_MAX, s)); // Normalize a stored slot value. Pre-reframe sidecars stored a bare // data-URL string; newer ones store {u, s, x, y}. Either shape is valid. function getSlot(id) { const v = slots[id]; if (!v) return null; return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v; } function setSlot(id, val) { if (!id) return; if (val) { slots[id] = val; tombstones.delete(id); } else { delete slots[id]; if (!loaded) tombstones.add(id); } subs.forEach((fn) => fn()); // A drop is rare + high-value — write immediately so nav-away can't lose // it. Gate on the initial read so we don't overwrite a sidecar we haven't // merged yet; the merge in load() keeps this change once the read lands. if (loaded) save(); else load().then(save); } // ── Image downscale ───────────────────────────────────────────────────── // Encode through a canvas so the sidecar carries resized bytes, not the // raw upload. Longest side is capped at 2× the slot's rendered width // (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG // for photos, so there's no need for per-image format picking. async function toDataUrl(file, targetW) { const bitmap = await createImageBitmap(file); try { const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM); const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height)); const w = Math.max(1, Math.round(bitmap.width * scale)); const h = Math.max(1, Math.round(bitmap.height * scale)); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h); return canvas.toDataURL('image/webp', 0.85); } finally { bitmap.close && bitmap.close(); } } // ── Custom element ────────────────────────────────────────────────────── const stylesheet = // Fill the container by default: slots are usually placed inside a // sized wrapper (a hero frame, a grid cell, an inset:0 layer) and are // expected to take that box — a fixed intrinsic size would render as // a small tile in the corner of a full-bleed wrapper instead. // aspect-ratio is the companion fallback that keeps a bare slot // visible when the parent's height is indefinite: height:100% // resolves to auto there, and the ratio then derives height from // width instead of letting the slot collapse to zero height. // Explicit width/height on the element override all of this. // color:inherit (not a fixed near-black): the placeholder chrome — // empty-state icon/caption (currentColor) and the dashed ring — must // read on dark decks too, and the slide's own text color is the one // color guaranteed to contrast with the slide background. The soft // look comes from opacity on those parts, not from a baked-in alpha. ':host{display:block;position:relative;' + ' font:13px/1.3 system-ui,-apple-system,sans-serif;' + ' width:100%;height:100%;aspect-ratio:3/2}' + '.empty .cap,.empty .sub{opacity:.75}' + '.frame{position:absolute;inset:0;overflow:hidden;background:rgba(127,127,127,.08)}' + // .frame img (clipped) and .spill (unclipped ghost + handles) share the // same left/top/width/height in frame-%, computed by _applyView(), so the // inside-mask crop and the outside-mask spill stay pixel-aligned. '.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' + ' -webkit-user-drag:none;user-select:none;touch-action:none}' + // Reframe mode (double-click): the full image spills past the mask. The // spill layer is sized to the IMAGE bounds so its corners are where the // resize handles belong. The ghost <img> inside is translucent; the real // clipped <img> underneath shows the opaque in-mask crop. // popover=manual promotes the spill to the top layer on reframe, so it is // not clipped by any overflow:hidden / clip-path / scroll-container // ancestor (a plain z-index can't escape overflow clipping). UA popover // defaults (inset:0;margin:auto) are reset; _applyView sets viewport px. '.spill{position:fixed;margin:0;inset:auto;border:0;padding:0;background:transparent;' + ' overflow:visible;transform:translate(-50%,-50%);z-index:1;cursor:grab;touch-action:none}' + ':host([data-panning]) .spill{cursor:grabbing}' + '.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' + ' pointer-events:none;-webkit-user-drag:none;user-select:none;' + ' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' + '.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' + ' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' + ' transform:translate(-50%,-50%)}' + '.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' + '.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' + '.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' + '.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' + ':host([data-reframe]){z-index:10}' + ':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' + '.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' + ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' + ' cursor:pointer;user-select:none}' + '.empty svg{opacity:.45}' + '.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' + '.empty .sub{font-size:11px}' + '.empty .sub u{text-underline-offset:2px}' + '.empty:hover .sub{opacity:1}' + ':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' + ' background:rgba(201,100,66,.10)}' + '.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed currentColor;' + ' opacity:.35;transition:border-color .12s,opacity .12s}' + ':host([data-over]) .ring{border-color:#c96442;opacity:1}' + ':host([data-filled]) .ring{display:none}' + // Controls overlay INSIDE the frame, pinned to the top-right corner, so // a full-bleed slot in an overflow:hidden container still shows them // (the old below-mask placement got clipped). Credit sits bottom-left, // so top-right avoids collision. The blurred pill background keeps them // legible over the image. // The UA [popover] base rule styles the element in EVERY state (only // display:none is gated on :not(:popover-open), and the display:flex // below overrides that) — so the UA resets live HERE, like .spill's, // or the ordinary hover-state strip renders as a bordered Canvas box // centered by margin:auto. inset:auto precedes top/right (shorthand). '.ctl{position:absolute;inset:auto;top:8px;right:8px;margin:0;border:0;padding:0;' + ' background:transparent;overflow:visible;' + ' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' + ' white-space:nowrap}' + // While reframing, the spill owns the top layer and would swallow every // click on the in-frame controls. Promoting .ctl into the top layer // ABOVE the spill (shown after it — later popovers stack higher) keeps // Edit-as-toggle and Replace clickable mid-reframe. _applyView pins it // to the frame's top-right in viewport px (translateX(-100%) // right-aligns against the computed left edge); inset:auto clears the // base rule's top/right so the inline left/top position it alone. '.ctl:popover-open{position:fixed;inset:auto;transform:translateX(-100%)}' + ':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' + ' {opacity:1;pointer-events:auto}' + '.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' + ' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' + ' backdrop-filter:blur(6px)}' + '.ctl button:hover{background:rgba(0,0,0,.8)}' + '.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' + ' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}' + // Replacement in flight: after a src swap the browser keeps painting // the PREVIOUS image until the new one decodes, so a Replace would // flash the old photo and then pop. Hide the stale frame (visibility, // not display — _applyView geometry still applies) and spin until the // new image reports in (load/error clears data-swapping). ':host([data-swapping]) .frame img{visibility:hidden}' + '.loading{position:absolute;inset:0;display:none;align-items:center;' + ' justify-content:center;pointer-events:none}' + ':host([data-swapping]) .loading{display:flex}' + '.loading::after{content:"";width:22px;height:22px;border-radius:50%;' + ' border:2px solid rgba(127,127,127,.25);border-top-color:currentColor;' + ' animation:om-slot-spin .7s linear infinite}' + '@keyframes om-slot-spin{to{transform:rotate(360deg)}}' + // Reduced motion: the static two-tone ring still reads as "working". '@media (prefers-reduced-motion:reduce){.loading::after{animation:none}}' + '.credit{position:absolute;left:6px;bottom:6px;max-width:calc(100% - 12px);display:none;' + ' padding:3px 7px;border-radius:5px;background:rgba(0,0,0,.55);color:#fff;' + ' font:10px/1.2 system-ui,-apple-system,sans-serif;text-decoration:none;' + ' white-space:nowrap;overflow:hidden;text-overflow:ellipsis;backdrop-filter:blur(6px)}' + // The credit is a SPAN holding one or two <a>s (Unsplash's prescribed // form links the photographer AND Unsplash) — anchors style inline so // the overlay reads as one line of text. '.credit a{color:inherit;text-decoration:none}' + '.credit a:hover,.credit a:focus-visible{text-decoration:underline}' + ':host([data-filled][data-credit]) .credit{display:block}' + // Exports must ship JUST the image — no hover controls, no credit chip // (the host marks <html data-om-exporting> for the capture window; the // page-level hide script can't reach shadow DOM, this rule can). ':host-context([data-om-exporting]) .ctl,' + ':host-context([data-om-exporting]) .credit{display:none !important}' + // Print must ship just the image too: the hover-gated controls can be // mid-hover when print() fires, and the credit chip is screen chrome — // the same rule the capture window gets, keyed on print media instead // of the host's data-om-exporting mark (the print path sets no mark). '@media print{.ctl,.credit{display:none !important}}' + // No export-window mask rules here on purpose: the export capture // releases the replacement mask by REMOVING data-swapping (the // shadow-root pass in pages/export/shared.ts HIDE_EXPORT_CHROME_SCRIPT) // — attribute removal works in every engine (:host-context is // Chromium-only), is scoped by construction to slots actually // mid-swap, and hides the spinner through the same gate. A masked img // would otherwise be silently dropped from PPTX decks (the capture // walk skips visibility:hidden imgs). // Attribution error tile: REPLACES the photo when an Unsplash src has // no credit attribute — rendering the photo uncredited is the terms // violation, so the photo must not appear at all. // Calm and neutral on purpose (review feedback): the tile informs the // user; the fix instructions are machine-facing (usage docblock, tool // description, and the turn-end scan's bounce copy name the attributes // for the agent). '.attr-error{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;' + ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' + ' background:#f2f1ef;color:#6e6c66;user-select:none;' + ' font:13px/1.45 system-ui,-apple-system,sans-serif}' + '.attr-error svg{opacity:.55}' + '.attr-error .cap{max-width:92%;font-weight:500;letter-spacing:.01em}' + ':host([data-attribution-error]) .attr-error{display:flex}' + ':host([data-attribution-error]) .ring{display:none}'; const icon = '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + 'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>' + '<path d="m21 15-5-5L5 21"/></svg>'; const warnIcon = '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + 'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + '<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/>' + '<path d="M12 9v4"/><path d="M12 17h.01"/></svg>'; class ImageSlot extends HTMLElement { static get observedAttributes() { return ['shape', 'radius', 'mask', 'fit', 'placeholder', 'src', 'id', 'credit', 'credit-href']; } /** Duplicate-slide hook (called by deck-stage, see its * _remintDuplicateIds): copy this id's stored image, if any, under a * freshly minted key and return that key — so a duplicated slide's * slot keeps its dropped photo instead of reverting to the * placeholder. 'isFree' is the caller's uniqueness check (document * ids); candidates must ALSO be unused in the sidecar, which can * hold keys from other pages sharing the project root. (An EMPTY * slot on another page leaves no sidecar entry, so its id is not * detectable here — a minted key can collide with it and that slot * would show this photo. Same blast radius as two pages reusing an * id by hand, which the shared sidecar already permits.) Returns null * when no id could be minted (caller strips the id, today's * behavior). */ static cloneSlot(fromId, isFree) { if (typeof fromId !== 'string' || !fromId) return null; // Pre-hydration the store can't veto candidates or source the copy // — degrade to the strip (today's behavior) rather than mint // against keys we can't see yet. Any rendered (= droppable) slot // means load() has already settled. if (!loaded) return null; const stem = fromId.replace(/-\d+$/, '') || fromId; for (let n = 2; n < 100; n++) { const toId = stem + '-' + n; if (toId === fromId) continue; if (slots[toId] !== undefined) { // Reuse a key holding this exact value (bytes AND crop) if no // live element here owns it — a duplicate op the host refused // after minting leaves such a key behind, and reusing keeps // refused retries from accumulating one orphaned copy per // attempt. Full equality (not just bytes) so a byte-identical // key another PAGE owns with its own crop is stepped past, not // adopted or rewritten. (Entries without .u never match.) const prev = getSlot(toId); const cur = getSlot(fromId); if (!(prev && cur && prev.u && prev.u === cur.u && prev.s === cur.s && prev.x === cur.x && prev.y === cur.y && (typeof isFree !== 'function' || isFree(toId)))) continue; return toId; } if (typeof isFree === 'function' && !isFree(toId)) continue; const v = getSlot(fromId); if (v) setSlot(toId, Object.assign({}, v)); return toId; } return null; } constructor() { super(); // clonable: rail thumbnails deep-clone slides and carry this shadow // along; reuse an already-cloned root so upgrade-after-clone works. // (Deliberately NOT serializable — a getHTML consumer would embed // multi-MB sidecar data-URLs into serialized page HTML.) const root = this.shadowRoot || this.attachShadow({ mode: 'open', clonable: true }); // .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius // on the frame (circle, pill, rounded) can't clip them. root.innerHTML = '<style>' + stylesheet + '</style>' + '<div class="frame" part="frame">' + ' <img part="image" alt="" draggable="false" style="display:none">' + ' <div class="empty" part="empty">' + icon + ' <div class="cap"></div>' + ' <div class="sub">or <u>browse files</u></div></div>' + ' <div class="attr-error" part="attribution-error">' + warnIcon + ' <div class="cap">This photo needs attribution</div></div>' + ' <div class="loading" part="loading"></div>' + ' <div class="ring" part="ring"></div>' + '</div>' + // Outside .frame, like .spill/.ctl — the frame's overflow:hidden + // border-radius/clip-path would cut the credit off on circle/pill/mask. // A SPAN, not an <a>: the prescribed Unsplash credit holds two links // (photographer + Unsplash), built per-render in _render(). '<span class="credit" part="credit"></span>' + '<div class="spill" popover="manual" data-dc-edit-transparent>' + ' <img class="ghost" alt="" draggable="false">' + ' <div class="handle" data-c="nw"></div><div class="handle" data-c="ne"></div>' + ' <div class="handle" data-c="sw"></div><div class="handle" data-c="se"></div>' + '</div>' + // data-dc-edit-transparent: the DC editor's edit-mode picker lets // clicks through for chrome marked with it (EDIT_TRANSPARENT_SEL) // — without it, Replace/Edit clicks in Edit mode are swallowed by // element selection and the controls look dead. '<div class="ctl" popover="manual" data-dc-edit-transparent><button data-act="replace" title="Replace image">Replace</button>' + ' <button data-act="edit" title="Reframe image">Edit</button></div>' + '<input type="file" accept="' + ACCEPT.join(',') + '" hidden>'; this._frame = root.querySelector('.frame'); this._ring = root.querySelector('.ring'); this._img = root.querySelector('.frame img'); this._empty = root.querySelector('.empty'); this._cap = root.querySelector('.cap'); this._sub = root.querySelector('.sub'); this._spill = root.querySelector('.spill'); this._ctl = root.querySelector('.ctl'); this._credit = root.querySelector('.credit'); this._attrError = root.querySelector('.attr-error'); // Credit clicks open the link, not browse/reframe. this._credit.addEventListener('click', (e) => e.stopPropagation()); this._credit.addEventListener('dblclick', (e) => e.stopPropagation()); this._ghost = root.querySelector('.ghost'); this._err = null; this._input = root.querySelector('input'); this._depth = 0; this._gen = 0; // Encode-in-flight marker (the owning _ingest generation): while set, // the same-src "nothing in flight" clear in _render must not fire — // the stored value still points at the OLD image until the encode // lands, so that clear would unmask the stale image mid-replace. this._swapGen = 0; // Render-owned swap in flight: set when _render assigns a new src, // cleared only by the img's own load/error (or the empty branch). // img.complete CANNOT stand in for this — setting src only QUEUES // the current-request swap (a microtask), so synchronously after an // assignment, complete still reports the OLD settled request. The // pick path does exactly that: the host sets src, credit, and // credit-href back-to-back in one task, and renders #2/#3 would // read the stale complete === true and drop the mask one render // after it was set. this._loadPending = false; // See _render's empty branch: a transient attribution-error wipe of a // showing image must make the follow-up render a replacement (spinner), // not a first fill (blank frame). this._hidShowing = false; this._view = { s: 1, x: 0, y: 0 }; this._subFn = () => this._render(); // Shadow-DOM listeners live with the shadow DOM — bound once here so // disconnect/reconnect (e.g. React remount) doesn't stack handlers. this._empty.addEventListener('click', () => this._input.click()); root.addEventListener('click', (e) => { const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act'); if (!act) return; // The hidden controls are opacity-0 but still tabbable — without // this gate a keyboard user could drive them on a read-only share // link (mirrors the dblclick handler's editable gate). if (!this.hasAttribute('data-editable')) return; if (act === 'replace') { this._exitReframe(true); // Host-owned picker (Unsplash modal; it also offers local import). this.dispatchEvent(new CustomEvent('image-slot:pick', { bubbles: true, composed: true, detail: { id: this.id || null } })); } if (act === 'edit') { if (!this._reframes()) return; if (this.hasAttribute('data-reframe')) this._exitReframe(true); else this._enterReframe(); } }); this._input.addEventListener('change', () => { const f = this._input.files && this._input.files[0]; if (f) this._ingest(f); this._input.value = ''; }); // naturalWidth/Height aren't known until load — re-apply so the cover // baseline is computed from real dimensions, not the 100%×100% fallback. // load/error also release the replacement-in-flight mask (via the // single discipline in _releaseMask): the swap is only revealed once // the new image can actually paint (on error the frame shows its // background, same as a fresh slot with a broken src). this._img.addEventListener('load', () => { this._loadPending = false; this._releaseMask(true); this._applyView(); }); this._img.addEventListener('error', () => { this._loadPending = false; this._releaseMask(true); }); // Gated only on editable — any filled slot can be repositioned/scaled, // regardless of fit. Share links (no writeFile) stay static. this.addEventListener('dblclick', (e) => { if (!this.hasAttribute('data-editable') || !this._reframes()) return; e.preventDefault(); if (this.hasAttribute('data-reframe')) this._exitReframe(true); else this._enterReframe(); }); // Pan + resize both originate on the spill layer. A handle pointerdown // drives an aspect-locked resize anchored at the opposite corner; any // other pointerdown on the spill pans. Offsets are frame-% so a // reframed slot survives responsive resize / PPTX export. this._spill.addEventListener('pointerdown', (e) => { if (e.button !== 0 || !this.hasAttribute('data-reframe')) return; e.preventDefault(); e.stopPropagation(); this._spill.setPointerCapture(e.pointerId); const rect = this.getBoundingClientRect(); const fw = rect.width || 1, fh = rect.height || 1; const corner = e.target.getAttribute && e.target.getAttribute('data-c'); let move; if (corner) { // Resize about the OPPOSITE corner. Viewport-px throughout (rect // fw/fh, not clientWidth) so the math survives a transform:scale() // ancestor — deck_stage renders slides scaled-to-fit. const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1; const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; const base = contain ? Math.min(fw / iw, fh / ih) : Math.max(fw / iw, fh / ih); const sx = corner.includes('e') ? 1 : -1; const sy = corner.includes('s') ? 1 : -1; const s0 = this._view.s; const w0 = iw * base * s0, h0 = ih * base * s0; const cx0 = (50 + this._view.x) / 100 * fw; const cy0 = (50 + this._view.y) / 100 * fh; const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2; const diag0 = Math.hypot(w0, h0); const ux = sx * w0 / diag0, uy = sy * h0 / diag0; move = (ev) => { const proj = (ev.clientX - rect.left - ox) * ux + (ev.clientY - rect.top - oy) * uy; const s = clampS(s0 * proj / diag0); const d = diag0 * s / s0; this._view.s = s; this._view.x = (ox + ux * d / 2) / fw * 100 - 50; this._view.y = (oy + uy * d / 2) / fh * 100 - 50; this._clampView(); this._applyView(); }; } else { this.setAttribute('data-panning', ''); const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y }; move = (ev) => { this._view.x = start.x + (ev.clientX - start.px) / fw * 100; this._view.y = start.y + (ev.clientY - start.py) / fh * 100; this._clampView(); this._applyView(); }; } const up = () => { try { this._spill.releasePointerCapture(e.pointerId); } catch {} this._spill.removeEventListener('pointermove', move); this._spill.removeEventListener('pointerup', up); this._spill.removeEventListener('pointercancel', up); this.removeAttribute('data-panning'); this._dragUp = null; }; // Stashed so _exitReframe (Escape / outside-click mid-drag) can // tear the capture + listeners down synchronously. this._dragUp = up; this._spill.addEventListener('pointermove', move); this._spill.addEventListener('pointerup', up); this._spill.addEventListener('pointercancel', up); }); // Wheel zoom stays available inside reframe mode as a trackpad nicety — // zooms toward the cursor (offset' = cursor·(1-k) + offset·k). this.addEventListener('wheel', (e) => { if (!this.hasAttribute('data-reframe')) return; e.preventDefault(); const r = this.getBoundingClientRect(); const cx = (e.clientX - r.left) / r.width * 100 - 50; const cy = (e.clientY - r.top) / r.height * 100 - 50; const prev = this._view.s; const next = clampS(prev * Math.pow(1.0015, -e.deltaY)); if (next === prev) return; const k = next / prev; this._view.s = next; this._view.x = cx * (1 - k) + this._view.x * k; this._view.y = cy * (1 - k) + this._view.y * k; this._clampView(); this._applyView(); }, { passive: false }); } connectedCallback() { // Warn once per page — an id-less slot works for the session but // cannot persist, and two id-less slots would share nothing. if (!this.id && !ImageSlot._warned) { ImageSlot._warned = true; console.warn('<image-slot> without an id will not persist its dropped image.'); } this.addEventListener('dragenter', this); this.addEventListener('dragover', this); this.addEventListener('dragleave', this); this.addEventListener('drop', this); subs.add(this._subFn); // The host may inject window.omelette.writeFile AFTER the first render; // re-render on hover so the editable-gated controls reliably appear. this.addEventListener('pointerenter', this._subFn); // width%/height% in _applyView encode the frame aspect at call time — // a host resize (responsive grid, pane divider) would stretch the // image until the next _render. Re-render on size change: _render() // re-seeds _view from stored before clamp/apply, so a shrink→grow // cycle round-trips instead of ratcheting x/y toward the narrower // frame's clamp range. this._ro = new ResizeObserver(() => this._render()); this._ro.observe(this); load(); this._render(); } disconnectedCallback() { subs.delete(this._subFn); this.removeEventListener('pointerenter', this._subFn); this.removeEventListener('dragenter', this); this.removeEventListener('dragover', this); this.removeEventListener('dragleave', this); this.removeEventListener('drop', this); if (this._ro) { this._ro.disconnect(); this._ro = null; } // commit=false: a disconnect is not a user intent — committing here // would persist whatever half-finished drag a React remount or DOM // splice happened to interrupt. Deliberate exits commit on their own // paths (Escape/click-out/toggle), and unloads commit via pagehide. this._exitReframe(false); } _enterReframe() { if (this.hasAttribute('data-reframe')) return; this.setAttribute('data-reframe', ''); this._signalReframe(true); // Best-effort commit when the document unloads mid-reframe (a host // navigation racing the enter signal, a manual reload, tab close): // the sidecar write rides the host bridge, which outlives this // document, so the crop survives even though the mode dies with the // DOM. Held on the instance so _exitReframe detaches exactly what // was attached. this._pagehide = () => { this._exitReframe(true); flushNow(); }; window.addEventListener('pagehide', this._pagehide); // Promote spill to the top layer, then keep it pinned over the frame: // scroll/resize cover the common cases, and a per-frame rect check // catches layout shifts that fire neither (an image above finishing // load, streamed DOM pushing the slot down, an ancestor transform // change) so the overlay can't detach from the frame. try { this._spill.showPopover(); } catch {} // After the spill, so the controls stack above it in the top layer. try { this._ctl.showPopover(); } catch {} this._reposition = () => { if (this.hasAttribute('data-reframe')) this._applyView(); }; window.addEventListener('scroll', this._reposition, true); window.addEventListener('resize', this._reposition); this._lastRect = ''; this._watch = () => { if (!this.hasAttribute('data-reframe')) return; const r = this.getBoundingClientRect(); const key = r.left + ',' + r.top + ',' + r.width + ',' + r.height; if (key !== this._lastRect) { this._lastRect = key; this._applyView(); } this._watchId = requestAnimationFrame(this._watch); }; this._watchId = requestAnimationFrame(this._watch); this._applyView(); // Close on click outside (the spill handler stopPropagation()s so // in-image drags don't reach this) and on Escape. Listeners are held // on the instance so _exitReframe / disconnectedCallback can detach // exactly what was attached. this._outside = (e) => { if (e.composedPath && e.composedPath().includes(this)) return; this._exitReframe(true); }; this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); }; document.addEventListener('pointerdown', this._outside, true); document.addEventListener('keydown', this._esc, true); } _exitReframe(commit) { if (!this.hasAttribute('data-reframe')) return; if (this._dragUp) this._dragUp(); this.removeAttribute('data-reframe'); this.removeAttribute('data-panning'); if (this._outside) document.removeEventListener('pointerdown', this._outside, true); if (this._esc) document.removeEventListener('keydown', this._esc, true); this._outside = this._esc = null; if (this._reposition) { window.removeEventListener('scroll', this._reposition, true); window.removeEventListener('resize', this._reposition); this._reposition = null; } if (this._watchId) { cancelAnimationFrame(this._watchId); this._watchId = 0; } if (this._pagehide) { window.removeEventListener('pagehide', this._pagehide); this._pagehide = null; } try { this._spill.hidePopover(); } catch {} try { this._ctl.hidePopover(); } catch {} this._ctl.style.left = ''; this._ctl.style.top = ''; if (commit) this._commitView(); this._signalReframe(false); } // Reframe state lives only in this DOM until commit, invisible to the // host's dirty signals — announce enter/exit so the host can hold // auto-reloads for exactly the gesture (the guest bundle forwards // image-slot:reframe to the host as imageSlotReframe). Dispatched on // the element (composed, so it escapes shadow roots) while connected; // a disconnected exit (disconnectedCallback) falls back to document so // the host still hears it. _signalReframe(active) { const target = this.isConnected ? this : document; target.dispatchEvent(new CustomEvent('image-slot:reframe', { bubbles: true, composed: true, detail: { active: active, id: this.id || null } })); } // Public: host's "Import from computer" calls this to run local browse. openFilePicker() { this._exitReframe(true); this._input.click(); } // A src write is a newer intent for this slot's content — the host // pick path (setImageSlotImage) or an agent edit — so it must win // over any encode still in flight from an earlier drop: left live, // that encode lands later, passes _ingest's gen guard, and its // setSlot silently overwrites the pick (the stored value shadows // src in _render). Bumping _gen kills the encode before its own // _swapGen clear runs, so clear the dead claim here too — otherwise // _releaseMask (gated on !_swapGen) never fires and the pick's // spinner is stranded. src ONLY: the pick sets credit/credit-href // in the same task, and clearing _swapGen on those would let the // same-src branch unmask the old image mid-encode. attributeChangedCallback(name, oldVal, newVal) { if (name === 'src' && oldVal !== newVal) { this._gen++; this._swapGen = 0; } if (this.shadowRoot) this._render(); } // handleEvent — one listener object for all four drag events keeps the // add/remove symmetric and the depth counter correct. handleEvent(e) { if (e.type === 'dragenter' || e.type === 'dragover') { // Without preventDefault the browser never fires 'drop'. e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; if (e.type === 'dragenter') this._depth++; this.setAttribute('data-over', ''); } else if (e.type === 'dragleave') { // dragenter/leave fire for every descendant crossing — count depth // so hovering the icon inside the empty state doesn't flicker. if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); } } else if (e.type === 'drop') { e.preventDefault(); e.stopPropagation(); this._depth = 0; this.removeAttribute('data-over'); const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) this._ingest(f); } } async _ingest(file) { this._setError(null); if (!file || ACCEPT.indexOf(file.type) < 0) { this._setError('Drop a PNG, JPEG, WebP, or AVIF image.'); return; } // toDataUrl can take hundreds of ms on a large photo. A Clear or a // newer drop during that window would be clobbered when this await // resumes — bump + capture a generation so stale encodes bail. const gen = ++this._gen; // Replacing a shown image: surface the swap through the encode too, // not just the decode — otherwise the old photo sits there with no // feedback while the canvas re-encode runs. An empty slot keeps its // placeholder (no spinner) until the encode lands, as before. // _swapGen guards the mask against re-renders DURING the encode // (pointerenter, ResizeObserver, another slot's store write): the // stored value still resolves to the old image there, so _render's // same-src clear would otherwise unmask it mid-replace. if (this.hasAttribute('data-filled')) { this.setAttribute('data-swapping', ''); this._swapGen = gen; } try { const w = this.clientWidth || this.offsetWidth || MAX_DIM; const url = await toDataUrl(file, w); if (gen !== this._gen) return; // Only exit reframe once the new image is in hand — a rejected type // or decode failure leaves the in-progress crop untouched. this._exitReframe(false); // Clear BEFORE setSlot: its synchronous re-render must see no // pending encode, so a byte-identical re-upload (same data URL, no // load event coming) still clears the mask via the complete branch. this._swapGen = 0; const val = { u: url, s: 1, x: 0, y: 0 }; setSlot(this.id || '', val); // Keep a session-local copy for id-less slots so the drop still // shows, even though it cannot persist. if (!this.id) { this._local = val; this._render(); } } catch (err) { if (gen !== this._gen) return; this._swapGen = 0; // Reveal the kept old image — unless another replacement (a // remote pick's src swap) is still in flight, in which case the // mask stays until THAT image settles (its load/error releases). this._releaseMask(); this._setError('Could not read that image.'); console.warn('<image-slot> ingest failed:', err); } } _setError(msg) { if (this._err) { this._err.remove(); this._err = null; } if (!msg) return; const d = document.createElement('div'); d.className = 'err'; d.textContent = msg; this.shadowRoot.appendChild(d); this._err = d; setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000); } // Reframing (pan/resize) is available on any filled slot — the user can // always reposition/scale. `fit` only sets the initial baseline (see // _geom): contain starts fully-visible, cover starts frame-filling. _reframes() { return this.hasAttribute('data-filled'); } // The single release discipline for the replacement-in-flight mask // (data-swapping). The mask comes off only when BOTH hold: // - no encode is pending (_swapGen) — mid-encode the stored value // still resolves to the old image, so any reveal paints it; // - the frame img has settled on its current src — an unsettled src // means some replacement is still in flight (e.g. a remote pick), // whoever started it, and revealing would paint the previous // frame. The load/error listeners pass settled=true (the event IS // the settlement signal, per spec complete is true by then); // other callers rely on the complete flag (covers loaded AND // failed). // Every release path funnels through here EXCEPT _render's empty // branch (the img is being cleared — nothing will ever settle). _releaseMask(settled) { if ( !this._swapGen && !this._loadPending && (settled || this._img.complete) ) { this.removeAttribute('data-swapping'); } } // Baseline geometry, shared by clamp/apply/resize. `base` is the scale at // view-scale s=1: cover = fill the frame (overflow on the looser axis), // contain = fit fully inside (letterboxed). Zooming a contain image past // s where it overflows naturally becomes a crop. Null until the img has // loaded (naturalWidth is 0 before that) or when the slot has no layout // box — ResizeObserver fires with a 0×0 rect under display:none, and // clamping against a degenerate 1×1 frame would silently pull the stored // pan toward zero. _geom() { const iw = this._img.naturalWidth, ih = this._img.naturalHeight; const fw = this.clientWidth, fh = this.clientHeight; if (!iw || !ih || !fw || !fh) return null; const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; const base = contain ? Math.min(fw / iw, fh / ih) : Math.max(fw / iw, fh / ih); return { iw, ih, fw, fh, base }; } _clampView() { // Pan range on each axis is half the overflow past the frame edge. const g = this._geom(); if (!g) return; const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50); const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50); this._view.x = Math.max(-mx, Math.min(mx, this._view.x)); this._view.y = Math.max(-my, Math.min(my, this._view.y)); } _applyView() { const g = this._geom(); // Top-layer controls: pin to the frame's top-right in viewport px // (the same 8px inset as the in-frame layout; unscaled — top-layer UI // reads as chrome, not page content). BEFORE the geometry branch: // placement needs only the frame rect, and a not-yet-loaded or broken // src must not leave the promoted strip floating unpositioned. Gated // on the popover actually being open: without the Popover API, // showPopover() threw (swallowed in _enterReframe), .ctl stays in // its in-frame absolute layout, and viewport-px coordinates would // shove it off-frame — and matches(':popover-open') itself throws // there (unknown pseudo-class), hence the try/catch. if (this.hasAttribute('data-reframe')) { let onTop = false; try { onTop = this._ctl.matches(':popover-open'); } catch {} if (onTop) { const r = this.getBoundingClientRect(); this._ctl.style.left = (r.right - 8) + 'px'; this._ctl.style.top = (r.top + 8) + 'px'; } } if (!g) { // Dimensions not known yet (before img load) — centered fit so there // is no flash of an unpositioned image before the geometry lands. const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; this._img.style.width = '100%'; this._img.style.height = '100%'; this._img.style.left = '50%'; this._img.style.top = '50%'; this._img.style.objectFit = contain ? 'contain' : 'cover'; return; } // Baseline (cover-fill or contain-fit) × view scale. Width/height and // left/top are all frame-% — depends only on the frame aspect ratio, so // a responsive resize keeps the same crop. The spill layer mirrors the // same box so its corners = image corners. const k = g.base * this._view.s; const w = (g.iw * k / g.fw * 100) + '%'; const h = (g.ih * k / g.fh * 100) + '%'; const l = (50 + this._view.x) + '%'; const t = (50 + this._view.y) + '%'; this._img.style.width = w; this._img.style.height = h; this._img.style.left = l; this._img.style.top = t; this._img.style.objectFit = ''; if (this.hasAttribute('data-reframe')) { // Top-layer spill: position in viewport px over the frame. The top // layer escapes ancestor transforms entirely, so EVERY term must be // in viewport units: getBoundingClientRect gives the frame's scaled // origin AND size, and the rect/layout ratio rescales the ghost — // sizing from layout px alone renders it 1/scale too large under a // scaled deck slide. Inner ghost + handles stay box-relative. const r = this.getBoundingClientRect(); const sx = g.fw ? r.width / g.fw : 1; const sy = g.fh ? r.height / g.fh : 1; this._spill.style.width = (g.iw * k * sx) + 'px'; this._spill.style.height = (g.ih * k * sy) + 'px'; this._spill.style.left = (r.left + (50 + this._view.x) / 100 * r.width) + 'px'; this._spill.style.top = (r.top + (50 + this._view.y) / 100 * r.height) + 'px'; } } _commitView() { const v = { s: this._view.s, x: this._view.x, y: this._view.y }; if (this._userUrl) v.u = this._userUrl; // Framing-only (no u) persists too so an author-src slot remembers its // crop; clearing the sidecar still falls through to src=. if (this.id) setSlot(this.id, v); else { this._local = v; } } _render() { // Shape / mask. Presets use border-radius so the dashed ring can // follow the rounded outline; clip-path is only applied for an // explicit `mask` (the ring is hidden there since a rectangle // dashed border chopped by an arbitrary polygon looks broken). const mask = this.getAttribute('mask'); const shape = (this.getAttribute('shape') || 'rounded').toLowerCase(); let radius = ''; if (shape === 'circle') radius = '50%'; else if (shape === 'pill') radius = '9999px'; else if (shape === 'rounded') { const n = parseFloat(this.getAttribute('radius')); radius = (Number.isFinite(n) ? n : 12) + 'px'; } this._frame.style.borderRadius = mask ? '' : radius; this._frame.style.clipPath = mask || ''; this._ring.style.borderRadius = mask ? '' : radius; this._ring.style.display = mask ? 'none' : ''; // Controls and reframe entry gate on this so share links stay read-only. const editable = !!(window.omelette && window.omelette.writeFile); this.toggleAttribute('data-editable', editable); this._sub.style.display = editable ? '' : 'none'; // Content. The sidecar is also writable by the agent's write_file // tool, so its value isn't guaranteed canvas-originated — only accept // data:image/ URLs from it. The `src` attribute is author-controlled // (Claude wrote it into the HTML) so it passes through unchanged. let stored = this.id ? getSlot(this.id) : this._local; if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null; const srcAttr = this.getAttribute('src') || ''; this._userUrl = (stored && stored.u) || null; const url = this._userUrl || srcAttr; // Don't clobber an in-flight reframe with a store-triggered re-render. if (!this.hasAttribute('data-reframe')) { this._view = { s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1, x: stored && Number.isFinite(stored.x) ? stored.x : 0, y: stored && Number.isFinite(stored.y) ? stored.y : 0, }; } this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image'; // Toggle via style.display — the [hidden] attribute alone loses to // the display:flex / display:block rules in the stylesheet above. // An Unsplash src with no credit attribute must NOT render — showing // the photo uncredited is the Unsplash-terms violation itself. The // error tile replaces the photo until the credit is written. A // user-dropped image is the user's own content and always renders. // Trimmed: credit is agent/user-editable content, and a whitespace- // only value must count as missing — otherwise it would suppress the // error tile AND render an empty credit box (no text, no links), // exactly the unattributed state this gate exists to prevent. const credit = (this.getAttribute('credit') || '').trim(); const attrError = !!( !credit && !this._userUrl && srcAttr && isUnsplashHost(srcAttr) ); this.toggleAttribute('data-attribution-error', attrError); if (url && !attrError) { const prev = this._img.getAttribute('src'); if (prev !== url) { // Replacing an already-shown image: mark the swap BEFORE setting // src so the stale frame is never revealed (see the data-swapping // stylesheet rules). First fill (prev empty) keeps the existing // placeholder-until-load behavior — no spinner. _hidShowing // covers the pick path's transient attribution-error wipe: prev // is gone, but an image WAS showing, so this is a replacement. if (prev || this._hidShowing) this.setAttribute('data-swapping', ''); // Mark the swap BEFORE assigning src: complete keeps reporting // the old settled request until the browser's // update-the-image-data microtask runs, so same-task re-renders // (the pick path's credit/credit-href setAttributes) need this // flag, not complete, to know a load is in flight. this._loadPending = true; this._img.src = url; this._ghost.src = url; } else { // Same-src re-render — release if settled, so an ingest-set // spinner can't stick after a byte-identical re-upload (same // data URL, no further load event ever fires). this._releaseMask(); } this._hidShowing = false; this._img.style.display = 'block'; this._empty.style.display = 'none'; this.setAttribute('data-filled', ''); this._clampView(); this._applyView(); } else { this.removeAttribute('data-swapping'); // The src is being removed — no load/error will ever fire for it. this._loadPending = false; // A transient attribution-error wipe of a showing image happens on // the pick path: the host sets src one setAttribute before credit, // so render N hides the old image (attrError) and render N+1 // restores a URL. Remember the wipe so that restore renders as a // replacement (spinner), not a first fill (blank frame). this._hidShowing = attrError && !!this._img.getAttribute('src'); this._img.style.display = 'none'; this._img.removeAttribute('src'); this._ghost.removeAttribute('src'); // The error tile owns the blocked-photo state; .empty stays for // the genuinely-empty slot. this._empty.style.display = attrError ? 'none' : 'flex'; this.removeAttribute('data-filled'); } // Credit belongs to the author src, so a user drop hides it. // textContent + the http(s)-only funnel keep external strings inert. const showCredit = !!(url && credit && !this._userUrl && !attrError); this._credit.textContent = ''; if (showCredit) { // Validate once (resolved against the document, http(s) only), // then append the terms-required utm referral params to links // that point back at unsplash.com. let href = ''; const rawHref = this.getAttribute('credit-href') || ''; if (rawHref) { try { const u = new URL(rawHref, document.baseURI); if (u.protocol === 'http:' || u.protocol === 'https:') { href = withReferral(u.href); } } catch {} } const mkLink = (text, linkHref) => { const a = document.createElement('a'); a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener noreferrer'); a.setAttribute('href', linkHref); a.textContent = text; return a; }; // Unsplash's prescribed credit is TWO links — the photographer's // name to their profile (credit-href) and 'Unsplash' to the // homepage. Render that split whenever the text has the canonical // shape; other text keeps the legacy single-link rendering. const m = /^Photo by (.+) on Unsplash$/.exec(credit); if (m) { this._credit.appendChild(document.createTextNode('Photo by ')); this._credit.appendChild( href ? mkLink(m[1], href) : document.createTextNode(m[1]) ); this._credit.appendChild(document.createTextNode(' on ')); this._credit.appendChild(mkLink('Unsplash', UNSPLASH_HOMEPAGE_HREF)); } else if (href) { this._credit.appendChild(mkLink(credit, href)); } else { this._credit.textContent = credit; } } this.toggleAttribute('data-credit', showCredit); } } if (!customElements.get('image-slot')) { customElements.define('image-slot', ImageSlot); } })(); </script> </helmet> <div style="font-family:'Outfit',sans-serif;letter-spacing:-0.02em;color:#2A2B43;background:#FFFFFF;overflow-x:hidden;"> <!-- Top bar --> <div class="prl-topbar" style="display:flex;align-items:center;justify-content:space-between;max-width:1160px;margin:0 auto;flex-wrap:wrap;gap:10px;"> <a href="https://fisify.com/demo" target="_blank" rel="noopener" style="display:flex;align-items:center;"> <img src="https://res.cloudinary.com/dpzdvpitf/image/upload/v1785398763/fisify-logo_bsn9ri.png" alt="Fisify" style="height:36px;width:auto;display:block;"> </a> <div style="display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;color:#3F3C63;background:#F4FFF2;border:1px solid #AFD7AE;border-radius:999px;padding:7px 14px;"> <span style="width:7px;height:7px;border-radius:50%;background:#5aa564;"></span> Serie PRL 2026 · completada </div> </div> <!-- PORTADA --> <section class="prl-hero-grid" style="max-width: 1160px; margin: 0 auto; padding: 20px 24px 10px; display: grid; gap: 32px; align-items: center; padding-bottom: 100px"> <div> <h1 class="prl-h1-portada" style="font-family:'Philosopher',serif;line-height:1.12;font-weight:700;margin:0 0 18px;letter-spacing:-0.01em;color:#2A2B43;">Toda la reforma PRL 2026, <i style="font-weight: normal;">en un sitio.</i></h1> <p style="font-size:18px;line-height:1.55;font-weight:300;color:#3F3C63;margin:0 0 28px;max-width:480px;">Los 6 vídeos de la serie, y el autodiagnóstico para ponerle números a tu empresa. Empieza por el diagnóstico: 3 minutos para saber qué mover primero.</p> <div style="display:flex;flex-wrap:wrap;gap:14px;"> <a href="#prl-form" style="display: inline-flex; align-items: center; gap: 10px; color: #fff; font-weight: 600; font-size: 16px; padding: 15px 26px; border-radius: 999px; box-shadow: 0px 10px 24px 0px #8B75EE12; background-color: var(--fis-black)">Hacer el autodiagnóstico</a> <a href="#campana" style="display:inline-flex;align-items:center;gap:8px;background:#F7F6FA;color:#2A2B43;font-weight:600;font-size:16px;padding:15px 26px;border-radius:999px;">Ver la serie&nbsp;</a> </div> </div> <div style="position:relative;"> <div class="prl-hero-img" style="width:100%;"> <img src="https://res.cloudinary.com/dpzdvpitf/image/upload/v1785398643/portada-personas_fkipwo.png" alt="Personas sonriendo en un ámbito laboral" style="width:100%;height:100%;object-fit:cover;border-radius:24px;display:block;"> </div> <div style="position: absolute; bottom: -24px; left: -24px; border-radius: 20px; padding: 18px 22px; box-shadow: 0 16px 36px rgba(42,43,67,0.16); background-color: var(--fis-white)"> <div style="font-size: 32px; font-weight: 700; letter-spacing: -0.02em; color: var(--fis-black); line-height: 1; font-family: ph">−31%</div> <div style="font-size:13px;font-weight:500;color:#3F3C63;line-height:1.35;margin-top:6px;">bajas musculoesqueléticas<br>con intervención activa</div> </div> </div> </section> <!-- HERO / AUTODIAGNÓSTICO --> <section id="prl-form" style="position:relative;max-width:1160px;margin:0 auto;padding:34px 24px 60px;"> <div style="position: relative; overflow: hidden; border-radius: 32px; background: url(&quot;./fisify_super_realistic_photograph_of_an_-ms32uo1f-s0og.png&quot;) center / cover no-repeat; border: 1px solid #EDECF2"> <div style="position:absolute;top:-90px;right:-70px;width:340px;height:340px;border-radius:50%;background:radial-gradient(circle at 30% 30%,#C6B5F4,#FE93A7);filter:blur(20px);opacity:0.55;animation:floaty 7s ease-in-out infinite;"></div> <div style="position:absolute;bottom:-120px;left:-60px;width:280px;height:280px;border-radius:50%;background:radial-gradient(circle at 40% 40%,#BFD0FF,#A793FF);filter:blur(24px);opacity:0.45;"></div> <div class="prl-diag-grid" style="position: relative; display: grid; gap: 40px; align-items: center; background: linear-gradient(180deg, #EEE9F8, #EBD3E9)"> <div> <div style="display: inline-block; font-size: 13px; font-weight: 600; color: var(--fis-black); background: #fff; border-radius: 999px; padding: 7px 15px; box-shadow: 0 2px 6px rgba(42,43,67,0.06); margin-bottom: 22px">Empieza por aquí</div> <h1 class="prl-h1-diag" style="font-family: 'Philosopher',serif; line-height: 1.08; font-weight: 700; margin: 0 0 18px; letter-spacing: -0.01em; color: var(--fis-black)">Descubre en 3&nbsp;minutos el <span style="-webkit-background-clip: text; background-clip: text; color: transparent; background-color: var(--fis-black)"><i style="color: var(--fis-black)"><span style="font-weight: normal;">riesgo musculoesquelético</span></i></span> real de tu plantilla</h1> <p style="font-size: 19px; line-height: 1.55; font-weight: 300; color: var(--fis-black); margin: 0 0 30px; max-width: 520px">El autodiagnóstico PRL de Fisify te da una foto honesta de dónde estás en prevención de trastornos musculoesqueléticos y qué mover primero. Sin registro previo.</p> <div style="display:flex;flex-wrap:wrap;gap:14px;align-items:center;"> <span style="font-size: 14px; color: var(--fis-black); font-weight: 400">⏱ ~3 min · gratis · resultado inmediato</span> </div> </div> <div style="display:flex;justify-content:center;width:100%;"> <div class="prl-diag-form-wrap" style="max-width: 420px; background: #fff; border-radius: 24px; padding: 8px; box-shadow: 0 20px 48px rgba(42,43,67,0.12);"> <iframe src="https://share-eu1.hsforms.com/2ldgkRwvWTgO91lAWCMApGAf6mpp" title="Autodiagnóstico PRL" style="width:100%;border:0;border-radius:18px;" loading="lazy"></iframe> </div> </div> </div> </div> </section> <!-- CAMPAÑA --> <section id="campana" style="max-width:1160px;margin:0 auto;padding:20px 24px 40px;"> <div style="text-align:center;max-width:680px;margin:0 auto 44px;"> <div style="display: inline-block; font-size: 13px; font-weight: 600; color: var(--fis-white); border-radius: 999px; padding: 7px 15px; margin-bottom: 18px; background-color: var(--fis-black)">La serie completa · 6 episodios</div> <h2 class="prl-h2-campana" style="font-family:'Philosopher',serif;line-height:1.1;font-weight:700;margin:0 0 14px;letter-spacing:-0.01em;">Todo lo que contamos sobre<br><i style="font-weight: normal;">&nbsp;la reforma PRL 2026</i></h2> <p style="font-size:18px;line-height:1.55;font-weight:300;color:#3F3C63;margin:0;">Seis vídeos cortos, directos y sin humo sobre prevención de riesgos y salud musculoesquelética. Míralos en el orden que quieras.</p> </div> <div class="prl-episodes-grid" style="display:grid;gap:26px;"> <div style="display:flex;flex-direction:column;background:#fff;border:1px solid #EDECF2;border-radius:22px;overflow:hidden;box-shadow:0 8px 24px rgba(42,43,67,0.06);"> <div style="position:relative;aspect-ratio:9/16;background:#000;"> <iframe src="https://www.youtube-nocookie.com/embed/nnKGV00sx2Y?rel=0&amp;playsinline=1" title="Introducción · Por qué esta serie" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="width:100%;height:100%;border:0;" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share" allowfullscreen=""></iframe> </div> <div style="padding:20px;display:flex;flex-direction:column;gap:8px;flex:1;"> <h3 style="font-family:'Philosopher',serif;font-size:18px;font-weight:700;line-height:1.28;margin:0;letter-spacing:0;">Introducción · Por qué esta serie</h3> <p style="font-size:14px;font-weight:300;line-height:1.45;color:#3F3C63;margin:0;flex:1;">El espejismo del papel y el coste real del absentismo.</p> <a href="https://youtube.com/shorts/nnKGV00sx2Y" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 600; margin-top: 6px; color: var(--fis-black)">Ver el reel <span style="font-size:16px;line-height:0;">→</span></a> </div> </div> <div style="display:flex;flex-direction:column;background:#fff;border:1px solid #EDECF2;border-radius:22px;overflow:hidden;box-shadow:0 8px 24px rgba(42,43,67,0.06);"> <div style="position:relative;aspect-ratio:9/16;background:#000;"> <iframe src="https://www.youtube-nocookie.com/embed/slns0R36Odk?rel=0&amp;playsinline=1" title="La reforma de 2026 · Lo que cambia (y lo que no)" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="width:100%;height:100%;border:0;" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share" allowfullscreen=""></iframe> </div> <div style="padding:20px;display:flex;flex-direction:column;gap:8px;flex:1;"> <h3 style="font-family:'Philosopher',serif;font-size:18px;font-weight:700;line-height:1.28;margin:0;letter-spacing:0;">La reforma de 2026 · Lo que cambia (y lo que no)</h3> <p style="font-size:14px;font-weight:300;line-height:1.45;color:#3F3C63;margin:0;flex:1;">Qué hacer hoy con el anteproyecto recién aprobado: salud mental, ergonomía y clima.</p> <a href="https://youtube.com/shorts/slns0R36Odk" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 600; margin-top: 6px; color: var(--fis-black)">Ver el reel <span style="font-size:16px;line-height:0;">→</span></a> </div> </div> <div style="display:flex;flex-direction:column;background:#fff;border:1px solid #EDECF2;border-radius:22px;overflow:hidden;box-shadow:0 8px 24px rgba(42,43,67,0.06);"> <div style="position:relative;aspect-ratio:9/16;background:#000;"> <iframe src="https://www.youtube-nocookie.com/embed/nkpS5HwXtyc?rel=0&amp;playsinline=1" title="Las pausas activas que la ley NO menciona" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="width:100%;height:100%;border:0;" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share" allowfullscreen=""></iframe> </div> <div style="padding:20px;display:flex;flex-direction:column;gap:8px;flex:1;"> <h3 style="font-family:'Philosopher',serif;font-size:18px;font-weight:700;line-height:1.28;margin:0;letter-spacing:0;">Las pausas activas que la ley NO menciona</h3> <p style="font-size:14px;font-weight:300;line-height:1.45;color:#3F3C63;margin:0;flex:1;">Lo que sí funciona cuando el descanso reglamentario no basta.</p> <a href="https://youtube.com/shorts/nkpS5HwXtyc" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 600; margin-top: 6px; color: var(--fis-black)">Ver el reel <span style="font-size:16px;line-height:0;">→</span></a> </div> </div> <div style="display:flex;flex-direction:column;background:#fff;border:1px solid #EDECF2;border-radius:22px;overflow:hidden;box-shadow:0 8px 24px rgba(42,43,67,0.06);"> <div style="position:relative;aspect-ratio:9/16;background:#000;"> <iframe src="https://www.youtube-nocookie.com/embed/zC44IC3rKYw?rel=0&amp;playsinline=1" title="Adaptar el trabajo a la persona (sin presupuesto)" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="width:100%;height:100%;border:0;" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share" allowfullscreen=""></iframe> </div> <div style="padding:20px;display:flex;flex-direction:column;gap:8px;flex:1;"> <h3 style="font-family:'Philosopher',serif;font-size:18px;font-weight:700;line-height:1.28;margin:0;letter-spacing:0;">Adaptar el trabajo a la persona (sin presupuesto)</h3> <p style="font-size:14px;font-weight:300;line-height:1.45;color:#3F3C63;margin:0;flex:1;">Ajustes reales que no requieren comprar nada nuevo.</p> <a href="https://youtube.com/shorts/zC44IC3rKYw" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 600; margin-top: 6px; color: var(--fis-black)">Ver el reel <span style="font-size:16px;line-height:0;">→</span></a> </div> </div> <div style="display:flex;flex-direction:column;background:#fff;border:1px solid #EDECF2;border-radius:22px;overflow:hidden;box-shadow:0 8px 24px rgba(42,43,67,0.06);"> <div style="position:relative;aspect-ratio:9/16;background:#000;"> <iframe src="https://www.youtube-nocookie.com/embed/FE33VC5gRpg?rel=0&amp;playsinline=1" title="El coste REAL de un dolor de espalda" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="width:100%;height:100%;border:0;" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share" allowfullscreen=""></iframe> </div> <div style="padding:20px;display:flex;flex-direction:column;gap:8px;flex:1;"> <h3 style="font-family:'Philosopher',serif;font-size:18px;font-weight:700;line-height:1.28;margin:0;letter-spacing:0;">El coste REAL de un dolor de espalda</h3> <p style="font-size:14px;font-weight:300;line-height:1.45;color:#3F3C63;margin:0;flex:1;">Lo que sí sale en la cuenta y nadie calcula.</p> <a href="https://youtube.com/shorts/FE33VC5gRpg" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 600; margin-top: 6px; color: var(--fis-black)">Ver el reel <span style="font-size:16px;line-height:0;">→</span></a> </div> </div> <div style="display:flex;flex-direction:column;background:#fff;border:1px solid #EDECF2;border-radius:22px;overflow:hidden;box-shadow:0 8px 24px rgba(42,43,67,0.06);"> <div style="position:relative;aspect-ratio:9/16;background:#000;"> <iframe src="https://www.youtube-nocookie.com/embed/R2nmPZFKde8?rel=0&amp;playsinline=1" title="Cuando el dolor «no es del trabajo»" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="width:100%;height:100%;border:0;" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share" allowfullscreen=""></iframe> </div> <div style="padding:20px;display:flex;flex-direction:column;gap:8px;flex:1;"> <h3 style="font-family:'Philosopher',serif;font-size:18px;font-weight:700;line-height:1.28;margin:0;letter-spacing:0;">Cuando el dolor «no es del trabajo»</h3> <p style="font-size:14px;font-weight:300;line-height:1.45;color:#3F3C63;margin:0;flex:1;">El límite difuso entre contingencia común y profesional.</p> <a href="https://youtube.com/shorts/R2nmPZFKde8" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 600; margin-top: 6px; color: var(--fis-black)">Ver el reel <span style="font-size:16px;line-height:0;">→</span></a> </div> </div> </div> </section> <!-- NEWSLETTER --> <section style="max-width:1160px;margin:0 auto;padding:40px 24px 80px;"> <div style="position:relative;overflow:hidden;border-radius:32px;background:linear-gradient(135deg,#2A2B43,#241f38);padding:64px 40px;text-align:center;color:#fff;"> <div style="position:absolute;top:-80px;left:-40px;width:260px;height:260px;border-radius:50%;background:radial-gradient(circle,#8B75EE,transparent 70%);opacity:0.5;"></div> <div style="position:absolute;bottom:-90px;right:-30px;width:260px;height:260px;border-radius:50%;background:radial-gradient(circle,#FE93A7,transparent 70%);opacity:0.35;"></div> <div class="prl-newsletter-grid" style="position:relative;display:grid;gap:36px;align-items:center;text-align:left;"> <div> <h2 class="prl-h2-news" style="font-weight:700;margin:0 0 16px;letter-spacing:-0.01em;font-family:'Philosopher',serif;color:#fff;">¿Te ha gustado esta serie? <i style="color: var(--fis-white); font-weight: 400"><br>¡Pues todavía tenemos más!</i></h2> <p style="font-size:17px;font-weight:300;color:rgba(255,255,255,0.72);margin:0;">Suscríbete a nuestra newsletter y no te pierdas todas las novedades que os vamos a traer.</p> </div> <div style="background:#fff;border-radius:20px;overflow:hidden;"> <iframe src="https://f6mpp.share-eu1.hsforms.com/20z_0fSPOS0ucnMOWVvOiRQ" title="Newsletter PRL" style="width:100%;height:420px;border:0;display:block;" loading="lazy"></iframe> </div> </div> </div> <div style="text-align:center;font-size:13px;color:#9490a8;margin-top:28px;font-weight:400;">Fisify · Tu fisioterapeuta 24/7 · Diseñando la nueva era de la fisioterapia</div> </section> </div> </x-dc> <script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1160,&quot;height&quot;:2200},&quot;videoLinks&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Vídeos&quot;}}"> class Component extends DCLogic { state = { playingEp: null }; renderVals() { const posters = [ 'linear-gradient(150deg,#C6B5F4,#FE93A7)', 'linear-gradient(150deg,#BFD0FF,#A793FF)', 'linear-gradient(150deg,#8B75EE,#C6B5F4)', 'linear-gradient(150deg,#FBB9A0,#FE93A7)', 'linear-gradient(150deg,#A793FF,#FE93A7)', 'linear-gradient(150deg,#3F3C63,#8B75EE)' ]; const raw = [ { num: '00', title: 'Introducción · Por qué esta serie', subtitle: 'El espejismo del papel y el coste real del absentismo.', date: '6 may' }, { num: '01', title: 'La reforma de 2026 · Lo que cambia (y lo que no)', subtitle: 'Qué hacer hoy con el anteproyecto recién aprobado: salud mental, ergonomía y clima.', date: '21 may' }, { num: '02', title: 'Las pausas activas que la ley NO menciona', subtitle: 'Lo que sí funciona cuando el descanso reglamentario no basta.', date: '2 jun' }, { num: '03', title: 'Adaptar el trabajo a la persona (sin presupuesto)', subtitle: 'Ajustes reales que no requieren comprar nada nuevo.', date: '18 jun' }, { num: '04', title: 'El coste REAL de un dolor de espalda', subtitle: 'Lo que sí sale en la cuenta y nadie calcula.', date: '30 jun' }, { num: '05', title: 'Cuando el dolor «no es del trabajo»', subtitle: 'El límite difuso entre contingencia común y profesional.', date: '' } ]; const links = (this.props.videoLinks || '').split('\n').map(s => s.trim()).filter(Boolean); const shortsEmbeds = { '00': 'nnKGV00sx2Y', '01': 'slns0R36Odk', '02': 'nkpS5HwXtyc', '03': 'zC44IC3rKYw', '04': 'FE33VC5gRpg', '05': 'R2nmPZFKde8' }; const episodes = raw.map((e, i) => { const embedId = shortsEmbeds[e.num]; return { ...e, date: e.date || 'Serie', poster: posters[i], url: embedId ? 'https://youtube.com/shorts/' + embedId : (links[i] || '#'), embedId: !!embedId, noEmbed: !embedId, embedSrc: embedId ? 'https://www.youtube-nocookie.com/embed/' + embedId + '?rel=0&playsinline=1' : '' }; }); return { episodes, checklist: [ 'Evaluación de riesgos musculoesqueléticos al día', 'Pausas activas implantadas de verdad', 'Puestos adaptados a cada persona', 'Datos de absentismo por dolor medidos' ] }; } } </script> </body> </html>