서피스 이식성

마이크로 프론트엔드 앱에는 서피스 — 웹 호스트가 앱에 할당하는 직사각형 영역 — 가 주어집니다. 그 영역은 보통 브라우저 창이 아닙니다. 앱은 다중 패널 레이아웃의 여러 패널 중 하나일 수 있고, 같은 앱이 같은 화면에서 서로 다른 크기로 두 렌더 엔진 중 어느 쪽에서든 렌더링될 수 있습니다.

따라서 레이아웃 크기를 창에 맞추는 것은 두 엔진 모두에서 잘못입니다. 서피스 계약은 CSS와 JavaScript 양쪽에서 이식 가능한 대안을 제공합니다.

상태: 계약 1, 출시됨. Tailwind surface-* 변형, 호스트 중재 스크롤, 깊은 히트 테스트는 아직 출시되지 않았습니다. 이 페이지는 현재 존재하는 것만 문서화합니다.

CSS 계약

컨테이너 쿼리

호스트는 앱의 박스를 wippy-surface로 명명하므로, 다른 CSS 컨테이너와 마찬가지로 쿼리할 수 있습니다:

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

앱이 차지하는 공간에 반응하는 모든 것에는 @media (min-width: 640px) 대신 이것을 사용하세요. 네이티브 컨테이너 단위도 같은 박스를 기준으로 해석됩니다:

.hero { inline-size: 50cqw; }

서피스 변수

네 개의 커스텀 프로퍼티가 지오메트리를 평범한 픽셀 길이로 전달합니다:

프로퍼티 의미
--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가 기준으로 삼는 것과 같은 박스입니다.

애플리케이션은 이 네 이름을 선언하거나 대입해서는 안 됩니다. 하위 선언은 상속된 값을 가려 앱을 서피스에서 조용히 떼어냅니다.

또한 등록되지 않은 상태로 유지되어야 합니다. @property나 CSS.registerProperty()로 기술하지 마세요. 호스트는 보장된 무효 값을 대입하여 블록 축을 사용 불가로 표시하는데, 이 값은 프로퍼티가 등록되지 않은 동안에만 빈 문자열로 계산됩니다. 하나에 initial-value를 주면 그 값으로 계산되므로, 콘텐츠 사이징 앱이 스스로를 컨테이너 사이징이라고 보고하고 supports('block-size')가 true를 반환하기 시작합니다 — 어디에서도 오류 없이 말입니다.

이 값들을 100cqw와 픽셀 단위로 비교하기 전에 두 가지 주의점이 있습니다. 첫 프레임이 더 넓을 수 있습니다. 부트 값은 앱의 문서가 존재하기 전 호스트 쪽 <iframe> 엘리먼트에서 시드되므로, 콘텐츠가 스크롤바를 만들지 알 수 없습니다. 그 값이 문서의 CSS에 구워지므로 첫 레이아웃은 이를 사용하고 한 프레임 뒤에 보정됩니다. 그리고 값은 1/64 px로 양자화되므로 허용 오차를 두고 비교하세요.

컨테이너 사이징과 콘텐츠 사이징

인라인 축 블록 축
컨테이너 사이징 — 호스트가 두 치수를 모두 강제 사용 가능 사용 가능
콘텐츠 사이징 — 앱의 콘텐츠가 높이를 결정 사용 가능 사용 불가

콘텐츠 사이징에서 높이 프로퍼티는 의도적으로 무효이므로, var(--wippy-surface-height, 400px)는 숫자를 보고하는 대신 폴백하고 @container wippy-surface (min-height: …)는 결코 매칭되지 않습니다.

어느 쪽이 되는지는 작성자의 선택이 아니며, package.json의 무엇도 이를 바꾸지 않습니다. 사이징은 웹 호스트가 앱을 어디에 렌더링하는지가 결정합니다:

렌더링 형태 사이징
라우팅된 페이지, 레이아웃 패널, 우측 패널, 레지스트리 탭 컨테이너
임베드된 아티팩트, 인라인 아티팩트 블록, 내비바 위젯 콘텐츠

즉 같은 패키지가 자체 라우트에서는 컨테이너 사이징이고, 누군가 임베드하면 콘텐츠 사이징입니다. 따라서 블록 축이 필요한 앱은 그것이 없는 상황을 감내하거나, 아래의 요구 사항을 선언해 깨진 채로 렌더링되는 대신 거부되게 해야 합니다. 현재 모드는 host.surface.snapshot.sizing으로 읽고, 동작은 host.surface.supports('block-size')로 게이팅하세요 — 절대 가정하지 마세요.

cqh는 "사용 불가"보다 나쁘게 동작합니다. 컨테이너 단위는 필요한 축을 제공하는 컨테이너가 없을 때 작은 뷰포트로 폴백하므로, 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를 선호하세요. CSS가 갈 수 없는 곳에서만 JavaScript API에 손을 뻗으세요: 캔버스 크기 지정, 가상화 계산, 리소스 선택, 런타임에 생성되는 스타일.

engine: 'host'

host.surface.engine은 iframe, fragment, host를 보고합니다. 마지막 것은 페이지 엔진이 아닙니다 — 서피스가 할당되지 않은 곳에서 코드가 실행 중이라는 뜻입니다:

  • 페이지가 아니라 호스트 문서에 직접 마운트된 웹 컴포넌트;
  • 웹 호스트가 전혀 없는 스탠드얼론 개발 프록시.

그곳에서 스냅샷은 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%는 서피스 안의 최외곽 엘리먼트에 두세요. 백분율 높이에는 그 위로 끊기지 않는 확정 높이 사슬이 필요하므로, 자동 높이인 #app 안에 중첩된 컴포넌트 루트에 적용하면 0으로 해석되어 같은 간극이 다시 생깁니다. Chromium, Firefox, WebKit에서 min 없는 경우를 대조군으로 두고 검증했습니다.

/* iframe 엔진 전용. `fixed`는 자식 뷰포트를 기준으로 해석되며 그곳에서는
   그것이 곧 서피스입니다 — 그러나 fragment 엔진에서는 호스트 창을 기준으로
   해석되어, 패널이 아니라 애플리케이션 전체를 덮게 됩니다. */
.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(또는 다른 무엇)인 콘텐츠 사이징 fragment 페이지는 높이 0으로 렌더링됩니다 — 빈 패널이며, 여러분 코드에는 오류가 없습니다. 호스트가 그 요구 사항을 명시하는 오류를 로그합니다. iframe 엔진은 CmdBodySize에서 높이를 가져오므로 영향을 받지 않으며, 그래서 같은 패키지가 거기서는 멀쩡해 보이고 fragment로는 비어 보일 수 있습니다.

<!-- 올바름 -->
<body><div id="app"></div></body>
createApp(App).mount('#app')

높이 0인 fragment를 #root에 높이를 주어 고치려 하지 마세요. 다른 이름의 루트에 height: 100%, min-height: 100dvh, 100vh를 더해도 엔진이 그것을 측정하게 되지는 않으며, 뷰포트 단위는 이 페이지가 존재하는 바로 그 이유로 여기서 잘못입니다 — 그것들은 여러분의 서피스가 아니라 브라우저 창을 기술합니다. 대신 엘리먼트 이름을 app으로 바꾸세요.

제약

  • body 박스. iframe 엔진에서 호스트는 앱 body의 margin, padding, border를 0으로 만들어 할당된 서피스가 명확히 정의되게 합니다. 페이지 패딩은 여러분 자신의 루트 엘리먼트에 두세요. fragment 엔진은 이를 하지 않으므로, body 패딩에 의존하는 앱은 엔진마다 조금 다르게 렌더링됩니다. 아직 이에 대한 빌드 타임 진단은 없습니다.
  • body > * 셀렉터, 그리고 html/body를 겨냥하는 규칙. iframe 엔진에서 호스트는 body 콘텐츠를 서피스 박스로 감싸므로, body에 뿌리를 둔 직계 자식 셀렉터는 더 이상 앱 엘리먼트에 매칭되지 않고 body/html은 쿼리 박스의 조상이 됩니다 — 이들을 겨냥한 @container 규칙은 결코 적용되지 않습니다. fragment 엔진은 반대 위상이지만(쿼리 박스가 반영된 트리 위에 있음), 반영된 문서가 wf-html/wf-body로 이름이 바뀌므로 문자 그대로의 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'를 사용하세요.
  • 콘텐츠 사이징에는 블록 축이 없습니다.
  • fragment 엔진은 앱의 루트 엘리먼트가 #app일 것을 요구합니다. 엔진은 페이지 높이 사슬을 그 셀렉터에 묶고 이를 통해 콘텐츠 높이를 측정합니다. 반영된 문서가 html/body가 아니라 wf-html/wf-body를 노출하므로, 앱은 iframe 안에서처럼 루트로부터 자체 사슬을 만들 수 없기 때문입니다. 루트가 다른(#root, <main>) 콘텐츠 사이징 fragment 앱은 측정될 수 없습니다. 호스트가 그 요구 사항을 명시하는 오류를 로그하고 패널은 높이 0으로 렌더링됩니다. iframe 엔진은 영향을 받지 않습니다 — CmdBodySize에서 높이를 가져옵니다.
  • 더 이상 권장되지 않는 /page/:id 라우트는 서피스를 받지 못합니다. 아무것도 측정하지 않는 맨 iframe으로 렌더링되므로 완전히 옵트아웃합니다 — 쿼리 박스도, 래퍼도, 앱 DOM의 변경도 없습니다. 앱은 그곳에서 이 계약이 존재하기 전과 똑같이 동작합니다. 서피스를 받으려면 /c/:id를 사용하세요. 중첩 임베드와 마찬가지로 여전히 engine: 'iframe'을 보고하므로, 엔진 이름이 아니라 snapshot.width를 검사하세요.
  • 두 엔진은 스크롤바만큼 차이가 날 수 있습니다. iframe 엔진은 인라인 축을 앱 문서 안의 쿼리 박스에서 측정하므로 문서 스크롤바가 이를 좁힙니다. fragment 엔진은 반영된 콘텐츠의 스크롤이 좁히지 않는 호스트 문서 래퍼를 측정합니다. 같은 할당 패널과 같은 스크롤 콘텐츠에서 fragment 엔진이 약간 더 큰 값을 보고합니다.
  • 격리 경계가 아닙니다. 계약은 레이아웃을 다스립니다. fragment에 독립적인 문서, 뷰포트, 선택 영역, top layer, 오리진을 부여하지는 않습니다.

마이그레이션

서피스 마이그레이션에는 기존 앱을 위한 레시피별 변환이 있으며, 각각 자동·조건부·수동·변환 불가로 라벨이 붙어 있습니다.