components/Stage/Stage.jsxjavascript
import PropTypes from "prop-types";
import { useLocation } from "react-router";
import { useConfig } from "../../services/config/config";
import { useElementSize } from "../../hooks/elementSize";
import { resolveModeForPath } from "../../services/presentationModes";
import "./Stage.scss";
/**
* Whole-app scale wrapper. Renders `children` inside a fixed-size box and
* scales that box down to fit whatever real screen it ends up on via CSS
* `transform: scale()`.
*
* Replaces the previous per-page `calc(1rem / X)` + html-root-font-size
* scaling scheme: instead of keeping dozens of components' em/rem units in
* sync with two independent, uncoordinated breakpoint systems (the app's
* own root font-size AND @ui-marcom/shared's internal viewport-width
* breakpoints), every pixel authored anywhere in `children` scales together
* as one image - which system decided a given size no longer matters once
* it's inside this box.
*
* Each sales app has one fixed, content-driven orientation (confirmed with
* the content team: everything is landscape except Rad Enablement, which is
* portrait-only - see PRESENTATION_MODES' `orientation` field, matched
* against the current route via resolveModeForPath()). "/" (app selection)
* matches no mode and defaults to landscape.
*
* The reference box has two independent axes, not one:
* - WHICH physical kiosk this is (`stageDevice` config value: "4k" or
* "ipad") picks that device's own landscape width/height pair. A 4K panel
* and an iPad need separate pairs (not a shared one) because an iPad is a
* 2x Retina display - its CSS point resolution is roughly half its
* physical pixel count, very different from a 4K panel's 1:1 mapping.
* - Orientation then swaps THAT device's own pair for portrait routes - "a
* physically rotated panel" is only true *within* one device's pair, not
* across devices. Treating portrait as a single cross-device resolution
* was tried first and broke both ends: Rad Enablement letterboxed on the
* iPad (wrong aspect ratio), and would have upscaled/blurred on a
* rotated 4K panel (wrong magnitude) - see README.md's "Scaling (Stage)"
* section.
*
* The scale factor is never allowed above 1 in practice: the reference
* resolution is deliberately the *largest* target device in its
* orientation, so real screens only ever scale down. Upscaling would blur
* the WebGL globe (InteractiveGlobe.jsx), whose canvas render resolution
* three.js derives from layout size, not from a CSS transform applied to an
* ancestor.
*
* `stage-inner` also carries `data-stage-device` so per-device CSS overrides
* (e.g. RadEnablement.scss's iPad content-density correction, since the
* same em-authored content necessarily renders larger on the iPad's
* ~2x-smaller reference box) can target a device without their own JS.
*
* Must be mounted inside the router (see HashRouter.jsx), not in App.jsx -
* it reads its config (stageDevice and the stageRefWidth/Height /
* stageIpadRefWidth/Height pairs) via useConfig() and the current route via
* useLocation(), both of which need router context and would silently fail
* (or throw) outside a <HashRouter>.
*/
export const Stage = ({ children }) => {
const { stageDevice, stageRefWidth, stageRefHeight, stageIpadRefWidth, stageIpadRefHeight } =
useConfig();
const { pathname } = useLocation();
const [outerRef, { width, height }] = useElementSize({
width: window.innerWidth,
height: window.innerHeight,
});
const [deviceLandscapeWidth, deviceLandscapeHeight] =
stageDevice === "ipad"
? [stageIpadRefWidth, stageIpadRefHeight]
: [stageRefWidth, stageRefHeight];
const isPortrait = resolveModeForPath(pathname)?.orientation === "portrait";
const refWidth = isPortrait ? deviceLandscapeHeight : deviceLandscapeWidth;
const refHeight = isPortrait ? deviceLandscapeWidth : deviceLandscapeHeight;
// "contain" scaling: shrink just enough that both dimensions fit, so the
// reference box never gets cropped - whichever dimension is relatively
// narrower on the real screen determines the factor, the other dimension
// letterboxes.
const scale = Math.min(width / refWidth, height / refHeight) || 1;
return (
<div className="stage-outer" ref={outerRef}>
<div
className="stage-inner"
data-stage-device={stageDevice}
style={{
width: refWidth,
height: refHeight,
transform: `scale(${scale})`,
}}>
{children}
</div>
</div>
);
};
Stage.propTypes = {
children: PropTypes.node.isRequired,
};