Loomguides

Previews on a phone

What a preview does on a 390px screen, and the `?base=` opt-out for a wide composition.

A preview lays the scene out against the stage it is given. Whatever the mount measures is the Roblox viewport. It is mirrored onto Workspace.CurrentCamera.ViewportSize and fed to the layout engine. So a phone gets a phone-sized viewport and the scene reflows exactly as the engine reflows there.

Nothing here needs configuration. The generated pages size with dvh, so a full-height stage does not hang under a mobile browser’s toolbars.

The stage is the viewport

Text re-wraps, a UIListLayout with Wraps re-flows, AutomaticSize re-measures, and a scene branching on ViewportSize takes its narrow path. A layout of offsets wider than the screen runs off the edge and is clipped. That is what it does on a real device, and usually what you opened the preview on a phone to find out.

Why zooming is not the default

Zooming a narrow device to a fixed 960-wide logical viewport reads as the same layout drawn smaller. It is not — it is a different layout, the one the engine gives at 960, shrunk to fit. TextWrapped text keeps the line breaks it had at 960, a UIListLayout with Wraps keeps its row count, and AutomaticSize settles at the wide measurement. Everything looks fine, just small, and is wrong in exactly the places a narrow viewport is the thing being checked.

?base= keeps a wide composition

The zoom is still there, because a page embedding a preview at a fixed narrow width sometimes wants the composition rather than the reflow. It just has to be asked for:

ValueEffect
absentThe default. The scene lays out against the real viewport at every width, on every device.
?base=<px>Keep this logical width. Below it the stage scales down to fit; at or above it nothing is applied.
?base (bare)The same, at 960. A value that cannot be read means this too — the param was typed to turn the zoom on.
?base=none (or off, or 0)The default, spelled out, for a host page templating the param.
A 1280-wide logical viewport, whatever the frame is
/loom-preview/index.html?target=src%2Fscenes%2FCounter.loom.tsx&chrome=none&base=1280

960 is the bare-?base fallback rather than a constant with one right answer. The wider the base, the more desktop layout survives and the smaller everything is drawn. It is wide enough for a two-column layout, and small enough that a phone renders at roughly 40%. A 1280 base gives ~30%, where body text stops being readable.

With a base in effect the world reads the mount’s untransformed layout size, so you are previewing the wide layout drawn smaller. The debug panel shows both boxes and the factor between them.

?base= sits alongside the rest of the URL contract.

Pointer coordinates follow the scale

Whenever ?base= is scaling the stage, an on-screen pixel is not a layout pixel. The renderer converts back by reading the mount’s rendered-to-layout ratio, so hit testing lands where the scene looks like it is:

  • MouseEnter / Activated / InputChanged positions
  • GetMouseLocation
  • Wheel deltas

Unscaled, which is the default, the ratio is 1 and the conversion is a no-op.

ScrollingFrames scroll from a touch drag

There is no wheel on a phone, so a drag inside a ScrollingFrame moves CanvasPosition with the same clamping the wheel path uses. Past a small slop threshold it stops counting as a tap, so dragging past a button does not activate it.

Two deliberate limits:

  • Only ScrollingFrames opt out of native touch panning. A preview embedded in a docs page never traps the reader’s scroll. The page keeps scrolling normally everywhere else.
  • Taps do not wait for the double-tap-zoom timeout. A tap registers immediately rather than after the browser’s ~300ms delay.

In full-chrome gallery mode the 248px sidebar becomes a top bar with a targets button. Picking a target closes it again. ?chrome=none is unaffected — there is no chrome to stack, so embedded previews get the touch handling and nothing else.