Rebounder Tech Blog

Written by the people who actually run these systems in production.

@media(max-width:899px) Leaks Into a Scaled Signage Preview

Published About 5 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

Even scaled down by transform:scale() at 1280×720, @media(max-width:899px) checks real viewport width, not stage size, so a PC browser under 899px leaks in phone layout.

Conclusion

Even when a 1280×720 stage is scaled down with transform: scale(), the @media (max-width: 899px) rule written for real phones evaluates the browser’s actual viewport width, not the stage’s visual size — so narrowing a PC browser below 899px pulls in the phone’s stacked layout. A scaled-to-fit preview should always render as a fixed 16:9 rectangle, but because @media only ever fires on real browser width, layout built for real phones — single-column scrolling UI, pager dots — leaks into the shrunk stage and breaks it.

Symptom

There’s a wrapper component, ScaledSignageBoard, that renders a signage board inside the editor at a near-real look while scaling it to fit any given width. Inside, a .stage fixed at 1280×720 is shrunk with transform: scale(), and the outer .frame keeps aspect-ratio: 16 / 9 with overflow: hidden to clip it. This part was already pinned to a 720p baseline by an earlier fix for large monitors (described below).

But leaving this preview open while narrowing the browser window — or opening the editor in a narrow split-view panel — caused single-column scrolling UI and pager dots to leak into what should always be a fixed 16:9 stage. Depending on the pattern, a two-column board would suddenly stack vertically, and header height and position would shift too.

.stage itself never changed size — it stayed 1280px by 720px. So the cause wasn’t the stage’s dimensions; it was the browser’s actual width.

Cause

.signageRoot contains responsive rules that rebuild the board into a single-column scrolling layout, meant for real signage hardware (physical displays of unknown aspect ratio):

@media (max-width: 899px) {
  .container { /* stack into one column */ }
  .p2Grid { /* pattern2 stacked */ }
  .p4Grid { /* pattern4 stacked */ }
  .p5Grid { /* pattern5 stacked */ }
  /* … */
}

This was written for narrow real-world viewports — actual phones, or physically portrait-mounted displays — and works correctly on that hardware, where the assumption holds.

But @media doesn’t evaluate the transform: scale() applied to the stage; it evaluates the actual width of the browser (or whichever ancestor provides the viewport). ScaledSignageBoard is a wrapper that renders the same .signageRoot shrunk inside a fixed 1280×720 .stage, so regardless of the stage’s own size, @media (max-width: 899px) fires as soon as the open browser’s real viewport width drops to 899px or less — applying the device-oriented stacked layout inside the stage too.

This gap had actually been noticed from the start. When a marker class, .scaledStage, was introduced to pin the 720p baseline for large monitors (≥1800px), a comment in the same file said (right before that fix):

(Neutralizing ≤899px mobile stacking is a separate axis — it needs the full §12 mobile
path restored, so it's not included in this block. The scaled preview's main entry
point is office PCs. Handle it separately if clipping is confirmed on a real phone.)

In other words: the decision was that “neutralizing ≤899px mobile stacking is a separate concern, and since the preview’s main entry point is office PCs, it would be handled separately if confirmed.” But in practice, the same code path triggers just by narrowing a PC browser below 899px — no real phone required.

The fix

Using the same approach as the large-monitor fix, a @media (max-width: 899px) block scoped to the stage was added, restoring each property that the device-oriented stacked layout changes back to its original desktop default, one by one:

@media (max-width: 899px) {
  .scaledStage .signageRoot { overflow: hidden; }
  .scaledStage .container {
    grid-template-columns: 70% 30%;
    grid-template-areas: "info ad";
    height: 100%;
    padding-top: var(--header-height);
  }
  .scaledStage .p2Grid { overflow: hidden; }
  .scaledStage .p4Grid { overflow: hidden; }
  .scaledStage .p5Grid {
    display: grid;
    grid-template-columns: 2fr 1fr;
    grid-template-rows: minmax(0, 1fr) auto;
    grid-template-areas:
      "notice schedule"
      "foot foot";
  }
  /* pattern3/4/5's large header (sticky, from §12) also restored to default */
  .scaledStage .signageRoot.p3Root,
  .scaledStage .signageRoot.p4Root,
  .scaledStage .signageRoot.p5Root {
    --header-height: 64px;
  }
  /* … each pattern's override restored one at a time */
}

A .scaledStage <selector> has higher specificity than the plain <selector> inside the device-oriented @media (max-width: 899px) block. So even when the browser’s real viewport width drops to 899px or less and the device rule fires, this restoring rule always wins under .scaledStage, and the stage keeps its 16:9 desktop skeleton.

What got restored were layout-shape properties — grid-template-columns, flex values, overflow — while clamp(…vw…)-based font sizes were left untouched. At a phone-equivalent width the fonts do shrink, but that’s just what you’d expect from viewing a scaled-down copy of a 50-inch board; the layout itself no longer breaks.

Because this restoration lives only in selectors rooted in the .scaledStage marker class, it has no effect on the live signage display or on the public /signage route — neither of those carries .scaledStage in its DOM, so the device-oriented @media (max-width: 899px) stacked layout still applies there unchanged.

Preventing a repeat

The reason this gap was visible from the start is preserved in the comment left with the large-monitor fix. The judgment that “≤899px mobile stacking is a separate axis” and “the scaled preview’s main entry point is office PCs” was correct for the targeted case of an actual phone — but it missed that the same condition also holds just by narrowing a PC browser window. @media doesn’t detect device type; it only ever looks at the viewport width at that moment, so the premise “this won’t happen except on a real phone” doesn’t hold in the first place.

In practice, this restoration was added as a further fix on the very same day the comment recording that judgment was written. A stage’s @media gaps in both directions — large (≥1800px monitors) and small (≤899px mobile) — needed to be closed under one shared principle: inside the stage, always pin to the 720p baseline.

Frequently asked questions

Q1Why does the mobile @media still fire when the stage is scaled down by transform:scale()?

@media (max-width: 899px) doesn't look at the size after transform shrinks it — it evaluates the browser's actual viewport width. Even with the stage fixed at 1280×720 and scaled down, the phone profile fires once the open browser's real viewport width drops to 899px or less.

Q2Does this only matter when opening the preview on a real phone?

No. Narrowing a PC browser window, or opening the editor in a narrow split view, also brings the real viewport width below 899px. Since this fires on actual browser width rather than device type, it reproduces on a PC once that condition is met.

Q3Does the same regression affect the real signage display or the public /signage page?

No. The fix is scoped to selectors rooted in the `.scaledStage` preview marker class only. Neither the real device display nor the public `/signage` route carries that class, so the `@media (max-width: 899px)` stacked layout still applies there as intended.

Environment verified

  • Next.js ^16 / React ^19 (apps/web)
  • Fixed same-day on 2026-07-12, right after a separate fix for large monitors

What this article is based on

  • CSS file lines 2490-2510commit a9f97dd
  • CSS file lines 2499-2707commit 84b1689

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.