@kreativa/device-preview 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kreativa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @kreativa/device-preview
2
+
3
+ App-agnostic device frame + catalog for rendering **web** previews of mobile UI
4
+ at real device window sizes and safe-area insets. Render any React tree (e.g. a
5
+ `react-native-web` screen) inside a device bezel that lays out at the device's
6
+ true logical size, then scales for display — so the preview never lies about
7
+ layout.
8
+
9
+ It deliberately knows nothing about your app, theme system, or React Native: you
10
+ map your theme to a `background` colour + `statusBarStyle` and wrap the children
11
+ in whatever providers they need.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @kreativa/device-preview
17
+ ```
18
+
19
+ `react` is a peer dependency (>=18). The package renders DOM, so it's for web /
20
+ `react-native-web` previews.
21
+
22
+ ## Usage
23
+
24
+ ```tsx
25
+ import {
26
+ DeviceFrame,
27
+ IPHONE_17_PRO,
28
+ displayWidthFor,
29
+ } from '@kreativa/device-preview';
30
+
31
+ function Preview() {
32
+ return (
33
+ <DeviceFrame
34
+ device={IPHONE_17_PRO}
35
+ statusBarStyle="light" // light glyphs on a dark UI
36
+ background="#120a18"
37
+ displayWidth={402} // CSS px; height follows the aspect ratio
38
+ >
39
+ <YourScreen />
40
+ </DeviceFrame>
41
+ );
42
+ }
43
+ ```
44
+
45
+ Feed the device's real safe-area insets into your layout (e.g. inject
46
+ `react-native-safe-area-context`'s `SafeAreaInsetsContext`/`SafeAreaFrameContext`
47
+ from `device.safeArea` / `device.logicalWidth × device.logicalHeight`) so screens
48
+ get the correct insets at every scale.
49
+
50
+ ## Exports
51
+
52
+ | Export | What it is |
53
+ |--------|------------|
54
+ | `DeviceFrame` | The presentational bezel (status bar, Dynamic Island, home indicator). |
55
+ | `DEVICES`, `IPHONE_17_PRO`, `getDevice(id)`, `DEFAULT_DEVICE_ID` | The device catalog. |
56
+ | `DevicePicker` | A bare, host-styled `<select>` over the catalog (auto-locks with one device). |
57
+ | `ScalePicker` | A `<select>` over the scale modes. |
58
+ | `displayWidthFor(device, mode, ctx)`, `SCALE_MODES` | Compute the rendered width for a scale mode. |
59
+ | Types | `DeviceProfile`, `DeviceInsets`, `DynamicIsland`, `StatusBarStyle`, `ScaleMode`, `ScaleContext`. |
60
+
61
+ ### Scale modes
62
+
63
+ Mirrors the iOS Simulator's window-scale menu (the modes a browser can reproduce
64
+ faithfully). Layout is always computed at the device's logical size — only the
65
+ on-screen zoom changes:
66
+
67
+ - `point` — 1 device point ↔ 1 CSS px (the design-true default).
68
+ - `pixel` — 1 device pixel ↔ 1 physical monitor pixel (uses `devicePixelRatio`).
69
+ - `fit` — scale to fit the available preview area.
70
+
71
+ ```ts
72
+ const width = displayWidthFor(IPHONE_17_PRO, 'pixel', {
73
+ devicePixelRatio: window.devicePixelRatio,
74
+ });
75
+ ```
76
+
77
+ The Simulator's "Physical Size" is intentionally omitted — a browser can't read
78
+ the monitor's true PPI, so it can't render life-size accurately.
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Presentational device bezel for web previews. Renders one screen surface at
3
+ * the device's **logical** size (so `children` — typically a react-native-web
4
+ * tree — lay out exactly as they would on-device), then CSS-scales the whole
5
+ * surface down to `displayWidth`. The status bar, Dynamic Island, and home
6
+ * indicator are non-interactive overlays positioned in logical coordinates.
7
+ *
8
+ * It deliberately knows nothing about themes or React Native: the consumer maps
9
+ * its theme to `background` + `statusBarStyle` and wraps `children` in whatever
10
+ * providers (e.g. a SafeAreaProvider seeded from `device.safeArea`) they need.
11
+ */
12
+ import type { ReactNode } from 'react';
13
+ import type { DeviceProfile, StatusBarStyle } from './types';
14
+ interface DeviceFrameProps {
15
+ device: DeviceProfile;
16
+ /** Status-bar glyph style — usually derived from the theme's light/dark mode. */
17
+ statusBarStyle: StatusBarStyle;
18
+ /** Screen surface colour (the app/theme background). */
19
+ background: string;
20
+ /** Rendered width of the screen surface in CSS px. Height follows the aspect ratio. */
21
+ displayWidth?: number;
22
+ /** Faux clock shown in the status bar. */
23
+ time?: string;
24
+ children: ReactNode;
25
+ }
26
+ export declare function DeviceFrame({ device, statusBarStyle, background, displayWidth, time, children, }: DeviceFrameProps): import("react").JSX.Element;
27
+ export {};
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A bare `<select>` over the device catalog. Styling is left to the host (pass
3
+ * `style`/`className`) so it blends into whatever admin chrome consumes it.
4
+ */
5
+ import type { CSSProperties } from 'react';
6
+ import type { DeviceProfile } from './types';
7
+ interface DevicePickerProps {
8
+ value: string;
9
+ onChange: (deviceId: string) => void;
10
+ devices?: readonly DeviceProfile[];
11
+ /** Force the locked (disabled) state. Defaults to locked when there is only one device. */
12
+ disabled?: boolean;
13
+ style?: CSSProperties;
14
+ className?: string;
15
+ 'aria-label'?: string;
16
+ }
17
+ export declare function DevicePicker({ value, onChange, devices, disabled, style, className, 'aria-label': ariaLabel, }: DevicePickerProps): import("react").JSX.Element;
18
+ export {};
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A bare `<select>` over the scale modes. Styling is left to the host (pass
3
+ * `style`/`className`) so it matches whatever admin chrome consumes it.
4
+ */
5
+ import type { CSSProperties } from 'react';
6
+ import { type ScaleMode } from './scale';
7
+ interface ScalePickerProps {
8
+ value: ScaleMode;
9
+ onChange: (mode: ScaleMode) => void;
10
+ style?: CSSProperties;
11
+ className?: string;
12
+ 'aria-label'?: string;
13
+ }
14
+ export declare function ScalePicker({ value, onChange, style, className, 'aria-label': ariaLabel, }: ScalePickerProps): import("react").JSX.Element;
15
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { DeviceProfile } from './types';
2
+ /**
3
+ * iPhone 17 Pro (2025). Logical 402×874 pt (native 1206×2622 px @3×, 460 ppi),
4
+ * portrait safe-area insets top 62 / bottom 34, status-bar band 54 pt, ~55 pt
5
+ * display corner radius, and the pill-shaped Dynamic Island near the top.
6
+ *
7
+ * Sources: Apple tech specs (support.apple.com/en-us/125090) and
8
+ * useyourloaf.com/blog/iphone-17-screen-sizes/.
9
+ */
10
+ export declare const IPHONE_17_PRO: DeviceProfile;
11
+ /** Every device the previewer offers. Add new profiles here. */
12
+ export declare const DEVICES: readonly DeviceProfile[];
13
+ /** The device selected by default. */
14
+ export declare const DEFAULT_DEVICE_ID: string;
15
+ export declare function getDevice(id: string): DeviceProfile | undefined;
package/dist/index.cjs ADDED
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ var jsxRuntime = require('react/jsx-runtime');
4
+
5
+ // src/DeviceFrame.tsx
6
+ var BEZEL_PADDING = 12;
7
+ function DeviceFrame({
8
+ device,
9
+ statusBarStyle,
10
+ background,
11
+ displayWidth = 300,
12
+ time = "9:41",
13
+ children
14
+ }) {
15
+ const { logicalWidth, logicalHeight, cornerRadius, statusBarHeight, dynamicIsland } = device;
16
+ const scale = displayWidth / logicalWidth;
17
+ const displayHeight = logicalHeight * scale;
18
+ const ink = statusBarStyle === "light" ? "rgba(255,255,255,0.92)" : "rgba(0,0,0,0.85)";
19
+ return /* @__PURE__ */ jsxRuntime.jsx(
20
+ "div",
21
+ {
22
+ "data-testid": "device-frame",
23
+ "data-device": device.id,
24
+ style: {
25
+ padding: BEZEL_PADDING,
26
+ borderRadius: cornerRadius * scale + BEZEL_PADDING,
27
+ background: "#0c0605",
28
+ border: "2px solid rgba(255,248,233,0.28)",
29
+ boxShadow: "0 24px 50px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,248,233,0.18), 0 0 0 1px rgba(0,0,0,0.6)",
30
+ width: "fit-content"
31
+ },
32
+ children: /* @__PURE__ */ jsxRuntime.jsx(
33
+ "div",
34
+ {
35
+ style: {
36
+ position: "relative",
37
+ width: displayWidth,
38
+ height: displayHeight,
39
+ borderRadius: cornerRadius * scale,
40
+ overflow: "hidden",
41
+ background
42
+ },
43
+ children: /* @__PURE__ */ jsxRuntime.jsxs(
44
+ "div",
45
+ {
46
+ style: {
47
+ position: "absolute",
48
+ top: 0,
49
+ left: 0,
50
+ width: logicalWidth,
51
+ height: logicalHeight,
52
+ transform: `scale(${scale})`,
53
+ transformOrigin: "top left",
54
+ display: "flex"
55
+ },
56
+ children: [
57
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex" }, children }),
58
+ dynamicIsland ? /* @__PURE__ */ jsxRuntime.jsx(
59
+ "div",
60
+ {
61
+ "aria-hidden": true,
62
+ style: {
63
+ position: "absolute",
64
+ top: dynamicIsland.top,
65
+ left: "50%",
66
+ transform: "translateX(-50%)",
67
+ width: dynamicIsland.width,
68
+ height: dynamicIsland.height,
69
+ borderRadius: dynamicIsland.height / 2,
70
+ background: "#000",
71
+ pointerEvents: "none"
72
+ }
73
+ }
74
+ ) : null,
75
+ /* @__PURE__ */ jsxRuntime.jsx(StatusBar, { height: statusBarHeight, ink, time }),
76
+ /* @__PURE__ */ jsxRuntime.jsx(HomeIndicator, { width: logicalWidth, ink })
77
+ ]
78
+ }
79
+ )
80
+ }
81
+ )
82
+ }
83
+ );
84
+ }
85
+ function StatusBar({ height, ink, time }) {
86
+ const containerStyle = {
87
+ position: "absolute",
88
+ top: 0,
89
+ left: 0,
90
+ right: 0,
91
+ height,
92
+ display: "flex",
93
+ alignItems: "center",
94
+ justifyContent: "space-between",
95
+ paddingLeft: 28,
96
+ paddingRight: 28,
97
+ paddingTop: 14,
98
+ fontSize: 15,
99
+ fontWeight: 700,
100
+ color: ink,
101
+ pointerEvents: "none"
102
+ };
103
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { "aria-hidden": true, style: containerStyle, children: [
104
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: time }),
105
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
106
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: 13 }, children: "5G" }),
107
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { display: "inline-flex", gap: 2, alignItems: "flex-end" }, children: [
108
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { width: 3, height: 6, background: ink, borderRadius: 1 } }),
109
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { width: 3, height: 9, background: ink, borderRadius: 1 } }),
110
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { width: 3, height: 12, background: ink, borderRadius: 1 } })
111
+ ] })
112
+ ] })
113
+ ] });
114
+ }
115
+ function HomeIndicator({ width, ink }) {
116
+ return /* @__PURE__ */ jsxRuntime.jsx(
117
+ "div",
118
+ {
119
+ "aria-hidden": true,
120
+ style: {
121
+ position: "absolute",
122
+ bottom: 8,
123
+ left: "50%",
124
+ transform: "translateX(-50%)",
125
+ width: width * 0.36,
126
+ height: 5,
127
+ borderRadius: 3,
128
+ background: ink,
129
+ opacity: 0.55,
130
+ pointerEvents: "none"
131
+ }
132
+ }
133
+ );
134
+ }
135
+
136
+ // src/devices.ts
137
+ var IPHONE_17_PRO = {
138
+ id: "iphone-17-pro",
139
+ name: "iPhone 17 Pro",
140
+ logicalWidth: 402,
141
+ logicalHeight: 874,
142
+ scale: 3,
143
+ safeArea: { top: 62, bottom: 34, left: 0, right: 0 },
144
+ statusBarHeight: 54,
145
+ cornerRadius: 55,
146
+ dynamicIsland: { width: 126, height: 37, top: 11 }
147
+ };
148
+ var DEVICES = [IPHONE_17_PRO];
149
+ var DEFAULT_DEVICE_ID = IPHONE_17_PRO.id;
150
+ function getDevice(id) {
151
+ return DEVICES.find((device) => device.id === id);
152
+ }
153
+ function DevicePicker({
154
+ value,
155
+ onChange,
156
+ devices = DEVICES,
157
+ disabled,
158
+ style,
159
+ className,
160
+ "aria-label": ariaLabel = "V\xE4lj enhet"
161
+ }) {
162
+ const locked = disabled ?? devices.length <= 1;
163
+ return /* @__PURE__ */ jsxRuntime.jsx(
164
+ "select",
165
+ {
166
+ "aria-label": ariaLabel,
167
+ className,
168
+ value,
169
+ disabled: locked,
170
+ onChange: (e) => onChange(e.target.value),
171
+ style: locked ? { ...style, opacity: 0.7, cursor: "not-allowed" } : style,
172
+ children: devices.map((device) => /* @__PURE__ */ jsxRuntime.jsx("option", { value: device.id, children: device.name }, device.id))
173
+ }
174
+ );
175
+ }
176
+
177
+ // src/scale.ts
178
+ var SCALE_MODES = [
179
+ { id: "point", label: "Point Accurate" },
180
+ { id: "pixel", label: "Pixel Accurate" },
181
+ { id: "fit", label: "Fit Screen" }
182
+ ];
183
+ function displayWidthFor(device, mode, ctx) {
184
+ switch (mode) {
185
+ case "pixel": {
186
+ const ratio = ctx.devicePixelRatio > 0 ? ctx.devicePixelRatio : 1;
187
+ return device.logicalWidth * device.scale / ratio;
188
+ }
189
+ case "fit": {
190
+ const aspect = device.logicalWidth / device.logicalHeight;
191
+ const widthFromHeight = ctx.availableHeight !== void 0 ? ctx.availableHeight * aspect : Number.POSITIVE_INFINITY;
192
+ const widthBound = ctx.availableWidth ?? Number.POSITIVE_INFINITY;
193
+ const fitted = Math.min(widthFromHeight, widthBound);
194
+ return Number.isFinite(fitted) ? fitted : device.logicalWidth;
195
+ }
196
+ case "point":
197
+ default:
198
+ return device.logicalWidth;
199
+ }
200
+ }
201
+ function ScalePicker({
202
+ value,
203
+ onChange,
204
+ style,
205
+ className,
206
+ "aria-label": ariaLabel = "V\xE4lj skala"
207
+ }) {
208
+ return /* @__PURE__ */ jsxRuntime.jsx(
209
+ "select",
210
+ {
211
+ "aria-label": ariaLabel,
212
+ className,
213
+ value,
214
+ onChange: (e) => onChange(e.target.value),
215
+ style,
216
+ children: SCALE_MODES.map((mode) => /* @__PURE__ */ jsxRuntime.jsx("option", { value: mode.id, children: mode.label }, mode.id))
217
+ }
218
+ );
219
+ }
220
+
221
+ exports.DEFAULT_DEVICE_ID = DEFAULT_DEVICE_ID;
222
+ exports.DEVICES = DEVICES;
223
+ exports.DeviceFrame = DeviceFrame;
224
+ exports.DevicePicker = DevicePicker;
225
+ exports.IPHONE_17_PRO = IPHONE_17_PRO;
226
+ exports.SCALE_MODES = SCALE_MODES;
227
+ exports.ScalePicker = ScalePicker;
228
+ exports.displayWidthFor = displayWidthFor;
229
+ exports.getDevice = getDevice;
230
+ //# sourceMappingURL=index.cjs.map
231
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/DeviceFrame.tsx","../src/devices.ts","../src/DevicePicker.tsx","../src/scale.ts","../src/ScalePicker.tsx"],"names":["jsx","jsxs"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Presentational device bezel for web previews. Renders one screen surface at\n * the device's **logical** size (so `children` — typically a react-native-web\n * tree — lay out exactly as they would on-device), then CSS-scales the whole\n * surface down to `displayWidth`. The status bar, Dynamic Island, and home\n * indicator are non-interactive overlays positioned in logical coordinates.\n *\n * It deliberately knows nothing about themes or React Native: the consumer maps\n * its theme to `background` + `statusBarStyle` and wraps `children` in whatever\n * providers (e.g. a SafeAreaProvider seeded from `device.safeArea`) they need.\n */\nimport type { CSSProperties, ReactNode } from 'react';\nimport type { DeviceProfile, StatusBarStyle } from './types';\n\ninterface DeviceFrameProps {\n device: DeviceProfile;\n /** Status-bar glyph style — usually derived from the theme's light/dark mode. */\n statusBarStyle: StatusBarStyle;\n /** Screen surface colour (the app/theme background). */\n background: string;\n /** Rendered width of the screen surface in CSS px. Height follows the aspect ratio. */\n displayWidth?: number;\n /** Faux clock shown in the status bar. */\n time?: string;\n children: ReactNode;\n}\n\nconst BEZEL_PADDING = 12;\n\nexport function DeviceFrame({\n device,\n statusBarStyle,\n background,\n displayWidth = 300,\n time = '9:41',\n children,\n}: DeviceFrameProps) {\n const { logicalWidth, logicalHeight, cornerRadius, statusBarHeight, dynamicIsland } = device;\n const scale = displayWidth / logicalWidth;\n const displayHeight = logicalHeight * scale;\n // `light` status-bar style = light glyphs on a dark background, and vice versa.\n const ink = statusBarStyle === 'light' ? 'rgba(255,255,255,0.92)' : 'rgba(0,0,0,0.85)';\n\n return (\n <div\n data-testid=\"device-frame\"\n data-device={device.id}\n style={{\n padding: BEZEL_PADDING,\n borderRadius: cornerRadius * scale + BEZEL_PADDING,\n background: '#0c0605',\n border: '2px solid rgba(255,248,233,0.28)',\n boxShadow:\n '0 24px 50px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,248,233,0.18), 0 0 0 1px rgba(0,0,0,0.6)',\n width: 'fit-content',\n }}\n >\n <div\n style={{\n position: 'relative',\n width: displayWidth,\n height: displayHeight,\n borderRadius: cornerRadius * scale,\n overflow: 'hidden',\n background,\n }}\n >\n {/* Logical-size surface, scaled to fit. Everything inside uses points. */}\n <div\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n width: logicalWidth,\n height: logicalHeight,\n transform: `scale(${scale})`,\n transformOrigin: 'top left',\n display: 'flex',\n }}\n >\n <div style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex' }}>{children}</div>\n\n {dynamicIsland ? (\n <div\n aria-hidden\n style={{\n position: 'absolute',\n top: dynamicIsland.top,\n left: '50%',\n transform: 'translateX(-50%)',\n width: dynamicIsland.width,\n height: dynamicIsland.height,\n borderRadius: dynamicIsland.height / 2,\n background: '#000',\n pointerEvents: 'none',\n }}\n />\n ) : null}\n\n <StatusBar height={statusBarHeight} ink={ink} time={time} />\n <HomeIndicator width={logicalWidth} ink={ink} />\n </div>\n </div>\n </div>\n );\n}\n\nfunction StatusBar({ height, ink, time }: { height: number; ink: string; time: string }) {\n const containerStyle: CSSProperties = {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n height,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'space-between',\n paddingLeft: 28,\n paddingRight: 28,\n paddingTop: 14,\n fontSize: 15,\n fontWeight: 700,\n color: ink,\n pointerEvents: 'none',\n };\n return (\n <div aria-hidden style={containerStyle}>\n <span>{time}</span>\n <span style={{ display: 'flex', gap: 6, alignItems: 'center' }}>\n <span style={{ fontSize: 13 }}>5G</span>\n <span style={{ display: 'inline-flex', gap: 2, alignItems: 'flex-end' }}>\n <span style={{ width: 3, height: 6, background: ink, borderRadius: 1 }} />\n <span style={{ width: 3, height: 9, background: ink, borderRadius: 1 }} />\n <span style={{ width: 3, height: 12, background: ink, borderRadius: 1 }} />\n </span>\n </span>\n </div>\n );\n}\n\nfunction HomeIndicator({ width, ink }: { width: number; ink: string }) {\n return (\n <div\n aria-hidden\n style={{\n position: 'absolute',\n bottom: 8,\n left: '50%',\n transform: 'translateX(-50%)',\n width: width * 0.36,\n height: 5,\n borderRadius: 3,\n background: ink,\n opacity: 0.55,\n pointerEvents: 'none',\n }}\n />\n );\n}\n","import type { DeviceProfile } from './types';\n\n/**\n * iPhone 17 Pro (2025). Logical 402×874 pt (native 1206×2622 px @3×, 460 ppi),\n * portrait safe-area insets top 62 / bottom 34, status-bar band 54 pt, ~55 pt\n * display corner radius, and the pill-shaped Dynamic Island near the top.\n *\n * Sources: Apple tech specs (support.apple.com/en-us/125090) and\n * useyourloaf.com/blog/iphone-17-screen-sizes/.\n */\nexport const IPHONE_17_PRO: DeviceProfile = {\n id: 'iphone-17-pro',\n name: 'iPhone 17 Pro',\n logicalWidth: 402,\n logicalHeight: 874,\n scale: 3,\n safeArea: { top: 62, bottom: 34, left: 0, right: 0 },\n statusBarHeight: 54,\n cornerRadius: 55,\n dynamicIsland: { width: 126, height: 37, top: 11 },\n};\n\n/** Every device the previewer offers. Add new profiles here. */\nexport const DEVICES: readonly DeviceProfile[] = [IPHONE_17_PRO];\n\n/** The device selected by default. */\nexport const DEFAULT_DEVICE_ID = IPHONE_17_PRO.id;\n\nexport function getDevice(id: string): DeviceProfile | undefined {\n return DEVICES.find((device) => device.id === id);\n}\n","/**\n * A bare `<select>` over the device catalog. Styling is left to the host (pass\n * `style`/`className`) so it blends into whatever admin chrome consumes it.\n */\nimport type { CSSProperties } from 'react';\nimport { DEVICES } from './devices';\nimport type { DeviceProfile } from './types';\n\ninterface DevicePickerProps {\n value: string;\n onChange: (deviceId: string) => void;\n devices?: readonly DeviceProfile[];\n /** Force the locked (disabled) state. Defaults to locked when there is only one device. */\n disabled?: boolean;\n style?: CSSProperties;\n className?: string;\n 'aria-label'?: string;\n}\n\nexport function DevicePicker({\n value,\n onChange,\n devices = DEVICES,\n disabled,\n style,\n className,\n 'aria-label': ariaLabel = 'Välj enhet',\n}: DevicePickerProps) {\n // Nothing to choose between with a single device — show it, but lock it.\n const locked = disabled ?? devices.length <= 1;\n return (\n <select\n aria-label={ariaLabel}\n className={className}\n value={value}\n disabled={locked}\n onChange={(e) => onChange(e.target.value)}\n style={locked ? { ...style, opacity: 0.7, cursor: 'not-allowed' } : style}\n >\n {devices.map((device) => (\n <option key={device.id} value={device.id}>\n {device.name}\n </option>\n ))}\n </select>\n );\n}\n","import type { DeviceProfile } from './types';\n\n/**\n * How big to render the device on screen. Mirrors the iOS Simulator's window\n * scale menu (the modes a browser can reproduce faithfully):\n *\n * - `point` — 1 device point ↔ 1 CSS px. The design-true default.\n * - `pixel` — 1 device pixel ↔ 1 physical monitor pixel (uses devicePixelRatio).\n * On a Retina display this is larger than `point` (the device is denser).\n * - `fit` — scale to fit the available preview area.\n *\n * (The Simulator's \"Physical Size\" is intentionally omitted: a browser can't\n * read the monitor's true PPI, so it can't render life-size accurately.)\n *\n * In all modes the screen still lays out at the device's logical size — only the\n * on-screen zoom changes — so the preview never lies about layout.\n */\nexport type ScaleMode = 'point' | 'pixel' | 'fit';\n\nexport const SCALE_MODES: { id: ScaleMode; label: string }[] = [\n { id: 'point', label: 'Point Accurate' },\n { id: 'pixel', label: 'Pixel Accurate' },\n { id: 'fit', label: 'Fit Screen' },\n];\n\nexport interface ScaleContext {\n /** `window.devicePixelRatio` — physical monitor pixels per CSS px. */\n devicePixelRatio: number;\n /** Available width in the preview area (CSS px); bounds `fit`. */\n availableWidth?: number;\n /** Available height in the preview area (CSS px); bounds `fit`. */\n availableHeight?: number;\n}\n\n/** Rendered width (CSS px) of the screen surface for the given scale mode. */\nexport function displayWidthFor(\n device: DeviceProfile,\n mode: ScaleMode,\n ctx: ScaleContext,\n): number {\n switch (mode) {\n case 'pixel': {\n const ratio = ctx.devicePixelRatio > 0 ? ctx.devicePixelRatio : 1;\n return (device.logicalWidth * device.scale) / ratio;\n }\n case 'fit': {\n const aspect = device.logicalWidth / device.logicalHeight;\n const widthFromHeight =\n ctx.availableHeight !== undefined ? ctx.availableHeight * aspect : Number.POSITIVE_INFINITY;\n const widthBound = ctx.availableWidth ?? Number.POSITIVE_INFINITY;\n const fitted = Math.min(widthFromHeight, widthBound);\n return Number.isFinite(fitted) ? fitted : device.logicalWidth;\n }\n case 'point':\n default:\n return device.logicalWidth;\n }\n}\n","/**\n * A bare `<select>` over the scale modes. Styling is left to the host (pass\n * `style`/`className`) so it matches whatever admin chrome consumes it.\n */\nimport type { CSSProperties } from 'react';\nimport { SCALE_MODES, type ScaleMode } from './scale';\n\ninterface ScalePickerProps {\n value: ScaleMode;\n onChange: (mode: ScaleMode) => void;\n style?: CSSProperties;\n className?: string;\n 'aria-label'?: string;\n}\n\nexport function ScalePicker({\n value,\n onChange,\n style,\n className,\n 'aria-label': ariaLabel = 'Välj skala',\n}: ScalePickerProps) {\n return (\n <select\n aria-label={ariaLabel}\n className={className}\n value={value}\n onChange={(e) => onChange(e.target.value as ScaleMode)}\n style={style}\n >\n {SCALE_MODES.map((mode) => (\n <option key={mode.id} value={mode.id}>\n {mode.label}\n </option>\n ))}\n </select>\n );\n}\n"]}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @kreativa/device-preview — app-agnostic device frame + catalog for rendering
3
+ * web previews of mobile UI at real device window sizes and safe-area insets.
4
+ */
5
+ export { DeviceFrame } from './DeviceFrame';
6
+ export { DevicePicker } from './DevicePicker';
7
+ export { ScalePicker } from './ScalePicker';
8
+ export { DEVICES, DEFAULT_DEVICE_ID, IPHONE_17_PRO, getDevice } from './devices';
9
+ export { SCALE_MODES, displayWidthFor } from './scale';
10
+ export type { ScaleMode, ScaleContext } from './scale';
11
+ export type { DeviceProfile, DeviceInsets, DynamicIsland, StatusBarStyle, } from './types';
package/dist/index.js ADDED
@@ -0,0 +1,221 @@
1
+ import { jsx, jsxs } from 'react/jsx-runtime';
2
+
3
+ // src/DeviceFrame.tsx
4
+ var BEZEL_PADDING = 12;
5
+ function DeviceFrame({
6
+ device,
7
+ statusBarStyle,
8
+ background,
9
+ displayWidth = 300,
10
+ time = "9:41",
11
+ children
12
+ }) {
13
+ const { logicalWidth, logicalHeight, cornerRadius, statusBarHeight, dynamicIsland } = device;
14
+ const scale = displayWidth / logicalWidth;
15
+ const displayHeight = logicalHeight * scale;
16
+ const ink = statusBarStyle === "light" ? "rgba(255,255,255,0.92)" : "rgba(0,0,0,0.85)";
17
+ return /* @__PURE__ */ jsx(
18
+ "div",
19
+ {
20
+ "data-testid": "device-frame",
21
+ "data-device": device.id,
22
+ style: {
23
+ padding: BEZEL_PADDING,
24
+ borderRadius: cornerRadius * scale + BEZEL_PADDING,
25
+ background: "#0c0605",
26
+ border: "2px solid rgba(255,248,233,0.28)",
27
+ boxShadow: "0 24px 50px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,248,233,0.18), 0 0 0 1px rgba(0,0,0,0.6)",
28
+ width: "fit-content"
29
+ },
30
+ children: /* @__PURE__ */ jsx(
31
+ "div",
32
+ {
33
+ style: {
34
+ position: "relative",
35
+ width: displayWidth,
36
+ height: displayHeight,
37
+ borderRadius: cornerRadius * scale,
38
+ overflow: "hidden",
39
+ background
40
+ },
41
+ children: /* @__PURE__ */ jsxs(
42
+ "div",
43
+ {
44
+ style: {
45
+ position: "absolute",
46
+ top: 0,
47
+ left: 0,
48
+ width: logicalWidth,
49
+ height: logicalHeight,
50
+ transform: `scale(${scale})`,
51
+ transformOrigin: "top left",
52
+ display: "flex"
53
+ },
54
+ children: [
55
+ /* @__PURE__ */ jsx("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex" }, children }),
56
+ dynamicIsland ? /* @__PURE__ */ jsx(
57
+ "div",
58
+ {
59
+ "aria-hidden": true,
60
+ style: {
61
+ position: "absolute",
62
+ top: dynamicIsland.top,
63
+ left: "50%",
64
+ transform: "translateX(-50%)",
65
+ width: dynamicIsland.width,
66
+ height: dynamicIsland.height,
67
+ borderRadius: dynamicIsland.height / 2,
68
+ background: "#000",
69
+ pointerEvents: "none"
70
+ }
71
+ }
72
+ ) : null,
73
+ /* @__PURE__ */ jsx(StatusBar, { height: statusBarHeight, ink, time }),
74
+ /* @__PURE__ */ jsx(HomeIndicator, { width: logicalWidth, ink })
75
+ ]
76
+ }
77
+ )
78
+ }
79
+ )
80
+ }
81
+ );
82
+ }
83
+ function StatusBar({ height, ink, time }) {
84
+ const containerStyle = {
85
+ position: "absolute",
86
+ top: 0,
87
+ left: 0,
88
+ right: 0,
89
+ height,
90
+ display: "flex",
91
+ alignItems: "center",
92
+ justifyContent: "space-between",
93
+ paddingLeft: 28,
94
+ paddingRight: 28,
95
+ paddingTop: 14,
96
+ fontSize: 15,
97
+ fontWeight: 700,
98
+ color: ink,
99
+ pointerEvents: "none"
100
+ };
101
+ return /* @__PURE__ */ jsxs("div", { "aria-hidden": true, style: containerStyle, children: [
102
+ /* @__PURE__ */ jsx("span", { children: time }),
103
+ /* @__PURE__ */ jsxs("span", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
104
+ /* @__PURE__ */ jsx("span", { style: { fontSize: 13 }, children: "5G" }),
105
+ /* @__PURE__ */ jsxs("span", { style: { display: "inline-flex", gap: 2, alignItems: "flex-end" }, children: [
106
+ /* @__PURE__ */ jsx("span", { style: { width: 3, height: 6, background: ink, borderRadius: 1 } }),
107
+ /* @__PURE__ */ jsx("span", { style: { width: 3, height: 9, background: ink, borderRadius: 1 } }),
108
+ /* @__PURE__ */ jsx("span", { style: { width: 3, height: 12, background: ink, borderRadius: 1 } })
109
+ ] })
110
+ ] })
111
+ ] });
112
+ }
113
+ function HomeIndicator({ width, ink }) {
114
+ return /* @__PURE__ */ jsx(
115
+ "div",
116
+ {
117
+ "aria-hidden": true,
118
+ style: {
119
+ position: "absolute",
120
+ bottom: 8,
121
+ left: "50%",
122
+ transform: "translateX(-50%)",
123
+ width: width * 0.36,
124
+ height: 5,
125
+ borderRadius: 3,
126
+ background: ink,
127
+ opacity: 0.55,
128
+ pointerEvents: "none"
129
+ }
130
+ }
131
+ );
132
+ }
133
+
134
+ // src/devices.ts
135
+ var IPHONE_17_PRO = {
136
+ id: "iphone-17-pro",
137
+ name: "iPhone 17 Pro",
138
+ logicalWidth: 402,
139
+ logicalHeight: 874,
140
+ scale: 3,
141
+ safeArea: { top: 62, bottom: 34, left: 0, right: 0 },
142
+ statusBarHeight: 54,
143
+ cornerRadius: 55,
144
+ dynamicIsland: { width: 126, height: 37, top: 11 }
145
+ };
146
+ var DEVICES = [IPHONE_17_PRO];
147
+ var DEFAULT_DEVICE_ID = IPHONE_17_PRO.id;
148
+ function getDevice(id) {
149
+ return DEVICES.find((device) => device.id === id);
150
+ }
151
+ function DevicePicker({
152
+ value,
153
+ onChange,
154
+ devices = DEVICES,
155
+ disabled,
156
+ style,
157
+ className,
158
+ "aria-label": ariaLabel = "V\xE4lj enhet"
159
+ }) {
160
+ const locked = disabled ?? devices.length <= 1;
161
+ return /* @__PURE__ */ jsx(
162
+ "select",
163
+ {
164
+ "aria-label": ariaLabel,
165
+ className,
166
+ value,
167
+ disabled: locked,
168
+ onChange: (e) => onChange(e.target.value),
169
+ style: locked ? { ...style, opacity: 0.7, cursor: "not-allowed" } : style,
170
+ children: devices.map((device) => /* @__PURE__ */ jsx("option", { value: device.id, children: device.name }, device.id))
171
+ }
172
+ );
173
+ }
174
+
175
+ // src/scale.ts
176
+ var SCALE_MODES = [
177
+ { id: "point", label: "Point Accurate" },
178
+ { id: "pixel", label: "Pixel Accurate" },
179
+ { id: "fit", label: "Fit Screen" }
180
+ ];
181
+ function displayWidthFor(device, mode, ctx) {
182
+ switch (mode) {
183
+ case "pixel": {
184
+ const ratio = ctx.devicePixelRatio > 0 ? ctx.devicePixelRatio : 1;
185
+ return device.logicalWidth * device.scale / ratio;
186
+ }
187
+ case "fit": {
188
+ const aspect = device.logicalWidth / device.logicalHeight;
189
+ const widthFromHeight = ctx.availableHeight !== void 0 ? ctx.availableHeight * aspect : Number.POSITIVE_INFINITY;
190
+ const widthBound = ctx.availableWidth ?? Number.POSITIVE_INFINITY;
191
+ const fitted = Math.min(widthFromHeight, widthBound);
192
+ return Number.isFinite(fitted) ? fitted : device.logicalWidth;
193
+ }
194
+ case "point":
195
+ default:
196
+ return device.logicalWidth;
197
+ }
198
+ }
199
+ function ScalePicker({
200
+ value,
201
+ onChange,
202
+ style,
203
+ className,
204
+ "aria-label": ariaLabel = "V\xE4lj skala"
205
+ }) {
206
+ return /* @__PURE__ */ jsx(
207
+ "select",
208
+ {
209
+ "aria-label": ariaLabel,
210
+ className,
211
+ value,
212
+ onChange: (e) => onChange(e.target.value),
213
+ style,
214
+ children: SCALE_MODES.map((mode) => /* @__PURE__ */ jsx("option", { value: mode.id, children: mode.label }, mode.id))
215
+ }
216
+ );
217
+ }
218
+
219
+ export { DEFAULT_DEVICE_ID, DEVICES, DeviceFrame, DevicePicker, IPHONE_17_PRO, SCALE_MODES, ScalePicker, displayWidthFor, getDevice };
220
+ //# sourceMappingURL=index.js.map
221
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/DeviceFrame.tsx","../src/devices.ts","../src/DevicePicker.tsx","../src/scale.ts","../src/ScalePicker.tsx"],"names":["jsx"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Presentational device bezel for web previews. Renders one screen surface at\n * the device's **logical** size (so `children` — typically a react-native-web\n * tree — lay out exactly as they would on-device), then CSS-scales the whole\n * surface down to `displayWidth`. The status bar, Dynamic Island, and home\n * indicator are non-interactive overlays positioned in logical coordinates.\n *\n * It deliberately knows nothing about themes or React Native: the consumer maps\n * its theme to `background` + `statusBarStyle` and wraps `children` in whatever\n * providers (e.g. a SafeAreaProvider seeded from `device.safeArea`) they need.\n */\nimport type { CSSProperties, ReactNode } from 'react';\nimport type { DeviceProfile, StatusBarStyle } from './types';\n\ninterface DeviceFrameProps {\n device: DeviceProfile;\n /** Status-bar glyph style — usually derived from the theme's light/dark mode. */\n statusBarStyle: StatusBarStyle;\n /** Screen surface colour (the app/theme background). */\n background: string;\n /** Rendered width of the screen surface in CSS px. Height follows the aspect ratio. */\n displayWidth?: number;\n /** Faux clock shown in the status bar. */\n time?: string;\n children: ReactNode;\n}\n\nconst BEZEL_PADDING = 12;\n\nexport function DeviceFrame({\n device,\n statusBarStyle,\n background,\n displayWidth = 300,\n time = '9:41',\n children,\n}: DeviceFrameProps) {\n const { logicalWidth, logicalHeight, cornerRadius, statusBarHeight, dynamicIsland } = device;\n const scale = displayWidth / logicalWidth;\n const displayHeight = logicalHeight * scale;\n // `light` status-bar style = light glyphs on a dark background, and vice versa.\n const ink = statusBarStyle === 'light' ? 'rgba(255,255,255,0.92)' : 'rgba(0,0,0,0.85)';\n\n return (\n <div\n data-testid=\"device-frame\"\n data-device={device.id}\n style={{\n padding: BEZEL_PADDING,\n borderRadius: cornerRadius * scale + BEZEL_PADDING,\n background: '#0c0605',\n border: '2px solid rgba(255,248,233,0.28)',\n boxShadow:\n '0 24px 50px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,248,233,0.18), 0 0 0 1px rgba(0,0,0,0.6)',\n width: 'fit-content',\n }}\n >\n <div\n style={{\n position: 'relative',\n width: displayWidth,\n height: displayHeight,\n borderRadius: cornerRadius * scale,\n overflow: 'hidden',\n background,\n }}\n >\n {/* Logical-size surface, scaled to fit. Everything inside uses points. */}\n <div\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n width: logicalWidth,\n height: logicalHeight,\n transform: `scale(${scale})`,\n transformOrigin: 'top left',\n display: 'flex',\n }}\n >\n <div style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex' }}>{children}</div>\n\n {dynamicIsland ? (\n <div\n aria-hidden\n style={{\n position: 'absolute',\n top: dynamicIsland.top,\n left: '50%',\n transform: 'translateX(-50%)',\n width: dynamicIsland.width,\n height: dynamicIsland.height,\n borderRadius: dynamicIsland.height / 2,\n background: '#000',\n pointerEvents: 'none',\n }}\n />\n ) : null}\n\n <StatusBar height={statusBarHeight} ink={ink} time={time} />\n <HomeIndicator width={logicalWidth} ink={ink} />\n </div>\n </div>\n </div>\n );\n}\n\nfunction StatusBar({ height, ink, time }: { height: number; ink: string; time: string }) {\n const containerStyle: CSSProperties = {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n height,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'space-between',\n paddingLeft: 28,\n paddingRight: 28,\n paddingTop: 14,\n fontSize: 15,\n fontWeight: 700,\n color: ink,\n pointerEvents: 'none',\n };\n return (\n <div aria-hidden style={containerStyle}>\n <span>{time}</span>\n <span style={{ display: 'flex', gap: 6, alignItems: 'center' }}>\n <span style={{ fontSize: 13 }}>5G</span>\n <span style={{ display: 'inline-flex', gap: 2, alignItems: 'flex-end' }}>\n <span style={{ width: 3, height: 6, background: ink, borderRadius: 1 }} />\n <span style={{ width: 3, height: 9, background: ink, borderRadius: 1 }} />\n <span style={{ width: 3, height: 12, background: ink, borderRadius: 1 }} />\n </span>\n </span>\n </div>\n );\n}\n\nfunction HomeIndicator({ width, ink }: { width: number; ink: string }) {\n return (\n <div\n aria-hidden\n style={{\n position: 'absolute',\n bottom: 8,\n left: '50%',\n transform: 'translateX(-50%)',\n width: width * 0.36,\n height: 5,\n borderRadius: 3,\n background: ink,\n opacity: 0.55,\n pointerEvents: 'none',\n }}\n />\n );\n}\n","import type { DeviceProfile } from './types';\n\n/**\n * iPhone 17 Pro (2025). Logical 402×874 pt (native 1206×2622 px @3×, 460 ppi),\n * portrait safe-area insets top 62 / bottom 34, status-bar band 54 pt, ~55 pt\n * display corner radius, and the pill-shaped Dynamic Island near the top.\n *\n * Sources: Apple tech specs (support.apple.com/en-us/125090) and\n * useyourloaf.com/blog/iphone-17-screen-sizes/.\n */\nexport const IPHONE_17_PRO: DeviceProfile = {\n id: 'iphone-17-pro',\n name: 'iPhone 17 Pro',\n logicalWidth: 402,\n logicalHeight: 874,\n scale: 3,\n safeArea: { top: 62, bottom: 34, left: 0, right: 0 },\n statusBarHeight: 54,\n cornerRadius: 55,\n dynamicIsland: { width: 126, height: 37, top: 11 },\n};\n\n/** Every device the previewer offers. Add new profiles here. */\nexport const DEVICES: readonly DeviceProfile[] = [IPHONE_17_PRO];\n\n/** The device selected by default. */\nexport const DEFAULT_DEVICE_ID = IPHONE_17_PRO.id;\n\nexport function getDevice(id: string): DeviceProfile | undefined {\n return DEVICES.find((device) => device.id === id);\n}\n","/**\n * A bare `<select>` over the device catalog. Styling is left to the host (pass\n * `style`/`className`) so it blends into whatever admin chrome consumes it.\n */\nimport type { CSSProperties } from 'react';\nimport { DEVICES } from './devices';\nimport type { DeviceProfile } from './types';\n\ninterface DevicePickerProps {\n value: string;\n onChange: (deviceId: string) => void;\n devices?: readonly DeviceProfile[];\n /** Force the locked (disabled) state. Defaults to locked when there is only one device. */\n disabled?: boolean;\n style?: CSSProperties;\n className?: string;\n 'aria-label'?: string;\n}\n\nexport function DevicePicker({\n value,\n onChange,\n devices = DEVICES,\n disabled,\n style,\n className,\n 'aria-label': ariaLabel = 'Välj enhet',\n}: DevicePickerProps) {\n // Nothing to choose between with a single device — show it, but lock it.\n const locked = disabled ?? devices.length <= 1;\n return (\n <select\n aria-label={ariaLabel}\n className={className}\n value={value}\n disabled={locked}\n onChange={(e) => onChange(e.target.value)}\n style={locked ? { ...style, opacity: 0.7, cursor: 'not-allowed' } : style}\n >\n {devices.map((device) => (\n <option key={device.id} value={device.id}>\n {device.name}\n </option>\n ))}\n </select>\n );\n}\n","import type { DeviceProfile } from './types';\n\n/**\n * How big to render the device on screen. Mirrors the iOS Simulator's window\n * scale menu (the modes a browser can reproduce faithfully):\n *\n * - `point` — 1 device point ↔ 1 CSS px. The design-true default.\n * - `pixel` — 1 device pixel ↔ 1 physical monitor pixel (uses devicePixelRatio).\n * On a Retina display this is larger than `point` (the device is denser).\n * - `fit` — scale to fit the available preview area.\n *\n * (The Simulator's \"Physical Size\" is intentionally omitted: a browser can't\n * read the monitor's true PPI, so it can't render life-size accurately.)\n *\n * In all modes the screen still lays out at the device's logical size — only the\n * on-screen zoom changes — so the preview never lies about layout.\n */\nexport type ScaleMode = 'point' | 'pixel' | 'fit';\n\nexport const SCALE_MODES: { id: ScaleMode; label: string }[] = [\n { id: 'point', label: 'Point Accurate' },\n { id: 'pixel', label: 'Pixel Accurate' },\n { id: 'fit', label: 'Fit Screen' },\n];\n\nexport interface ScaleContext {\n /** `window.devicePixelRatio` — physical monitor pixels per CSS px. */\n devicePixelRatio: number;\n /** Available width in the preview area (CSS px); bounds `fit`. */\n availableWidth?: number;\n /** Available height in the preview area (CSS px); bounds `fit`. */\n availableHeight?: number;\n}\n\n/** Rendered width (CSS px) of the screen surface for the given scale mode. */\nexport function displayWidthFor(\n device: DeviceProfile,\n mode: ScaleMode,\n ctx: ScaleContext,\n): number {\n switch (mode) {\n case 'pixel': {\n const ratio = ctx.devicePixelRatio > 0 ? ctx.devicePixelRatio : 1;\n return (device.logicalWidth * device.scale) / ratio;\n }\n case 'fit': {\n const aspect = device.logicalWidth / device.logicalHeight;\n const widthFromHeight =\n ctx.availableHeight !== undefined ? ctx.availableHeight * aspect : Number.POSITIVE_INFINITY;\n const widthBound = ctx.availableWidth ?? Number.POSITIVE_INFINITY;\n const fitted = Math.min(widthFromHeight, widthBound);\n return Number.isFinite(fitted) ? fitted : device.logicalWidth;\n }\n case 'point':\n default:\n return device.logicalWidth;\n }\n}\n","/**\n * A bare `<select>` over the scale modes. Styling is left to the host (pass\n * `style`/`className`) so it matches whatever admin chrome consumes it.\n */\nimport type { CSSProperties } from 'react';\nimport { SCALE_MODES, type ScaleMode } from './scale';\n\ninterface ScalePickerProps {\n value: ScaleMode;\n onChange: (mode: ScaleMode) => void;\n style?: CSSProperties;\n className?: string;\n 'aria-label'?: string;\n}\n\nexport function ScalePicker({\n value,\n onChange,\n style,\n className,\n 'aria-label': ariaLabel = 'Välj skala',\n}: ScalePickerProps) {\n return (\n <select\n aria-label={ariaLabel}\n className={className}\n value={value}\n onChange={(e) => onChange(e.target.value as ScaleMode)}\n style={style}\n >\n {SCALE_MODES.map((mode) => (\n <option key={mode.id} value={mode.id}>\n {mode.label}\n </option>\n ))}\n </select>\n );\n}\n"]}
@@ -0,0 +1,31 @@
1
+ import type { DeviceProfile } from './types';
2
+ /**
3
+ * How big to render the device on screen. Mirrors the iOS Simulator's window
4
+ * scale menu (the modes a browser can reproduce faithfully):
5
+ *
6
+ * - `point` — 1 device point ↔ 1 CSS px. The design-true default.
7
+ * - `pixel` — 1 device pixel ↔ 1 physical monitor pixel (uses devicePixelRatio).
8
+ * On a Retina display this is larger than `point` (the device is denser).
9
+ * - `fit` — scale to fit the available preview area.
10
+ *
11
+ * (The Simulator's "Physical Size" is intentionally omitted: a browser can't
12
+ * read the monitor's true PPI, so it can't render life-size accurately.)
13
+ *
14
+ * In all modes the screen still lays out at the device's logical size — only the
15
+ * on-screen zoom changes — so the preview never lies about layout.
16
+ */
17
+ export type ScaleMode = 'point' | 'pixel' | 'fit';
18
+ export declare const SCALE_MODES: {
19
+ id: ScaleMode;
20
+ label: string;
21
+ }[];
22
+ export interface ScaleContext {
23
+ /** `window.devicePixelRatio` — physical monitor pixels per CSS px. */
24
+ devicePixelRatio: number;
25
+ /** Available width in the preview area (CSS px); bounds `fit`. */
26
+ availableWidth?: number;
27
+ /** Available height in the preview area (CSS px); bounds `fit`. */
28
+ availableHeight?: number;
29
+ }
30
+ /** Rendered width (CSS px) of the screen surface for the given scale mode. */
31
+ export declare function displayWidthFor(device: DeviceProfile, mode: ScaleMode, ctx: ScaleContext): number;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Core types for the device-preview frame. Kept free of any app- or
3
+ * framework-specific imports so this package can be lifted into a standalone
4
+ * `@kreativa/device-preview` repo unchanged.
5
+ */
6
+ /**
7
+ * Status-bar content style, mirroring the iOS/`expo-status-bar` vocabulary:
8
+ * `light` = light-coloured glyphs (for dark backgrounds), `dark` = dark glyphs.
9
+ */
10
+ export type StatusBarStyle = 'light' | 'dark';
11
+ export interface DeviceInsets {
12
+ top: number;
13
+ bottom: number;
14
+ left: number;
15
+ right: number;
16
+ }
17
+ export interface DynamicIsland {
18
+ width: number;
19
+ height: number;
20
+ /** Distance from the top edge of the screen to the island, in logical points. */
21
+ top: number;
22
+ }
23
+ /**
24
+ * A device the previewer can render. All measurements are in **logical points**
25
+ * (CSS px before scaling), matching what a React Native screen lays out against
26
+ * — so feeding `logicalWidth`/`logicalHeight` and `safeArea` into the preview
27
+ * reproduces the real on-device layout.
28
+ */
29
+ export interface DeviceProfile {
30
+ id: string;
31
+ name: string;
32
+ logicalWidth: number;
33
+ logicalHeight: number;
34
+ /** Native pixel scale (e.g. 3 for @3x). Informational; layout uses points. */
35
+ scale: number;
36
+ safeArea: DeviceInsets;
37
+ /** Height of the status-bar band at the top of the screen. */
38
+ statusBarHeight: number;
39
+ /** Display corner radius of the screen surface. */
40
+ cornerRadius: number;
41
+ /** Pill cutout at the top, or `null` for devices without one. */
42
+ dynamicIsland: DynamicIsland | null;
43
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@kreativa/device-preview",
3
+ "version": "0.1.0",
4
+ "description": "App-agnostic device frame + catalog for rendering web previews of mobile UI at real device window sizes and safe-area insets.",
5
+ "license": "MIT",
6
+ "author": "Kreativa",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/kreativabyran/device-preview.git"
10
+ },
11
+ "homepage": "https://github.com/kreativabyran/device-preview#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/kreativabyran/device-preview/issues"
14
+ },
15
+ "keywords": [
16
+ "react",
17
+ "device-frame",
18
+ "preview",
19
+ "iphone",
20
+ "simulator",
21
+ "safe-area",
22
+ "responsive"
23
+ ],
24
+ "type": "module",
25
+ "main": "./dist/index.cjs",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js",
32
+ "require": "./dist/index.cjs"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist"
37
+ ],
38
+ "sideEffects": false,
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "scripts": {
43
+ "build": "tsup && tsc -p tsconfig.build.json",
44
+ "prepare": "npm run build",
45
+ "prepublishOnly": "npm run build",
46
+ "lint": "eslint src/",
47
+ "lint:fix": "eslint src/ --fix",
48
+ "lint-quiet": "concurrently \"npm run lint\" \"npm run typecheck\"",
49
+ "typecheck": "tsc --noEmit",
50
+ "test": "vitest run --passWithNoTests",
51
+ "test:watch": "vitest"
52
+ },
53
+ "peerDependencies": {
54
+ "react": ">=18"
55
+ },
56
+ "devDependencies": {
57
+ "@testing-library/jest-dom": "^6.9.1",
58
+ "@testing-library/react": "^16.3.2",
59
+ "@types/react": "~19.2.17",
60
+ "@typescript-eslint/eslint-plugin": "^8.61.0",
61
+ "@typescript-eslint/parser": "^8.61.0",
62
+ "concurrently": "^10.0.3",
63
+ "eslint": "^9.39.4",
64
+ "jsdom": "^29.1.1",
65
+ "react": "19.2.3",
66
+ "react-dom": "19.2.3",
67
+ "tsup": "^8.5.0",
68
+ "typescript": "~6.0.3",
69
+ "vitest": "^4.1.8"
70
+ }
71
+ }