@scenar/embed 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/embed.global.js CHANGED
@@ -1 +1 @@
1
- var ScenarEmbed=function(o){"use strict";var I=Object.defineProperty;var N=(o,u,d)=>u in o?I(o,u,{enumerable:!0,configurable:!0,writable:!0,value:d}):o[u]=d;var E=(o,u,d)=>N(o,typeof u!="symbol"?u+"":u,d);const u="scenar-embed";function g(e){return{source:u,v:1,...e}}function S(e){if(typeof e!="object"||e===null)return!1;const s=e;return s.source===u&&s.v===1}function l(e){return typeof e=="number"&&Number.isFinite(e)}function A(e){if(!S(e))return null;const s=e.type;switch(s){case"started":case"paused":case"completed":case"audioBlocked":return{type:s};case"ready":return l(e.totalSteps)&&typeof e.hasNarration=="boolean"?{type:s,totalSteps:e.totalSteps,hasNarration:e.hasNarration}:null;case"resize":return l(e.widthPx)&&l(e.heightPx)?{type:s,widthPx:e.widthPx,heightPx:e.heightPx}:null;case"stepchange":return l(e.stepIndex)&&l(e.totalSteps)?{type:s,stepIndex:e.stepIndex,totalSteps:e.totalSteps}:null;case"progress":return l(e.stepIndex)&&l(e.totalSteps)&&l(e.fraction)?{type:s,stepIndex:e.stepIndex,totalSteps:e.totalSteps,fraction:e.fraction}:null;case"error":return typeof e.message=="string"?{type:s,message:e.message}:null;default:return null}}function w(e,s={}){const{iframe:t,origin:n}=e,{onEvent:r}=s,a=typeof window<"u",c=i=>{var h;(h=t.contentWindow)==null||h.postMessage(g(i),n)},f=i=>{if(i.source!==t.contentWindow||i.origin!==n)return;const h=A(i.data);h&&(r==null||r(h))};return a&&window.addEventListener("message",f),{play:()=>c({type:"play"}),pause:()=>c({type:"pause"}),seek:i=>c({type:"seek",timeMs:i}),setMuted:i=>c({type:"setMuted",muted:i}),setVolume:i=>c({type:"setVolume",volume:i}),prefetch:()=>c({type:"prefetch"}),destroy:()=>{c({type:"destroy"}),a&&window.removeEventListener("message",f)}}}const T=896,C=480;function M(e,s){return e==="light"?"light":e==="dark"||s?"dark":"light"}function _(e,s){const t=new URL(e);return t.searchParams.set("theme",s),t.toString()}function P(e){return new URL(e).origin}function R(){return typeof document<"u"&&document.documentElement.classList.contains("dark")}function O(e,s){const{src:t,theme:n="auto",onAspectRatio:r,onEvent:a}=s,c=P(t);let f=null;const i=()=>{const m=M(n,R());m!==f&&(f=m,e.src=_(t,m))},h=w({iframe:e,origin:c},{onEvent:m=>{m.type==="resize"&&(r==null||r({widthPx:m.widthPx,heightPx:m.heightPx})),a==null||a(m)}});let p;return n==="auto"&&typeof MutationObserver<"u"&&typeof document<"u"&&(p=new MutationObserver(i),p.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]})),i(),{controller:h,destroy(){p==null||p.disconnect(),h.destroy()}}}const k="scenar-embed",x="Interactive product tour",L=typeof HTMLElement<"u"?HTMLElement:class{};class y extends L{constructor(){super(...arguments);E(this,"iframe",null);E(this,"mount",null)}static get observedAttributes(){return["src","title","theme"]}connectedCallback(){this.iframe||this.render(),this.attach()}disconnectedCallback(){var t;(t=this.mount)==null||t.destroy(),this.mount=null}attributeChangedCallback(t,n,r){if(n!==r){if(t==="title"){this.iframe&&this.iframe.setAttribute("title",this.titleAttr());return}this.isConnected&&this.attach()}}play(){var t;(t=this.mount)==null||t.controller.play()}pause(){var t;(t=this.mount)==null||t.controller.pause()}seek(t){var n;(n=this.mount)==null||n.controller.seek(t)}setMuted(t){var n;(n=this.mount)==null||n.controller.setMuted(t)}setVolume(t){var n;(n=this.mount)==null||n.controller.setVolume(t)}titleAttr(){return this.getAttribute("title")??x}themeAttr(){const t=this.getAttribute("theme");return t==="light"||t==="dark"?t:"auto"}setAspectRatio(t,n){this.style.aspectRatio=`${t} / ${n}`}render(){this.style.display="block",this.style.position="relative",this.style.width="100%",this.setAspectRatio(T,C);const t=document.createElement("iframe");t.setAttribute("title",this.titleAttr()),t.setAttribute("loading","lazy"),t.setAttribute("allow","autoplay; fullscreen"),t.setAttribute("allowfullscreen",""),t.style.cssText="position:absolute;inset:0;width:100%;height:100%;border:0;border-radius:inherit",this.appendChild(t),this.iframe=t}attach(){var n;const t=this.getAttribute("src");!t||!this.iframe||((n=this.mount)==null||n.destroy(),this.mount=O(this.iframe,{src:t,theme:this.themeAttr(),onAspectRatio:({widthPx:r,heightPx:a})=>this.setAspectRatio(r,a),onEvent:r=>this.dispatchEvent(new CustomEvent(`scenar:${r.type}`,{detail:r}))}))}}function b(e=k){typeof customElements>"u"||customElements.get(e)||customElements.define(e,y)}return b(),o.ScenarEmbedElement=y,o.defineScenarEmbed=b,Object.defineProperty(o,Symbol.toStringTag,{value:"Module"}),o}({});
1
+ var ScenarEmbed=function(l){"use strict";var W=Object.defineProperty;var G=(l,c,m)=>c in l?W(l,c,{enumerable:!0,configurable:!0,writable:!0,value:m}):l[c]=m;var S=(l,c,m)=>G(l,typeof c!="symbol"?c+"":c,m);const c="scenar-embed";function M(t){return{source:c,v:1,...t}}function T(t){if(typeof t!="object"||t===null)return!1;const n=t;return n.source===c&&n.v===1}function u(t){return typeof t=="number"&&Number.isFinite(t)}function _(t){if(t===void 0)return{};if(typeof t!="object"||t===null)return null;const n=t;return u(n.widthPx)&&n.widthPx>0&&u(n.heightPx)&&n.heightPx>0?{viewport:{widthPx:n.widthPx,heightPx:n.heightPx}}:null}function O(t){if(!T(t))return null;const n=t.type;switch(n){case"started":case"paused":case"completed":case"audioBlocked":return{type:n};case"ready":{if(!u(t.totalSteps)||typeof t.hasNarration!="boolean")return null;const e=_(t.viewport);return e===null?null:{type:n,totalSteps:t.totalSteps,hasNarration:t.hasNarration,...e}}case"resize":return u(t.widthPx)&&u(t.heightPx)?{type:n,widthPx:t.widthPx,heightPx:t.heightPx}:null;case"stepchange":return u(t.stepIndex)&&u(t.totalSteps)?{type:n,stepIndex:t.stepIndex,totalSteps:t.totalSteps}:null;case"progress":return u(t.stepIndex)&&u(t.totalSteps)&&u(t.fraction)?{type:n,stepIndex:t.stepIndex,totalSteps:t.totalSteps,fraction:t.fraction}:null;case"error":return typeof t.message=="string"?{type:n,message:t.message}:null;default:return null}}function k(t,n={}){const{iframe:e,origin:s}=t,{onEvent:o}=n,d=typeof window<"u",a=r=>{var h;(h=e.contentWindow)==null||h.postMessage(M(r),s)},E=r=>{if(r.source!==e.contentWindow||r.origin!==s)return;const h=O(r.data);h&&(o==null||o(h))};return d&&window.addEventListener("message",E),{play:()=>a({type:"play"}),pause:()=>a({type:"pause"}),seek:r=>a({type:"seek",timeMs:r}),setMuted:r=>a({type:"setMuted",muted:r}),setVolume:r=>a({type:"setVolume",volume:r}),setHostScale:r=>a({type:"setHostScale",scale:r}),prefetch:()=>a({type:"prefetch"}),destroy:()=>{a({type:"destroy"}),d&&window.removeEventListener("message",E)}}}const L=896,I=480;function B(t,n){return t==="light"?"light":t==="dark"||n?"dark":"light"}function N(t,n){const e=new URL(t);return e.searchParams.set("theme",n),e.toString()}function V(t){return new URL(t).origin}function v(){return typeof document<"u"&&document.documentElement.classList.contains("dark")}function D(t,n){const{src:e,theme:s="auto",onAspectRatio:o,onEvent:d}=n,a=V(e);let E=null;const r=()=>{const i=B(s,v());i!==E&&(E=i,t.src=N(e,i))};let h=null,p,A=null;const R=()=>{const i=t.parentElement;if(!h||!i)return;const{widthPx:w,heightPx:C}=h,g=i.getBoundingClientRect();if(g.width<=0)return;const f=Math.min(g.width/w,1),U=(g.width-w*f)/2,j=(g.height-C*f)/2;t.style.width=`${w}px`,t.style.height=`${C}px`,t.style.transformOrigin="0 0",t.style.transform=`translate(${U}px, ${j}px) scale(${f})`,f!==A&&(A=f,b.setHostScale(f))},F=i=>{h=i,R(),!p&&typeof ResizeObserver<"u"&&t.parentElement&&(p=new ResizeObserver(R),p.observe(t.parentElement))},b=k({iframe:t,origin:a},{onEvent:i=>{i.type==="resize"&&(o==null||o({widthPx:i.widthPx,heightPx:i.heightPx})),i.type==="ready"&&i.viewport&&F(i.viewport),d==null||d(i)}});let y;return s==="auto"&&typeof MutationObserver<"u"&&typeof document<"u"&&(y=new MutationObserver(r),y.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]})),r(),{controller:b,destroy(){y==null||y.disconnect(),p==null||p.disconnect(),b.destroy()}}}const H="scenar-embed",$="Interactive product tour",z=typeof HTMLElement<"u"?HTMLElement:class{};class x extends z{constructor(){super(...arguments);S(this,"iframe",null);S(this,"mount",null)}static get observedAttributes(){return["src","title","theme"]}connectedCallback(){this.iframe||this.render(),this.attach()}disconnectedCallback(){var e;(e=this.mount)==null||e.destroy(),this.mount=null}attributeChangedCallback(e,s,o){if(s!==o){if(e==="title"){this.iframe&&this.iframe.setAttribute("title",this.titleAttr());return}this.isConnected&&this.attach()}}play(){var e;(e=this.mount)==null||e.controller.play()}pause(){var e;(e=this.mount)==null||e.controller.pause()}seek(e){var s;(s=this.mount)==null||s.controller.seek(e)}setMuted(e){var s;(s=this.mount)==null||s.controller.setMuted(e)}setVolume(e){var s;(s=this.mount)==null||s.controller.setVolume(e)}titleAttr(){return this.getAttribute("title")??$}themeAttr(){const e=this.getAttribute("theme");return e==="light"||e==="dark"?e:"auto"}setAspectRatio(e,s){this.style.aspectRatio=`${e} / ${s}`}render(){this.style.display="block",this.style.position="relative",this.style.width="100%",this.setAspectRatio(L,I);const e=document.createElement("iframe");e.setAttribute("title",this.titleAttr()),e.setAttribute("loading","lazy"),e.setAttribute("allow","autoplay; fullscreen"),e.setAttribute("allowfullscreen",""),e.style.cssText="position:absolute;inset:0;width:100%;height:100%;border:0;border-radius:inherit",this.appendChild(e),this.iframe=e}attach(){var s;const e=this.getAttribute("src");!e||!this.iframe||((s=this.mount)==null||s.destroy(),this.mount=D(this.iframe,{src:e,theme:this.themeAttr(),onAspectRatio:({widthPx:o,heightPx:d})=>this.setAspectRatio(o,d),onEvent:o=>this.dispatchEvent(new CustomEvent(`scenar:${o.type}`,{detail:o}))}))}}function P(t=H){typeof customElements>"u"||customElements.get(t)||customElements.define(t,x)}return P(),l.ScenarEmbedElement=x,l.defineScenarEmbed=P,Object.defineProperty(l,Symbol.toStringTag,{value:"Module"}),l}({});
package/mount.d.ts CHANGED
@@ -33,6 +33,27 @@ export interface EmbedMount {
33
33
  * + own layout, then hand it here — so neither re-implements the bridge, exactly
34
34
  * as {@link createEmbedHostController} is shared by the console and this loader.
35
35
  *
36
+ * Iframe-as-screen (scenar-cloud DD-008's principle, applied to the frame):
37
+ * when the bundle's `ready` event carries its canonical viewport, the mount
38
+ * lays the iframe out at exactly that CSS-pixel size and scales it to the
39
+ * wrapper with a `transform` — instead of letting the iframe track the wrapper
40
+ * and scaling *inside* the document. The embedded document's viewport is then
41
+ * the canonical viewport, so its media queries resolve as they would in a
42
+ * real browser window of that size; the one scale factor per frame lives out
43
+ * here, at the true boundary. `transform` and not CSS `zoom` on purpose:
44
+ * transform is paint-level and never propagates into the child document, so
45
+ * the inner viewport stays canonical. The mount reports the factor back over
46
+ * `setHostScale` so the player's chrome layer can counter-scale its controls
47
+ * to native pixel size. Bundles packed before the viewport field keep the
48
+ * fit-inside-the-iframe behavior — both directions of version skew degrade
49
+ * to exactly the pre-mode rendering.
50
+ *
51
+ * Layout contract with the adapters: the iframe fills a positioned wrapper
52
+ * (`position: relative/absolute` box whose aspect ratio tracks the reported
53
+ * embed size). The mount measures that wrapper — `iframe.parentElement` — to
54
+ * derive the scale, and owns the iframe's `width`/`height`/`transform` once
55
+ * (and only once) a viewport-carrying `ready` arrives.
56
+ *
36
57
  * The caller owns the iframe element (and removes it for full teardown); this
37
58
  * function owns only the listeners it adds, all released by {@link EmbedMount.destroy}.
38
59
  */
package/mount.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"mount.d.ts","sourceRoot":"","sources":["../src/mount.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,yBAAyB,EAE/B,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,KAAK,gBAAgB,EAItB,MAAM,cAAc,CAAC;AAEtB,yEAAyE;AACzE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,yEAAyE;IACzE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,8EAA8E;IAC9E,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAC3D,gEAAgE;IAChE,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;CACtD;AAED,2EAA2E;AAC3E,MAAM,WAAW,UAAU;IACzB,iFAAiF;IACjF,QAAQ,CAAC,UAAU,EAAE,yBAAyB,CAAC;IAC/C,2EAA2E;IAC3E,OAAO,IAAI,IAAI,CAAC;CACjB;AAUD;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,iBAAiB,EACzB,OAAO,EAAE,iBAAiB,GACzB,UAAU,CAkDZ"}
1
+ {"version":3,"file":"mount.d.ts","sourceRoot":"","sources":["../src/mount.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,yBAAyB,EAG/B,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,KAAK,gBAAgB,EAItB,MAAM,cAAc,CAAC;AAEtB,yEAAyE;AACzE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,yEAAyE;IACzE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,8EAA8E;IAC9E,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAC3D,gEAAgE;IAChE,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;CACtD;AAED,2EAA2E;AAC3E,MAAM,WAAW,UAAU;IACzB,iFAAiF;IACjF,QAAQ,CAAC,UAAU,EAAE,yBAAyB,CAAC;IAC/C,2EAA2E;IAC3E,OAAO,IAAI,IAAI,CAAC;CACjB;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,iBAAiB,EACzB,OAAO,EAAE,iBAAiB,GACzB,UAAU,CA2FZ"}
package/mount.js CHANGED
@@ -15,6 +15,27 @@ function hostPrefersDark() {
15
15
  * + own layout, then hand it here — so neither re-implements the bridge, exactly
16
16
  * as {@link createEmbedHostController} is shared by the console and this loader.
17
17
  *
18
+ * Iframe-as-screen (scenar-cloud DD-008's principle, applied to the frame):
19
+ * when the bundle's `ready` event carries its canonical viewport, the mount
20
+ * lays the iframe out at exactly that CSS-pixel size and scales it to the
21
+ * wrapper with a `transform` — instead of letting the iframe track the wrapper
22
+ * and scaling *inside* the document. The embedded document's viewport is then
23
+ * the canonical viewport, so its media queries resolve as they would in a
24
+ * real browser window of that size; the one scale factor per frame lives out
25
+ * here, at the true boundary. `transform` and not CSS `zoom` on purpose:
26
+ * transform is paint-level and never propagates into the child document, so
27
+ * the inner viewport stays canonical. The mount reports the factor back over
28
+ * `setHostScale` so the player's chrome layer can counter-scale its controls
29
+ * to native pixel size. Bundles packed before the viewport field keep the
30
+ * fit-inside-the-iframe behavior — both directions of version skew degrade
31
+ * to exactly the pre-mode rendering.
32
+ *
33
+ * Layout contract with the adapters: the iframe fills a positioned wrapper
34
+ * (`position: relative/absolute` box whose aspect ratio tracks the reported
35
+ * embed size). The mount measures that wrapper — `iframe.parentElement` — to
36
+ * derive the scale, and owns the iframe's `width`/`height`/`transform` once
37
+ * (and only once) a viewport-carrying `ready` arrives.
38
+ *
18
39
  * The caller owns the iframe element (and removes it for full teardown); this
19
40
  * function owns only the listeners it adds, all released by {@link EmbedMount.destroy}.
20
41
  */
@@ -31,11 +52,50 @@ export function createEmbedMount(iframe, options) {
31
52
  appliedTheme = resolved;
32
53
  iframe.src = applyThemeToSrc(src, resolved);
33
54
  };
55
+ // Iframe-as-screen state: set by the first viewport-carrying `ready`, then
56
+ // kept in sync with the wrapper by a ResizeObserver. A theme flip reloads
57
+ // the iframe and replays `ready`, which simply re-applies.
58
+ let canonicalViewport = null;
59
+ let wrapperObserver;
60
+ let lastPostedScale = null;
61
+ const applyViewportScale = () => {
62
+ const wrapper = iframe.parentElement;
63
+ if (!canonicalViewport || !wrapper)
64
+ return;
65
+ const { widthPx, heightPx } = canonicalViewport;
66
+ const rect = wrapper.getBoundingClientRect();
67
+ if (rect.width <= 0)
68
+ return;
69
+ // Cap at 1 like the in-document viewport does: upscaling past native
70
+ // renders soft. Wider-than-canonical wrappers center the frame instead.
71
+ const scale = Math.min(rect.width / widthPx, 1);
72
+ const offsetX = (rect.width - widthPx * scale) / 2;
73
+ const offsetY = (rect.height - heightPx * scale) / 2;
74
+ iframe.style.width = `${widthPx}px`;
75
+ iframe.style.height = `${heightPx}px`;
76
+ iframe.style.transformOrigin = "0 0";
77
+ iframe.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
78
+ if (scale !== lastPostedScale) {
79
+ lastPostedScale = scale;
80
+ controller.setHostScale(scale);
81
+ }
82
+ };
83
+ const adoptViewport = (viewport) => {
84
+ canonicalViewport = viewport;
85
+ applyViewportScale();
86
+ if (!wrapperObserver && typeof ResizeObserver !== "undefined" && iframe.parentElement) {
87
+ wrapperObserver = new ResizeObserver(applyViewportScale);
88
+ wrapperObserver.observe(iframe.parentElement);
89
+ }
90
+ };
34
91
  const controller = createEmbedHostController({ iframe, origin }, {
35
92
  onEvent: (event) => {
36
93
  if (event.type === "resize") {
37
94
  onAspectRatio?.({ widthPx: event.widthPx, heightPx: event.heightPx });
38
95
  }
96
+ if (event.type === "ready" && event.viewport) {
97
+ adoptViewport(event.viewport);
98
+ }
39
99
  onEvent?.(event);
40
100
  },
41
101
  });
@@ -56,6 +116,7 @@ export function createEmbedMount(iframe, options) {
56
116
  controller,
57
117
  destroy() {
58
118
  observer?.disconnect();
119
+ wrapperObserver?.disconnect();
59
120
  controller.destroy();
60
121
  },
61
122
  };
package/mount.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"mount.js","sourceRoot":"","sources":["../src/mount.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,yBAAyB,GAC1B,MAAM,cAAc,CAAC;AACtB,OAAO,EAEL,eAAe,EACf,aAAa,EACb,YAAY,GACb,MAAM,cAAc,CAAC;AA4BtB,4EAA4E;AAC5E,SAAS,eAAe;IACtB,OAAO,CACL,OAAO,QAAQ,KAAK,WAAW;QAC/B,QAAQ,CAAC,eAAe,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,CACpD,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,gBAAgB,CAC9B,MAAyB,EACzB,OAA0B;IAE1B,MAAM,EAAE,GAAG,EAAE,KAAK,GAAG,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IAChE,MAAM,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IAElC,6EAA6E;IAC7E,4EAA4E;IAC5E,IAAI,YAAY,GAA4B,IAAI,CAAC;IACjD,MAAM,UAAU,GAAG,GAAS,EAAE;QAC5B,MAAM,QAAQ,GAAG,YAAY,CAAC,KAAK,EAAE,eAAe,EAAE,CAAC,CAAC;QACxD,IAAI,QAAQ,KAAK,YAAY;YAAE,OAAO;QACtC,YAAY,GAAG,QAAQ,CAAC;QACxB,MAAM,CAAC,GAAG,GAAG,eAAe,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;IAC9C,CAAC,CAAC;IAEF,MAAM,UAAU,GAAG,yBAAyB,CAC1C,EAAE,MAAM,EAAE,MAAM,EAAE,EAClB;QACE,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE;YACjB,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAC5B,aAAa,EAAE,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC;YACxE,CAAC;YACD,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC;QACnB,CAAC;KACF,CACF,CAAC;IAEF,IAAI,QAAsC,CAAC;IAC3C,IACE,KAAK,KAAK,MAAM;QAChB,OAAO,gBAAgB,KAAK,WAAW;QACvC,OAAO,QAAQ,KAAK,WAAW,EAC/B,CAAC;QACD,QAAQ,GAAG,IAAI,gBAAgB,CAAC,UAAU,CAAC,CAAC;QAC5C,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,eAAe,EAAE;YACzC,UAAU,EAAE,IAAI;YAChB,eAAe,EAAE,CAAC,OAAO,CAAC;SAC3B,CAAC,CAAC;IACL,CAAC;IAED,8EAA8E;IAC9E,0CAA0C;IAC1C,UAAU,EAAE,CAAC;IAEb,OAAO;QACL,UAAU;QACV,OAAO;YACL,QAAQ,EAAE,UAAU,EAAE,CAAC;YACvB,UAAU,CAAC,OAAO,EAAE,CAAC;QACvB,CAAC;KACF,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"mount.js","sourceRoot":"","sources":["../src/mount.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,yBAAyB,GAC1B,MAAM,cAAc,CAAC;AACtB,OAAO,EAEL,eAAe,EACf,aAAa,EACb,YAAY,GACb,MAAM,cAAc,CAAC;AA4BtB,4EAA4E;AAC5E,SAAS,eAAe;IACtB,OAAO,CACL,OAAO,QAAQ,KAAK,WAAW;QAC/B,QAAQ,CAAC,eAAe,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,CACpD,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,UAAU,gBAAgB,CAC9B,MAAyB,EACzB,OAA0B;IAE1B,MAAM,EAAE,GAAG,EAAE,KAAK,GAAG,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IAChE,MAAM,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IAElC,6EAA6E;IAC7E,4EAA4E;IAC5E,IAAI,YAAY,GAA4B,IAAI,CAAC;IACjD,MAAM,UAAU,GAAG,GAAS,EAAE;QAC5B,MAAM,QAAQ,GAAG,YAAY,CAAC,KAAK,EAAE,eAAe,EAAE,CAAC,CAAC;QACxD,IAAI,QAAQ,KAAK,YAAY;YAAE,OAAO;QACtC,YAAY,GAAG,QAAQ,CAAC;QACxB,MAAM,CAAC,GAAG,GAAG,eAAe,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;IAC9C,CAAC,CAAC;IAEF,2EAA2E;IAC3E,0EAA0E;IAC1E,2DAA2D;IAC3D,IAAI,iBAAiB,GAA+B,IAAI,CAAC;IACzD,IAAI,eAA2C,CAAC;IAChD,IAAI,eAAe,GAAkB,IAAI,CAAC;IAE1C,MAAM,kBAAkB,GAAG,GAAS,EAAE;QACpC,MAAM,OAAO,GAAG,MAAM,CAAC,aAAa,CAAC;QACrC,IAAI,CAAC,iBAAiB,IAAI,CAAC,OAAO;YAAE,OAAO;QAC3C,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,iBAAiB,CAAC;QAChD,MAAM,IAAI,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAC;QAC7C,IAAI,IAAI,CAAC,KAAK,IAAI,CAAC;YAAE,OAAO;QAC5B,qEAAqE;QACrE,wEAAwE;QACxE,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,GAAG,OAAO,EAAE,CAAC,CAAC,CAAC;QAChD,MAAM,OAAO,GAAG,CAAC,IAAI,CAAC,KAAK,GAAG,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;QACnD,MAAM,OAAO,GAAG,CAAC,IAAI,CAAC,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;QACrD,MAAM,CAAC,KAAK,CAAC,KAAK,GAAG,GAAG,OAAO,IAAI,CAAC;QACpC,MAAM,CAAC,KAAK,CAAC,MAAM,GAAG,GAAG,QAAQ,IAAI,CAAC;QACtC,MAAM,CAAC,KAAK,CAAC,eAAe,GAAG,KAAK,CAAC;QACrC,MAAM,CAAC,KAAK,CAAC,SAAS,GAAG,aAAa,OAAO,OAAO,OAAO,aAAa,KAAK,GAAG,CAAC;QACjF,IAAI,KAAK,KAAK,eAAe,EAAE,CAAC;YAC9B,eAAe,GAAG,KAAK,CAAC;YACxB,UAAU,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;QACjC,CAAC;IACH,CAAC,CAAC;IAEF,MAAM,aAAa,GAAG,CAAC,QAA6B,EAAQ,EAAE;QAC5D,iBAAiB,GAAG,QAAQ,CAAC;QAC7B,kBAAkB,EAAE,CAAC;QACrB,IAAI,CAAC,eAAe,IAAI,OAAO,cAAc,KAAK,WAAW,IAAI,MAAM,CAAC,aAAa,EAAE,CAAC;YACtF,eAAe,GAAG,IAAI,cAAc,CAAC,kBAAkB,CAAC,CAAC;YACzD,eAAe,CAAC,OAAO,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;QAChD,CAAC;IACH,CAAC,CAAC;IAEF,MAAM,UAAU,GAAG,yBAAyB,CAC1C,EAAE,MAAM,EAAE,MAAM,EAAE,EAClB;QACE,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE;YACjB,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAC5B,aAAa,EAAE,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC;YACxE,CAAC;YACD,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;gBAC7C,aAAa,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;YAChC,CAAC;YACD,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC;QACnB,CAAC;KACF,CACF,CAAC;IAEF,IAAI,QAAsC,CAAC;IAC3C,IACE,KAAK,KAAK,MAAM;QAChB,OAAO,gBAAgB,KAAK,WAAW;QACvC,OAAO,QAAQ,KAAK,WAAW,EAC/B,CAAC;QACD,QAAQ,GAAG,IAAI,gBAAgB,CAAC,UAAU,CAAC,CAAC;QAC5C,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,eAAe,EAAE;YACzC,UAAU,EAAE,IAAI;YAChB,eAAe,EAAE,CAAC,OAAO,CAAC;SAC3B,CAAC,CAAC;IACL,CAAC;IAED,8EAA8E;IAC9E,0CAA0C;IAC1C,UAAU,EAAE,CAAC;IAEb,OAAO;QACL,UAAU;QACV,OAAO;YACL,QAAQ,EAAE,UAAU,EAAE,CAAC;YACvB,eAAe,EAAE,UAAU,EAAE,CAAC;YAC9B,UAAU,CAAC,OAAO,EAAE,CAAC;QACvB,CAAC;KACF,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenar/embed",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Drop-in host-side embedding for Scenar tours — a framework-agnostic <scenar-embed> web component and a matching React wrapper.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -44,7 +44,7 @@
44
44
  "./loader": "./embed.global.js"
45
45
  },
46
46
  "dependencies": {
47
- "@scenar/core": "0.9.1"
47
+ "@scenar/core": "0.10.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": ">=18.0.0",
package/src/mount.test.ts CHANGED
@@ -178,6 +178,106 @@ describe("createEmbedMount — ignores messages that do not match the contract",
178
178
  });
179
179
  });
180
180
 
181
+ /** Build a framed ready message (protocol v1), optionally carrying a viewport. */
182
+ function readyMessage(viewport?: { widthPx: number; heightPx: number }) {
183
+ return {
184
+ source: "scenar-embed",
185
+ v: 1,
186
+ type: "ready",
187
+ totalSteps: 3,
188
+ hasNarration: false,
189
+ ...(viewport ? { viewport } : {}),
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Give an iframe a positioned wrapper of a fixed layout size (the adapter
195
+ * contract the mount scales against). jsdom computes no layout, so the
196
+ * wrapper's rect is stubbed.
197
+ */
198
+ function wrapInSizedWrapper(
199
+ iframe: HTMLIFrameElement,
200
+ width: number,
201
+ height: number,
202
+ ): HTMLDivElement {
203
+ const wrapper = document.createElement("div");
204
+ wrapper.style.position = "relative";
205
+ vi.spyOn(wrapper, "getBoundingClientRect").mockReturnValue({
206
+ width,
207
+ height,
208
+ top: 0,
209
+ left: 0,
210
+ right: width,
211
+ bottom: height,
212
+ x: 0,
213
+ y: 0,
214
+ toJSON: () => ({}),
215
+ } as DOMRect);
216
+ wrapper.appendChild(iframe);
217
+ document.body.appendChild(wrapper);
218
+ return wrapper;
219
+ }
220
+
221
+ describe("createEmbedMount — iframe-as-screen (ready carries a viewport)", () => {
222
+ it("lays the iframe out at the canonical size, scaled to fit the wrapper", () => {
223
+ const { iframe, frame } = makeIframe();
224
+ const send = vi.fn();
225
+ (frame as { postMessage: unknown }).postMessage = send;
226
+ // 896-wide wrapper for a 1440x900 canonical viewport: scale 896/1440.
227
+ wrapInSizedWrapper(iframe, 896, 560);
228
+ track(createEmbedMount(iframe, { src: SRC }));
229
+
230
+ post(readyMessage({ widthPx: 1440, heightPx: 900 }), { source: frame });
231
+
232
+ const scale = 896 / 1440;
233
+ expect(iframe.style.width).toBe("1440px");
234
+ expect(iframe.style.height).toBe("900px");
235
+ expect(iframe.style.transformOrigin).toBe("0 0");
236
+ expect(iframe.style.transform).toBe(`translate(0px, 0px) scale(${scale})`);
237
+ });
238
+
239
+ it("reports the applied scale back to the embed over setHostScale", () => {
240
+ const { iframe, frame } = makeIframe();
241
+ const send = vi.fn();
242
+ (frame as { postMessage: unknown }).postMessage = send;
243
+ wrapInSizedWrapper(iframe, 720, 450);
244
+ track(createEmbedMount(iframe, { src: SRC }));
245
+
246
+ post(readyMessage({ widthPx: 1440, heightPx: 900 }), { source: frame });
247
+
248
+ expect(send).toHaveBeenCalledWith(
249
+ expect.objectContaining({ type: "setHostScale", scale: 0.5 }),
250
+ ORIGIN,
251
+ );
252
+ });
253
+
254
+ it("caps the scale at 1 and centers the frame in a wider-than-canonical wrapper", () => {
255
+ const { iframe, frame } = makeIframe();
256
+ (frame as { postMessage: unknown }).postMessage = vi.fn();
257
+ wrapInSizedWrapper(iframe, 2000, 1100);
258
+ track(createEmbedMount(iframe, { src: SRC }));
259
+
260
+ post(readyMessage({ widthPx: 1440, heightPx: 900 }), { source: frame });
261
+
262
+ // scale 1; centered: x = (2000 - 1440) / 2 = 280, y = (1100 - 900) / 2 = 100.
263
+ expect(iframe.style.transform).toBe("translate(280px, 100px) scale(1)");
264
+ });
265
+
266
+ it("leaves the iframe's fill layout untouched for pre-viewport bundles", () => {
267
+ const { iframe, frame } = makeIframe();
268
+ (frame as { postMessage: unknown }).postMessage = vi.fn();
269
+ wrapInSizedWrapper(iframe, 896, 560);
270
+ track(createEmbedMount(iframe, { src: SRC }));
271
+
272
+ post(readyMessage(), { source: frame });
273
+
274
+ // The version-skew contract: an old bundle (no viewport on ready) keeps
275
+ // exactly the fit-inside-the-iframe behavior — no size, no transform.
276
+ expect(iframe.style.width).toBe("");
277
+ expect(iframe.style.transform).toBe("");
278
+ });
279
+ });
280
+
181
281
  describe("createEmbedMount — teardown", () => {
182
282
  it("stops reacting to resizes after destroy", () => {
183
283
  const ratios: EmbedAspectRatio[] = [];
package/src/mount.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type ScenarEmbedEvent,
3
3
  type ScenarEmbedHostController,
4
+ type ScenarEmbedViewport,
4
5
  createEmbedHostController,
5
6
  } from "@scenar/core";
6
7
  import {
@@ -54,6 +55,27 @@ function hostPrefersDark(): boolean {
54
55
  * + own layout, then hand it here — so neither re-implements the bridge, exactly
55
56
  * as {@link createEmbedHostController} is shared by the console and this loader.
56
57
  *
58
+ * Iframe-as-screen (scenar-cloud DD-008's principle, applied to the frame):
59
+ * when the bundle's `ready` event carries its canonical viewport, the mount
60
+ * lays the iframe out at exactly that CSS-pixel size and scales it to the
61
+ * wrapper with a `transform` — instead of letting the iframe track the wrapper
62
+ * and scaling *inside* the document. The embedded document's viewport is then
63
+ * the canonical viewport, so its media queries resolve as they would in a
64
+ * real browser window of that size; the one scale factor per frame lives out
65
+ * here, at the true boundary. `transform` and not CSS `zoom` on purpose:
66
+ * transform is paint-level and never propagates into the child document, so
67
+ * the inner viewport stays canonical. The mount reports the factor back over
68
+ * `setHostScale` so the player's chrome layer can counter-scale its controls
69
+ * to native pixel size. Bundles packed before the viewport field keep the
70
+ * fit-inside-the-iframe behavior — both directions of version skew degrade
71
+ * to exactly the pre-mode rendering.
72
+ *
73
+ * Layout contract with the adapters: the iframe fills a positioned wrapper
74
+ * (`position: relative/absolute` box whose aspect ratio tracks the reported
75
+ * embed size). The mount measures that wrapper — `iframe.parentElement` — to
76
+ * derive the scale, and owns the iframe's `width`/`height`/`transform` once
77
+ * (and only once) a viewport-carrying `ready` arrives.
78
+ *
57
79
  * The caller owns the iframe element (and removes it for full teardown); this
58
80
  * function owns only the listeners it adds, all released by {@link EmbedMount.destroy}.
59
81
  */
@@ -74,6 +96,43 @@ export function createEmbedMount(
74
96
  iframe.src = applyThemeToSrc(src, resolved);
75
97
  };
76
98
 
99
+ // Iframe-as-screen state: set by the first viewport-carrying `ready`, then
100
+ // kept in sync with the wrapper by a ResizeObserver. A theme flip reloads
101
+ // the iframe and replays `ready`, which simply re-applies.
102
+ let canonicalViewport: ScenarEmbedViewport | null = null;
103
+ let wrapperObserver: ResizeObserver | undefined;
104
+ let lastPostedScale: number | null = null;
105
+
106
+ const applyViewportScale = (): void => {
107
+ const wrapper = iframe.parentElement;
108
+ if (!canonicalViewport || !wrapper) return;
109
+ const { widthPx, heightPx } = canonicalViewport;
110
+ const rect = wrapper.getBoundingClientRect();
111
+ if (rect.width <= 0) return;
112
+ // Cap at 1 like the in-document viewport does: upscaling past native
113
+ // renders soft. Wider-than-canonical wrappers center the frame instead.
114
+ const scale = Math.min(rect.width / widthPx, 1);
115
+ const offsetX = (rect.width - widthPx * scale) / 2;
116
+ const offsetY = (rect.height - heightPx * scale) / 2;
117
+ iframe.style.width = `${widthPx}px`;
118
+ iframe.style.height = `${heightPx}px`;
119
+ iframe.style.transformOrigin = "0 0";
120
+ iframe.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
121
+ if (scale !== lastPostedScale) {
122
+ lastPostedScale = scale;
123
+ controller.setHostScale(scale);
124
+ }
125
+ };
126
+
127
+ const adoptViewport = (viewport: ScenarEmbedViewport): void => {
128
+ canonicalViewport = viewport;
129
+ applyViewportScale();
130
+ if (!wrapperObserver && typeof ResizeObserver !== "undefined" && iframe.parentElement) {
131
+ wrapperObserver = new ResizeObserver(applyViewportScale);
132
+ wrapperObserver.observe(iframe.parentElement);
133
+ }
134
+ };
135
+
77
136
  const controller = createEmbedHostController(
78
137
  { iframe, origin },
79
138
  {
@@ -81,6 +140,9 @@ export function createEmbedMount(
81
140
  if (event.type === "resize") {
82
141
  onAspectRatio?.({ widthPx: event.widthPx, heightPx: event.heightPx });
83
142
  }
143
+ if (event.type === "ready" && event.viewport) {
144
+ adoptViewport(event.viewport);
145
+ }
84
146
  onEvent?.(event);
85
147
  },
86
148
  },
@@ -107,6 +169,7 @@ export function createEmbedMount(
107
169
  controller,
108
170
  destroy() {
109
171
  observer?.disconnect();
172
+ wrapperObserver?.disconnect();
110
173
  controller.destroy();
111
174
  },
112
175
  };