서피스 마이그레이션
기존 마이크로 프론트엔드 앱을 뷰포트 기반 반응형에서 서피스 계약으로 전환하기 위한 레시피입니다.
각 레시피에는 라벨이 붙어 있습니다:
| 라벨 | 의미 |
|---|---|
| 자동 | 기계적입니다. 변환된 규칙은 같은 것을 의미합니다. |
| 조건부 | 명시된 전제 조건이 성립할 때만 안전합니다. 확인하세요. |
| 수동 | 사람의 판단이 필요합니다. 하나의 올바른 재작성이 존재하지 않습니다. |
| 변환 불가 | 컨테이너 쿼리 형태가 없습니다. host.surface를 쓰거나 뷰포트 동작을 의도적으로 유지하세요. |
아래 각 레시피는 독립된 기법입니다. 웹 호스트 저장소는 이들을 모두 결합한 실행 가능한 페이지를 유지하며, 테스트 스위트가 이를 실행하므로 레시피가 잘못된 지침으로 썩지 않습니다.
아직 출시되지 않은 작업에 의존하는 레시피 — Tailwind
surface-*변형, 빌드 타임 진단, 호스트 중재 스크롤, 히트 테스트 — 는 아직 출시되지 않음으로 표시되어 있으며 현재 존재하는 것만 설명합니다.
결정 트리: 이 규칙은 무엇에 관한 것인가?
무엇이든 변환하기 전에 의도를 분류하세요. 대부분의 잘못된 마이그레이션은 변환하지 말았어야 할 규칙을 올바르게 변환한 결과입니다.
이 규칙이 이 페이지가 가진 공간의 크기에 반응하는가?
├── 예 → @container wippy-surface로 변환 (레시피 1-8)
├── 아니오, 하나의 컴포넌트 너비에 반응한다
│ → 그 컴포넌트에 자체 컨테이너를 부여 (레시피 22)
├── 아니오, 사용자/기기 선호에 반응한다
│ → @media로 그대로 둔다 (레시피 13)
└── 아니오, 의도적으로 브라우저 창을 추적한다
(진짜 전체 창 오버레이)
→ 그대로 두고, 이유를 문서화한다
판단할 수 없다면 그대로 두고 나중에 다시 보세요. 변환하지 않은 미디어 쿼리는 단지 이식 불가능할 뿐이지만, 잘못 변환된 것은 조용히 깨져 있습니다.
1. max-width → inline-size <= — 자동
/* 이전 */ @media (max-width: 640px) { .nav { display: none } }
/* 이후 */ @container wippy-surface (max-width: 640px) { .nav { display: none } }
2. min-width → inline-size >= — 자동
/* 이전 */ @media (min-width: 640px) { .sidebar { display: block } }
/* 이후 */ @container wippy-surface (min-width: 640px) { .sidebar { display: block } }
3. 상하한이 있는 너비 범위 — 자동
/* 이전 */ @media (min-width: 640px) and (max-width: 1024px) { … }
/* 이후 */ @container wippy-surface (640px <= width <= 1024px) { … }
범위 문법은 서피스 계약이 대상으로 하는 모든 엔진에서 지원됩니다. 원한다면
and 형태도 동작합니다.
4. 여러 브레이크포인트, 캐스케이드 순서 유지 — 자동
컨테이너 쿼리는 명시도나 순서를 바꾸지 않습니다. 각 블록을 변환하고 동일한 소스 순서로 유지하세요:
@container wippy-surface (min-width: 480px) { .grid { grid-template-columns: repeat(2, 1fr) } }
@container wippy-surface (min-width: 900px) { .grid { grid-template-columns: repeat(4, 1fr) } }
5. 높이 쿼리 — 조건부 (컨테이너 사이징에서만)
/* 이후 */ @container wippy-surface (min-height: 500px) { .tall-only { display: block } }
전제 조건: 페이지가 컨테이너 사이징이어야 합니다. 콘텐츠 사이징에서는 페이지의 높이가 곧 자신의 콘텐츠이므로 높이 쿼리는 결코 매칭되지 않습니다. 조용히 실패하는 대신 크게 실패하도록 의존성을 선언하세요:
{ "wippy": { "surface": { "contract": 1, "requirements": ["block-size"] } } }
6. 종횡비 쿼리 — 조건부 (컨테이너 사이징에서만)
/* 이전 */ @media (min-aspect-ratio: 16/9) { … }
/* 이후 */ @container wippy-surface (min-aspect-ratio: 16/9) { … }
레시피 5와 같은 전제 조건입니다. 종횡비에는 두 축이 모두 필요합니다.
7. 방향(orientation) 쿼리 — 조건부 (컨테이너 사이징에서만)
@container wippy-surface (orientation: landscape)는 여러분 패널의 형태를
기술하며, 보통 그것이 의도한 바입니다. 정말로 기기를 의미했다면 그것은 미디어
쿼리이므로 그대로 두세요(레시피 13).
8. 콘텐츠 사이징에서의 높이 / 종횡비 / 방향 — 변환 불가
쿼리할 블록 축이 없습니다. 레이아웃이 인라인 축에 의존하도록 구조를 바꾸세요.
cqh로 흉내 내지 마세요 — 레시피 22를 보세요.
앱을 컨테이너 사이징으로 직접 전환할 수는 없습니다. 사이징은 웹 호스트가 앱을
어디에 렌더링하는지가 결정하며, 앱 패키지의 무엇도 결정하지 않습니다. 레이아웃이
정말 블록 축 없이는 동작할 수 없다면 requirements: ["block-size"]를 선언하여
콘텐츠 사이징 배치가 잘못 렌더링되는 대신 아예 거부되게 하고, 앱이 컨테이너
사이징 컨텍스트(자체 라우트 또는 레이아웃 패널)에서 렌더링되도록 하세요.
서피스 이식성의 "컨테이너 사이징과 콘텐츠 사이징"을
참고하세요.
9. 환경 미디어 쿼리 안에 중첩된 지오메트리 — 수동
/* 이전 */
@media (prefers-color-scheme: dark) and (min-width: 640px) { .panel { … } }
/* 이후 — 분리: 선호는 남고, 지오메트리는 옮깁니다 */
@media (prefers-color-scheme: dark) {
@container wippy-surface (min-width: 640px) { .panel { … } }
}
두 조건이 예전에 하나의 프렐류드에서 결합되어 있었을 때 어떤 선언이 이기는지가 중첩 순서에 따라 달라질 수 있으므로 수동입니다. 결과를 다시 확인하세요.
10. 쉼표 OR 분기 — 수동
/* 이전 */ @media (max-width: 480px), (min-width: 1200px) { … }
쉼표는 OR입니다. 이를 두 개의 @container 블록으로 나누면 두 블록이 그 외에는
동일하고 인접해 있을 때에만 OR가 보존됩니다. 실수로 중첩하면 OR를 AND로 바꾼
것이 되어 아무것도 매칭되지 않습니다. 선언을 두 개의 형제 블록으로 복제하세요:
@container wippy-surface (max-width: 480px) { … }
@container wippy-surface (min-width: 1200px) { … }
11. not, only, 복잡한 불리언 — 수동
only는 미디어 타입의 잔재이며 컨테이너 대응물이 없습니다 — 버리세요.
not은 두 문법 모두에서 조건 전체를 반전하지만, and/or를 섞는 순간 우선순위가
달라집니다. 원래의 그룹핑을 믿지 말고 명시적으로 괄호를 치세요.
12. 지오메트리와 결합된 screen / print — 수동
미디어 타입에는 컨테이너 형태가 없습니다. 타입은 미디어 쿼리로 유지하고 그 안에 지오메트리를 중첩하세요(레시피 9와 같이). 특히 인쇄 레이아웃은 보통 전적으로 뷰포트/페이지 기반으로 남겨야 합니다.
13. 선호는 미디어 쿼리로 남습니다 — 변환 불가(그리고 지금 그대로가 옳음)
prefers-color-scheme, prefers-contrast, prefers-reduced-motion,
forced-colors, hover, pointer, any-pointer. @container는 크기 특성만
지원합니다. 이들을 변환하면 결코 매칭되지 않는 규칙이 됩니다.
14. em 브레이크포인트 — 수동
@media (min-width: 40em)는 em을 초기 폰트 크기에 대해 해석합니다.
@container wippy-surface (min-width: 40em)는 컨테이너의 폰트 크기에 대해
해석합니다. 두 값이 다르면 브레이크포인트가 조용히 이동합니다.
px로 변환하거나, 먼저 컨테이너의 계산된 font-size를 확인하세요.
15. rem 브레이크포인트 — 수동
@media 안에서 rem은 루트 상대가 아닙니다. 미디어 쿼리 조건은 em과 rem을
모두 초기 폰트 크기 — 작성자 CSS와 무관한 브라우저 기본값 — 에 대해 해석하는 반면,
@container는 실제 계산된 루트/컨테이너 폰트 크기에 대해 통상적인 방식으로
해석합니다.
따라서 루트 폰트 크기가 브라우저 기본값과 다른 순간, 런타임에 아무것도 바뀌지
않아도 둘은 이미 같지 않습니다. 흔한 html { font-size: 62.5% } 리셋만으로도
변환된 브레이크포인트가 640px에서 400px로 이동합니다.
그러므로 "루트 폰트 크기를 아무것도 바꾸지 않는다"는 것은 충분한 전제 조건이
아닙니다. 루트의 계산된 폰트 크기가 브라우저 기본값과 같음을 증명할 수 없다면
em(레시피 14)과 똑같이 px로 변환하세요.
16. 뷰포트 대 콘텐츠 박스의 스크롤바 경계 — 조건부
100vw는 고전적인 스크롤바 여백을 포함합니다. iframe 엔진에서 서피스 너비는
앱 문서 안 쿼리 박스의 콘텐츠 박스이므로 이를 포함하지 않습니다. 문서
스크롤바가 있는 페이지에서 변환된 값은 스크롤바 너비만큼 좁아지며, 보통 그것이
여러분이 원하던 보정입니다(100vw가 가로 오버플로를 일으키는 것은 고전적인
버그입니다).
fragment 엔진은 콘텐츠의 스크롤이 좁히지 않는 호스트 문서 래퍼를 측정하므로 그 보정을 적용하지 않습니다. 같은 패널, 같은 스크롤 콘텐츠인데 너비가 스크롤바만큼 다릅니다. 따라서 이 레시피의 조건은 정렬이 픽셀 단위로 정확한지가 아니라 앱이 어느 엔진에서 실행되는지입니다.
17. html / body를 대상으로 하는 규칙 — 수동
컨테이너 쿼리는 자기 컨테이너를 스타일링하지 않으며, html이나 body를 겨냥한
규칙은 두 엔진 모두에서 실패합니다 — 이유는 서로 다릅니다:
- iframe 엔진: 호스트가 body 콘텐츠를 서피스 박스로 감싸므로
html과body는 쿼리 컨테이너의 조상입니다.@container규칙은 조상에 도달할 수 없습니다. - fragment 엔진: 반대 위상입니다 — 쿼리 박스가 여러분 콘텐츠 위의 호스트 문서
래퍼입니다 — 그러나 문자 그대로의
body셀렉터는 여전히 실패합니다. 반영된 문서가wf-html/wf-body로 이름이 바뀌기 때문입니다.
어느 쪽이든 해결책은 같고, 엔진에 안전합니다:
/* ✗ 조용히 결코 매칭되지 않음 */
@container wippy-surface (min-width: 640px) { body { display: flex } }
/* ✓ 서피스 안의 여러분 자신의 루트로 옮깁니다 */
@container wippy-surface (min-width: 640px) { #app { display: flex } }
18. <picture><source media>와 <link media> — 변환 불가
HTML 수준의 리소스 선택에는 컨테이너 쿼리 형태가 없습니다. host.surface.onChange로
JS에서 구동하거나, 계약이 적용되는 CSS로 아트 디렉션을 옮기세요(@container 규칙
아래의 background-image).
19. 지오메트리 matchMedia() → host.surface — 자동
// 이전
const mq = matchMedia('(min-width: 640px)')
mq.addEventListener('change', render)
// 이후
const off = host.surface.onChange(s => render(s.width >= 640))
render(host.surface.snapshot.width >= 640)
// 정리 시 off() 호출
선호 쿼리에는 matchMedia를 계속 쓰세요 — 잘못된 것은 지오메트리뿐입니다.
20. 런타임 CSS, adopted stylesheet, CSS-in-JS — 수동
@container wippy-surface (...) 규칙을 방출하고 CSS가 반응하게 하는 편을
선호하세요. JS에서 픽셀을 계산한다면 onChange로부터 다시 생성하세요 —
snapshot에서 한 번 읽은 값은 고정되어 다음 리사이즈에서 어긋납니다. 예약된 네
개의 --wippy-surface-* 이름을 직접 방출하지 말고, @property /
CSS.registerProperty()로 등록하지도 마세요 — 등록은 호스트의 "블록 축 사용 불가"
신호를 무력화하여 콘텐츠 사이징 앱이 스스로를 컨테이너 사이징이라고 조용히
보고하게 만듭니다. 하위 선언은 상속된 값을 가려 페이지를 서피스에서 떼어냅니다.
21. 서드파티 번들 CSS — 수동
보통 편집할 수 없습니다. 선호 순서대로: 라이브러리가 host.surface에서 제공하는
브레이크포인트/너비를 받아들이도록 설정하거나, 자체 컨테이너로 감싸서 변환하거나,
페이지를 iframe 엔진에 고정하고(wippy.renderEngine: "iframe") 창 기반 동작을
받아들이세요. 이를 자동으로 찾아내는 빌드 타임 스캔은 아직 출시되지 않았습니다.
22. 중첩 컨테이너와 cq* 폴백 함정 — 수동
컨테이너 단위는 필요한 축을 가진 가장 가까운 컨테이너에 대해 해석됩니다. 두 가지 결과가 따릅니다:
.card { container-type: inline-size; } /* 블록 축이 없음 */
.card .thing { block-size: 25cqh; } /* ✗ 조용히 작은 뷰포트를 사용 */
cqh/cqb는 블록 축 컨테이너를 찾지 못해도 오류를 내지 않습니다 — 작은 뷰포트로
폴백하여 그럴듯하지만 틀린 값을 렌더링합니다. 서피스의 블록 축이 필요하면
var(--wippy-surface-height, <fallback>)를 사용하세요. 루트에 고정되어 있어 더
가까운 컨테이너가 가로챌 수 없고, 사용할 수 없을 때 눈에 띄게 폴백합니다.
컴포넌트 쿼리는 대체가 아니라 추가입니다. 중첩된 컨테이너 안에서도
wippy-surface는 여전히 페이지의 영역을 가리킵니다.
뷰포트 단위
| 기존 | 사용 | 비고 |
|---|---|---|
100vw |
var(--wippy-surface-width) |
콘텐츠 박스. 레시피 16 참조 |
1vw / 37vw |
calc(var(--wippy-surface-width-unit) * 37) 또는 37cqw |
단위는 1% |
100vh |
var(--wippy-surface-height) |
컨테이너 사이징에서만 |
1vh / 37vh |
calc(var(--wippy-surface-height-unit) * 37) |
컨테이너 사이징에서만 |
vmin |
min(var(--wippy-surface-width), var(--wippy-surface-height)) |
컨테이너 사이징에서만 — 두 축이 모두 필요 |
vmax |
max(var(--wippy-surface-width), var(--wippy-surface-height)) |
컨테이너 사이징에서만 |
vi / vb |
cqi / cqb, 또는 물리 변수 |
논리적 단위. 서피스 변수는 물리적입니다 |
sv* / lv* / dv* |
var(--wippy-surface-*) |
별도의 대응물이 없습니다. 이들은 패널에 없는 브라우저 크롬 상태를 기술합니다. 서피스에는 크기가 하나뿐입니다 |
sv*/lv*는 실제 CSS 단위입니다 — "서피스"를 의미하지 않습니다.
계산
/* 이전 */ block-size: calc(100vh - 4rem);
/* 이후 */ block-size: calc(var(--wippy-surface-height, 400px) - 4rem);
폴백은 100vh 대신 일부러 고정되고 명백히 틀린 값을 씁니다 — 아래 "폴백 뒤에 누락된 계약을 숨기지 말 것"을 보세요. 이는 인라인 축보다 블록 축에서 더 중요합니다. 높이는 계약이 없는 경우뿐 아니라 모든 콘텐츠 사이징 배치에서 유효하지 않으므로, 100vh 폴백은 앱이 처음 임베드되는 순간 조용히 창 높이를 렌더링합니다.
min()/max()/clamp()는 그대로 변환됩니다. 그 안의 단위만 치환하세요.
100%가 서피스 값보다 나은 경우
엘리먼트가 부모를 채워야 한다면 100% 또는 w-full을 사용하세요.
페이지의 영역이 특별히 필요할 때에만 --wippy-surface-width에 손을 뻗으세요 —
보통은 조상이 더 좁아서 그것을 벗어나고 싶은 경우입니다. 부모 상대여야 할 것을
루트에 고정하는 것이 바로, 어떤 중첩 깊이에서는 맞고 다른 깊이에서는 틀린
레이아웃이 되는 원인입니다.
폴백 뒤에 누락된 계약을 숨기지 말 것
/* ✗ */ inline-size: var(--wippy-surface-width, 100vw);
이는 계약이 없을 때 창 너비를 렌더링합니다 — 계약이 막으려던 바로 그 버그를,
보이지 않게 만든 것입니다. 눈에 띄게 실패하게 두거나, 눈치챌 수 있도록 명백히
틀린 고정 폴백(400px)을 고르세요.
오버레이
서피스 계약은 position: fixed를 포착하지 않습니다 — container-type은 레이아웃
컨테인먼트 없이 독립적인 포매팅 컨텍스트를 만들므로, 쿼리 컨테이너는
contain: none으로 계산되어 아무것도 앵커하지 않습니다. 이는 Chromium, Firefox,
WebKit에서 검증되었습니다. PrimeVue 오버레이와 직접 만든 fixed 오버레이 모두 계속
동작하므로 위치 지정은 마이그레이션이 필요 없습니다.
크기 지정은 다릅니다. 서피스를 덮으려는 오버레이는 inset: 0을 써야 합니다 —
브라우저 창을 측정하여 다중 패널 호스트에서 넘쳐버리는 100vw/100vh도 아니고,
콘텐츠 사이징에서 사용할 수 없는 var(--wippy-surface-height)도 아닙니다. 두 엔진
모두에서 동작해야 한다면 inset: 0을 앱 자체의 position: relative 루트 안의
position: absolute와 짝지으세요. position: fixed는 바로 아래에 설명하는 이유로
iframe 엔진에서만 올바릅니다.
주의가 필요한 것은 계약이 아니라 엔진입니다. Web Fragment 엔진에서
position: fixed는 여러분의 패널이 아니라 호스트 창에 대해 해석됩니다.
렌더 엔진을 참고하고, 그것이 중요하다면
wippy.renderEngine: "iframe"으로 앱을 고정하세요.
호스트가 중재하는 오버레이 배치와 host.surface 스크롤 헬퍼는
아직 출시되지 않았습니다.
체크리스트
- 각 규칙을 분류하세요(페이지 / 컴포넌트 / 선호 / 의도적 창).
- 페이지 의도의 지오메트리를
@container wippy-surface로 변환하세요. - 뷰포트 단위를 서피스 변수로 교체하세요.
html/body를 겨냥하던 규칙을 여러분 자신의 루트 엘리먼트로 옮기세요.em브레이크포인트를 다시 확인하세요.- 블록 축에 의존한다면
requirements를 선언하세요. - 페이지를 두 엔진 그리고 두 사이징 모두에서 실행하세요 — 이 마이그레이션이
실제로 좌우하는 것은 컨테이너와 콘텐츠이며, 앱은 라우팅되지 않고 임베드될 때
언제나 콘텐츠 사이징입니다.
host.surface.snapshot.sizing으로 어느 쪽인지 확인하고, 블록 축 동작은host.surface.supports('block-size')로 게이팅하세요.