Surface Migration

Classification: partial migration recipe collection. Each before/after block converts one isolated pattern. Apply the decision tree to the complete stylesheet, then verify the page in both render engines and both sizing modes.

Recipes for converting an existing micro frontend app from viewport-based responsiveness to the surface contract.

Every recipe is labelled:

Label Meaning
automatic Mechanical. The converted rule means the same thing.
conditional Safe only when a stated precondition holds. Check it.
manual Needs a human decision; there is no single correct rewrite.
not convertible No container-query form exists. Use host.surface or keep the viewport behavior deliberately.

Each recipe below presents one technique in isolation. The Web Host repository includes a runnable page that combines them and is covered by its test suite.

Recipes that depend on unshipped work — Tailwind surface-* variants, build-time diagnostics, host-mediated scrolling, hit testing — are marked not yet shipped and describe only what exists today.


Decision tree: what is this rule about?

Before converting anything, classify the intent. A mechanically correct conversion is still wrong when the original rule was not surface-relative.

Does the rule respond to how much room THIS PAGE has?
├── yes → convert to @container wippy-surface        (recipes 1-8)
├── no, it responds to one COMPONENT's width
│        → give that component its own container      (recipe 22)
├── no, it responds to a user/device PREFERENCE
│        → leave it as @media                         (recipe 13)
└── no, it deliberately tracks the BROWSER WINDOW
         (a true full-window overlay)
         → leave it, and document why

If you cannot tell, leave it and revisit. An unconverted media query is merely non-portable; a wrongly-converted one is silently broken.


1. max-width → inline-size <= — automatic

/* before */ @media (max-width: 640px)                      { .nav { display: none } }
/* after  */ @container wippy-surface (max-width: 640px)    { .nav { display: none } }

2. min-width → inline-size >= — automatic

/* before */ @media (min-width: 640px)                      { .sidebar { display: block } }
/* after  */ @container wippy-surface (min-width: 640px)    { .sidebar { display: block } }

3. A bounded width range — automatic

/* before */ @media (min-width: 640px) and (max-width: 1024px) { … }
/* after  */ @container wippy-surface (640px <= width <= 1024px) { … }

The range syntax is supported in all engines the surface contract targets. The and form also works if you prefer it.

4. Multiple breakpoints, cascade order preserved — automatic

Container queries do not change specificity or ordering. Convert each block and keep them in the same source order:

@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. Height queries — conditional (container sizing only)

/* after */ @container wippy-surface (min-height: 500px) { .tall-only { display: block } }

Precondition: the page is container-sized. In content sizing the page's height is its own content, so height queries never match. Declare the dependency so it fails loudly rather than silently:

{ "wippy": { "surface": { "contract": 1, "requirements": ["block-size"] } } }

6. Aspect-ratio queries — conditional (container sizing only)

/* before */ @media (min-aspect-ratio: 16/9)                     { … }
/* after  */ @container wippy-surface (min-aspect-ratio: 16/9)   { … }

Same precondition as recipe 5: aspect ratio needs both axes.

7. Orientation queries — conditional (container sizing only)

@container wippy-surface (orientation: landscape) describes your panel's shape, which is usually what you meant. If you genuinely meant the device, that is a media query — keep it (recipe 13).

8. Height / aspect / orientation in content sizing — not convertible

There is no block axis to query. Restructure so the layout depends on the inline axis. Do not fake it with cqh — see recipe 22.

You cannot switch the app to container sizing yourself: sizing is set by where the Web Host renders the app, not by anything in its package. If the layout truly cannot work without the block axis, declare requirements: ["block-size"] so a content-sized placement is refused outright instead of rendering wrong, and get the app rendered in a container-sized context (its own route or a layout panel). See "Container sizing and content sizing" in Surface Portability.

9. Geometry nested inside an environmental media query — manual

/* before */
@media (prefers-color-scheme: dark) and (min-width: 640px) { .panel { … } }

/* after — split: the preference stays, the geometry moves */
@media (prefers-color-scheme: dark) {
  @container wippy-surface (min-width: 640px) { .panel { … } }
}

Manual because nesting order can change which declarations win when the two conditions previously combined in one prelude. Re-check the result.

10. Comma-OR branches — manual

/* before */ @media (max-width: 480px), (min-width: 1200px) { … }

A comma is OR. Splitting it into two @container blocks preserves OR only if the two blocks are otherwise identical and adjacent; if you accidentally nest them you have turned OR into AND, which matches nothing. Duplicate the declarations into two sibling blocks:

@container wippy-surface (max-width: 480px)  { … }
@container wippy-surface (min-width: 1200px) { … }

11. not, only, complex Boolean — manual

only is a media-type artifact and has no container equivalent — drop it. not inverts the whole condition in both syntaxes, but precedence differs once you mix and/or; parenthesise explicitly rather than trusting the original grouping.

12. screen / print combined with geometry — manual

Media types have no container form. Keep the type as a media query and nest the geometry inside it (as in recipe 9). Print layout in particular should usually stay entirely viewport/page-based.

13. Preferences stay media queries — not convertible (and correct as-is)

prefers-color-scheme, prefers-contrast, prefers-reduced-motion, forced-colors, hover, pointer, any-pointer. @container supports size features only. Converting these produces a rule that never matches.

14. em breakpoints — manual

@media (min-width: 40em) resolves em against the initial font size. @container wippy-surface (min-width: 40em) resolves it against the container's font size. If those differ, your breakpoint moves silently. Convert to px, or verify the container's computed font-size first.

15. rem breakpoints — manual

rem is not root-relative inside @media. Media-query conditions resolve both em and rem against the initial font size — the browser default, independent of any author CSS — while @container resolves them the ordinary way, against the actual computed root/container font size.

So the two are already unequal the moment your root font size differs from the browser default, with nothing changing at runtime. The common html { font-size: 62.5% } reset is enough to move a converted breakpoint from 640px to 400px.

"Nothing changes the root font size" is therefore not a sufficient precondition. Convert to px, exactly as for em (recipe 14), unless the root's computed font size provably equals the browser default.

16. Viewport vs content-box scrollbar boundary — conditional

100vw includes the classic scrollbar gutter. In the iframe engine the surface width is the query box's content box inside the app's document, so it does not: on a page with a document scrollbar the converted value is narrower by the scrollbar width, which is usually the correction you wanted (100vw causing horizontal overflow is a classic bug).

The fragment engine measures a host-document wrapper that the content's scrolling does not narrow, so it does not apply that correction. Same panel, same scrolling content, widths differing by a scrollbar. The condition on this recipe is therefore which engine the app runs in, not merely whether the alignment is pixel-exact.

17. Rules targeting html / body — manual

A container query never styles its own container, and a rule aimed at html or body fails in both engines — for different reasons:

  • Iframe engine: the host wraps your body content in the surface box, so html and body are ancestors of the query container. A @container rule cannot reach an ancestor.
  • Fragment engine: the opposite topology — the query box is a host-document wrapper above your content — but a literal body selector still fails, because the reflected document is renamed to wf-html / wf-body.

Either way the fix is the same, and it is engine-safe:

/* ✗ silently never matches */
@container wippy-surface (min-width: 640px) { body { display: flex } }

/* ✓ move it to your own root inside the surface */
@container wippy-surface (min-width: 640px) { #app { display: flex } }

HTML-level resource selection has no container-query form. Either drive it from JS with host.surface.onChange, or move the art direction into CSS (background-image under an @container rule) where the contract applies.

19. Geometry matchMedia() → host.surface — automatic

// before
const mq = matchMedia('(min-width: 640px)')
mq.addEventListener('change', render)

// after
import { host } from '@wippy-fe/proxy'

const off = host.surface.onChange(s => render(s.width >= 640))
render(host.surface.snapshot.width >= 640)
// call off() on teardown

Keep matchMedia for preference queries — it is only geometry that is wrong.

20. Runtime CSS, adopted stylesheets, CSS-in-JS — manual

Prefer emitting @container wippy-surface (...) rules and letting CSS respond. If you compute pixels in JS, regenerate from onChange — a value read once from snapshot is frozen and desyncs on the next resize. Never emit the four reserved --wippy-surface-* names yourself, and never register them with @property / CSS.registerProperty() — registration defeats the host's "block axis unavailable" signal, so a content-sized app silently reports itself as container-sized; a descendant declaration shadows the inherited value and unpins your page from the surface.

21. Third-party bundled CSS — manual

You usually cannot edit it. In order of preference: configure the library to accept a breakpoint/width you supply from host.surface; wrap it in your own container and translate; or pin the page to the iframe engine (wippy.renderEngine: "iframe") and accept window-based behavior. Build-time scanning to find these automatically is not yet shipped.

22. Nested containers and the cq* fallback trap — manual

Container units resolve against the nearest container that has the axis they need. Two consequences:

.card { container-type: inline-size; }   /* has NO block axis */
.card .thing { block-size: 25cqh; }      /* ✗ silently uses the small viewport */

cqh/cqb do not error when no block-axis container is found — they fall back to the small viewport and render a plausible wrong number. Use var(--wippy-surface-height, <fallback>) when you want the surface's block axis: it is root-pinned, so a nearer container cannot intercept it, and it visibly falls back when unavailable.

Component queries are additive, not a replacement: wippy-surface still refers to the page's area from inside a nested container.


Viewport units

Was Use Notes
100vw var(--wippy-surface-width) content box; see recipe 16
1vw / 37vw calc(var(--wippy-surface-width-unit) * 37) or 37cqw unit is 1%
100vh var(--wippy-surface-height) container sizing only
1vh / 37vh calc(var(--wippy-surface-height-unit) * 37) container sizing only
vmin min(var(--wippy-surface-width), var(--wippy-surface-height)) container sizing only — needs both axes
vmax max(var(--wippy-surface-width), var(--wippy-surface-height)) container sizing only
vi / vb cqi / cqb, or the physical variables logical; the surface variables are physical
sv* / lv* / dv* var(--wippy-surface-*) no separate equivalents. These describe browser-chrome states a panel does not have; the surface has one size

sv*/lv* are real CSS units — they do not mean "surface".

Calculations

/* before */ block-size: calc(100vh - 4rem);
/* after  */ block-size: calc(var(--wippy-surface-height, 400px) - 4rem);

The fallback is deliberately fixed and obviously wrong rather than 100vh — see "Do not hide a missing contract behind a fallback" below. That matters more on the block axis than the inline one: the height is invalid on every content-sized placement, not only where the contract is absent, so a 100vh fallback silently renders window height the first time the app is embedded.

min()/max()/clamp() convert unchanged; substitute the units inside them.

When 100% is better than a surface value

If an element should fill its parent, use 100% or w-full. Reach for --wippy-surface-width only when you need the page's area specifically — typically because an ancestor is narrower and you want to escape it. Root-pinning something that should be parent-relative is how a layout ends up correct at one nesting depth and wrong at another.

Do not hide a missing contract behind a fallback

/* ✗ */ inline-size: var(--wippy-surface-width, 100vw);

That renders window-width when the contract is absent — the exact bug the contract exists to prevent, made invisible. Let it fail visibly, or pick a fixed fallback that is obviously wrong (400px) so it is noticed.


Overlays

The surface contract does not capture position: fixed — container-type establishes an independent formatting context without layout containment, so a query container computes contain: none and anchors nothing. This is verified across Chromium, Firefox and WebKit. PrimeVue overlays and hand-rolled fixed overlays both keep working, so positioning needs no migration.

Their sizing does. An overlay meant to cover the surface should use inset: 0 — not 100vw/100vh, which measure the browser window and overshoot in a multi-panel host, and not var(--wippy-surface-height), which is unavailable in content sizing. Pair inset: 0 with position: absolute inside a position: relative root of the app's own if it must work in both engines; position: fixed is correct only in the iframe engine, for the reason directly below.

What does need attention is the engine, not the contract: in the Web Fragment engine position: fixed resolves against the host window, not your panel. See Render Engines and pin the app with wippy.renderEngine: "iframe" if that matters.

Host-mediated overlay placement and host.surface scroll helpers are not yet shipped.


Checklist

  1. Classify each rule (page / component / preference / deliberate window).
  2. Convert page-intent geometry to @container wippy-surface.
  3. Replace viewport units with the surface variables.
  4. Move any rule that targeted html/body onto your own root element.
  5. Re-check em breakpoints.
  6. Declare requirements if you depend on the block axis.
  7. Run the page in both engines and in both sizings — container and content are what this migration actually turns on, and an app is content-sized whenever it is embedded rather than routed. Check which one you are in with host.surface.snapshot.sizing, and gate block-axis behavior on host.surface.supports('block-size').