サーフェスの可搬性

マイクロフロントエンドアプリにはサーフェス — Web ホストが割り当てる矩形領域 — が与えられます。その領域は通常、ブラウザーウィンドウではありません。アプリはマルチパネルレイアウトの中の1つのパネルであるかもしれませんし、同じアプリが同じ画面上で、どちらのレンダリングエンジンによっても異なるサイズでレンダリングされうるからです。

したがってレイアウトをウィンドウに合わせてサイズ指定するのは、どちらのエンジンでも誤りです。サーフェス契約は、CSS と JavaScript の両方で可搬な代替手段を提供します。

ステータス: contract 1、出荷済み。Tailwind の surface-* バリアント、ホスト仲介のスクロール、深いヒットテストは not yet shipped です。このページは現時点で存在するもののみを記述します。

CSS の契約

コンテナクエリ

ホストはアプリのボックスに wippy-surface という名前を付けるため、任意の CSS コンテナと同様にクエリできます。

@container wippy-surface (min-width: 640px) {
  .sidebar { display: block; }
}

アプリが占める空間に応答するものには、@media (min-width: 640px) の代わりにこちらを使ってください。ネイティブのコンテナ単位も同じボックスに対して解決されます。

.hero { inline-size: 50cqw; }

サーフェス変数

4つのカスタムプロパティが、ジオメトリを素のピクセル長として運びます。

プロパティ 意味
--wippy-surface-width サーフェスの幅全体
--wippy-surface-width-unit サーフェス幅の1%
--wippy-surface-height サーフェスの高さ全体(コンテナサイジングのみ)
--wippy-surface-height-unit サーフェス高さの1%(コンテナサイジングのみ)

これらは vw / vh の可搬な置き換えです。

/* 以前: inline-size: 50vw */
.panel { inline-size: calc(var(--wippy-surface-width-unit) * 50); }

値は継承されるため、アプリ内のどの要素からも読めます。報告するのはクエリボックスのコンテンツボックスで、これは 100cqw が解決する対象と同じボックスです。

アプリケーションはこれら4つの名前を宣言も代入もしてはいけません。子孫での宣言は継承された値を覆い隠し、アプリを静かにサーフェスから外してしまいます。

また、これらは未登録のままでなければなりません。@property や CSS.registerProperty() で記述しないでください。ホストは、保証された無効値を代入することでブロック軸が利用不可であることを示します。これが空文字列に計算されるのは、そのプロパティが未登録である間だけです。initial-value を与えると代わりにその値へ計算されるため、コンテンツサイジングのアプリが自分をコンテナサイジングだと報告し、supports('block-size') が true を返し始めます — しかもどこにもエラーは出ません。

これらの値を 100cqw とピクセル単位で比較する前に、注意点が2つあります。最初のフレームは広くなりうること。ブート時の値は、アプリのドキュメントが存在する前にホスト側の <iframe> 要素から取られるため、コンテンツがスクロールバーを生じさせるかどうかを知りようがありません。その値がドキュメントの CSS へ焼き込まれるため、最初のレイアウトはそれを使い、1フレーム後に補正されます。そして値は 1/64 px に量子化されるため、比較には許容誤差を持たせてください。

コンテナサイジングとコンテンツサイジング

インライン軸 ブロック軸
コンテナサイジング — ホストが両方の寸法を課す 利用可 利用可
コンテンツサイジング — アプリのコンテンツが高さを決める 利用可 利用不可

コンテンツサイジングでは高さのプロパティは意図的に無効になるため、var(--wippy-surface-height, 400px) は数値を報告せずフォールバックし、@container wippy-surface (min-height: …) は決してマッチしません。

どちらになるかは作者の選択ではなく、package.json の何を変えても変わりません。サイジングはWeb ホストがアプリをどこでレンダリングするかで決まります。

レンダリングのされ方 サイジング
ルーティングされたページ、レイアウトパネル、右パネル、レジストリタブ コンテナ
埋め込みアーティファクト、インラインのアーティファクトブロック、ナビバーウィジェット コンテンツ

つまり同じパッケージでも、自身のルート上ではコンテナサイジングになり、誰かが埋め込めばコンテンツサイジングになります。したがってブロック軸を必要とするアプリは、それが無い状況に耐えられるようにするか、(後述の)要件を宣言して、壊れた状態で描画されるのではなく拒否されるようにしなければなりません。現在のモードは host.surface.snapshot.sizing で読み、挙動は host.surface.supports('block-size') でゲートしてください — 決して仮定しないこと。

cqh は「利用不可」よりたちが悪い振る舞いをします。必要な軸を供給するコンテナがない場合、コンテナ単位は small viewport にフォールバックするため、cqh はサーフェスとは無関係のもっともらしい数値を静かに生成します。ルートにピン留めされ、目に見えてフォールバックする var(--wippy-surface-height, <fallback>) を優先してください。同じ罠は、中間の要素に container-type: inline-size を宣言し、その下で cqh を使うアプリの内部でも現れます。

要件の宣言

アプリの package.json に、任意で記述します。

{
  "wippy": {
    "path": "index.html",
    "surface": {
      "contract": 1,
      "requirements": ["block-size"]
    }
  }
}

受け付けられるトークンは block-size と surface-scroll で、いずれもコンテナサイジングを必要とし、インスタンスがコンテンツサイジングの場合は拒否されます。registered-hit-testing、native-document-hit-testing、owner-visibility は予約語彙で、静かに無視されるのではなく未実装として拒否されます。

検証は起動前に走るため、満たせない宣言は、ブロック軸のクエリが決してマッチしないアプリを描画するのではなく、目に見えて失敗します。surface ブロックのないアプリも依然としてレンダリングされ、クエリボックスと変数を受け取ります。単に可搬性を表明していないだけです。

surface-scroll は受け付けられ supports() にも報告されますが、このリリースではホスト仲介のスクロール API は出荷されていません — 宣言は意図の表明であって、メソッドを解放するものではありません。

JavaScript からサーフェスを読む

完全なシグネチャは プロキシ API → Surface を参照してください。

const { width, widthUnit, height, sizing } = host.surface.snapshot

if (host.surface.supports('block-size')) {
  // ブロック軸に依存して安全
}

const off = host.surface.onChange((s) => reposition(s.width, s.height))
// 破棄時に off() を呼ぶ

スナップショットは、CSS が解決するのと同じ計算済みカスタムプロパティから読み戻されるため、@container や cqw が見ているものとずれることはありません。

レイアウトには CSS を優先してください。JavaScript API に手を伸ばすのは CSS では届かないところ — canvas のサイズ指定、仮想化の計算、リソース選択、実行時に生成されるスタイル — です。

engine: 'host'

host.surface.engine は iframe、fragment、host のいずれかを報告します。最後のものはページエンジンではありません — サーフェスが割り当てられていない場所でコードが動いていることを意味します。

  • ページの中ではなくホストのドキュメントへ直接マウントされた Web コンポーネント。
  • Web ホストがまったく存在しない、スタンドアロンの開発プロキシ。

そこではスナップショットが width: 0、height: null、sizing: 'content' を報告し、supports() はすべてについて false です。これは意図的です。ブラウザーウィンドウで代用することは、契約が避けるために存在する偽りの等価だからです。直接マウントされたコンポーネントは、代わりに自身のルートを測るべきです。

契約がカバーしないもの

コンテナクエリが置き換えるのは CSS におけるメディアクエリです。次の仕組みは CSS の外にあり、引き続きブラウザーウィンドウに従います。

仕組み 理由 対処
<picture> / <source media> HTML のリソース選択。コンテナクエリの形がない host.surface.onChange から駆動するか、@container 配下の CSS の background-image へアートディレクションを移す
srcset + sizes ビューポートに対して解決される サーフェスから sizes を導出するか、JS からソースを設定する
matchMedia() 定義上ウィンドウに問い合わせる ジオメトリには host.surface.onChange を使い、プリファレンスには matchMedia を残す

オーバーレイ

サーフェス契約は position: fixed を捕捉しません。container-type はレイアウトの封じ込めなしに独立したフォーマットコンテキストを確立するため、クエリコンテナは contain: none と計算され、何もアンカーしません。PrimeVue のオーバーレイも手書きの fixed オーバーレイも、変更なしで動き続けます。

エンジンの挙動は別の問題です。Web Fragment エンジンでは position: fixed がアプリのパネルではなくホストウィンドウに対して解決されます。レンダリングエンジン を参照し、正確なビューポートへのアンカーが重要なら wippy.renderEngine: "iframe" でアプリをピン留めしてください。

オーバーレイのサイズ指定は、アンカーとは別の問題です。サーフェスをちょうど覆うべきバックドロップやドロワーでは、ビューポート単位をやめて inset: 0 を使ってください — ただし、アプリがどれだけ可搬でなければならないかに合った位置指定方式と組み合わせてください。

/* 両方のエンジンで可搬: `fixed` がたまたま相対する対象ではなく、
   アプリ自身のルートに対して解決される。
   `min-block-size: 100%` は必須 — 下記を参照。 */
.app-root { position: relative; min-block-size: 100%; }
.backdrop { position: absolute; inset: 0; }

包含ブロックはサーフェスではなくアプリのルートなので、オーバーレイがサーフェスを覆うのは、そのルートが覆っている場合だけです。コンテンツサイジングでは自動的にそうなります(コンテンツが高さだからです)。コンテナサイジングでは、ホストがクエリボックスに課した高さをアプリのルートは継承しないため、min-block-size: 100% がないとバックドロップは静かに途中で止まります — まさに fixed 版なら正しく見えたはずのモードで失敗するのです。挙動も異なります。absolute はコンテンツとともにスクロールし、fixed は固定されたままです。

min-block-size: 100% は、サーフェス内の最も外側の要素に付けてください。パーセンテージの高さは、その上に確定した高さの途切れない連鎖を必要とします。高さが auto の #app の内側に入れ子になったコンポーネントのルートへ適用すると、ゼロに解決され、同じ隙間が再発します。min なしのケースを対照として、Chromium、Firefox、WebKit で検証済みです。

/* iframe エンジン専用。`fixed` は子のビューポートに対して解決され、
   そこではそれがサーフェスそのものである — が、フラグメントエンジンでは
   ホストウィンドウに対して解決され、パネルではなくアプリケーション全体を覆う。 */
.backdrop { position: fixed; inset: 0; }

これに var(--wippy-surface-height) を使うのは避けてください。コンテンツサイジングでは利用できないため、そう書かれたバックドロップは、もっとも気付きにくいページでちょうど潰れてしまいます。

アプリのルート要素 (#app)

Web Fragment エンジンは、ルート要素が id="app" であることを要求します。 #root でも #main でも <main> でもありません — id は文字どおりに照合されます。

エンジンはページの高さの連鎖をそのセレクターに結び付け、それを通じてコンテンツの高さを測ります。反映されたドキュメントは html/body ではなく wf-html/wf-body を公開するため、iframe の内側のようにドキュメントルートから連鎖を組み立てることはできません。

間違っているときの症状: ルートが #root(またはそれ以外)のコンテンツサイジングのフラグメントページは高さゼロで描画されます — 空白のパネルで、自分のコードにはエラーが出ません。ホストは要件を示すエラーをログに出します。iframe エンジンは影響を受けません。高さを CmdBodySize から取るためで、同じパッケージがそちらでは問題なく見え、フラグメントでは空白になりえます。

<!-- 正しい -->
<body><div id="app"></div></body>
createApp(App).mount('#app')

高さゼロのフラグメントを、#root に高さを与えて直そうとしないでください。 別名のルートに height: 100%、min-height: 100dvh、100vh を追加しても、エンジンがそれを測るようにはなりません。しかもここでビューポート単位が誤りである理由こそ、このページ全体が存在する理由です — それらはあなたのサーフェスではなくブラウザーウィンドウを表します。代わりに要素の名前を app に変えてください。

制限

  • body のボックス。 iframe エンジンでは、割り当てられたサーフェスが明確に定義されるよう、ホストがアプリの body の margin、padding、border をゼロにします。ページの padding は自分のルート要素に付けてください。フラグメントエンジンはこれを行わないため、body の padding に依存するアプリはエンジン間でわずかに異なる描画になります。これに対するビルド時の診断はまだありません。
  • body > * セレクター、および html/body を対象とするルール。 iframe エンジンでは、ホストが body のコンテンツをサーフェスボックスで包むため、body を起点とする直接子セレクターはアプリの要素にマッチしなくなり、body/html はクエリボックスの祖先になります — それらを対象とする @container ルールは決して適用されません。フラグメントエンジンはトポロジーが逆(クエリボックスは反映されたツリーの上にあります)ですが、そこでも文字どおりの body セレクターは失敗します。反映されたドキュメントが wf-html/wf-body へ名前を変えられているためです。そうしたルールは、サーフェス内の自分のルート要素に付けてください。それが両方のエンジンで正しい方法です。
  • <w-iframe> / <w-artifact> を通じて描画されるものにはサーフェスが与えられません — トップレベルのマネージドパネルであっても同様です。 これらの要素は常に、サーフェスのブートストラップを無効にした状態で子ドキュメントを構築し、何もそれらを測らないため、host.surface は width: 0 と sizing: 'content' を報告します — ただし engine: 'host' ではなく engine: 'iframe' です。コンポーネントがそのように埋め込まれうるなら、engine ではなく snapshot.width を確認してください。入れ子の埋め込みではこれは想定どおりですが、{ kind: 'component', tagName: 'w-artifact' } として宣言されたマネージドレイアウトのパネルでは見落としやすく、そこはフルサイズのトップレベルのスロットでありながら契約が与えられません。契約を必要とするコンテンツには kind: 'page' を使ってください。
  • コンテンツサイジングにはブロック軸がありません。
  • フラグメントエンジンはアプリのルート要素が #app であることを要求します。 エンジンはページの高さの連鎖をそのセレクターに結び付け、それを通じてコンテンツの高さを測ります。反映されたドキュメントが html/body ではなく wf-html/wf-body を公開するため、アプリは iframe の内側のようにルートから自前の連鎖を組み立てられないからです。ルートが異なる(#root、<main>)コンテンツサイジングのフラグメントアプリは測定できません。ホストは要件を示すエラーをログに出し、パネルは高さゼロで描画されます。iframe エンジンは影響を受けません — 高さを CmdBodySize から取ります。
  • 非推奨の /page/:id ルートにはサーフェスが与えられません。 何も測らない素の iframe へ描画されるため、完全にオプトアウトします — クエリボックスもラッパーもなく、アプリの DOM も変わりません。そこでのアプリの挙動は、この契約が存在する前とまったく同じです。サーフェスを得るには /c/:id を使ってください。入れ子の埋め込みと同様、そこでも engine: 'iframe' を報告するため、エンジン名ではなく snapshot.width を確認してください。
  • 2つのエンジンはスクロールバーの分だけ異なりうる。 iframe エンジンはアプリのドキュメント内側のクエリボックスからインライン軸を測るため、ドキュメントのスクロールバーが幅を狭めます。フラグメントエンジンはホストドキュメント側のラッパーを測り、反映されたコンテンツのスクロールはそれを狭めません。同じ割り当てパネルと同じスクロールするコンテンツで、フラグメントエンジンはわずかに大きい数値を報告します。
  • 分離境界ではありません。 契約が支配するのはレイアウトです。フラグメントに独立したドキュメント、ビューポート、選択範囲、トップレイヤー、オリジンを与えるものではありません。

移行

サーフェス移行 には、既存アプリ向けのレシピごとの変換手順があり、それぞれ automatic、conditional、manual、not convertible のいずれかにラベル付けされています。