@humanforest/frames 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/ATTRIBUTION.md ADDED
@@ -0,0 +1,48 @@
1
+ # Attribution — third-party artwork
2
+
3
+ The device renders in `src/art/` are unmodified vector illustrations from Wikimedia Commons,
4
+ used under the **Creative Commons Attribution-ShareAlike 4.0 International** licence
5
+ (<https://creativecommons.org/licenses/by-sa/4.0/>).
6
+
7
+ Attribution is a condition of that licence, not a courtesy. If you redistribute this package, or
8
+ ship a build that includes these renders, this notice must travel with it. Works derived from the
9
+ art itself must be licensed under CC BY-SA 4.0 in turn.
10
+
11
+ No changes have been made to the artwork. The package positions and clips each render; it does not
12
+ alter the drawings.
13
+
14
+ ## DM115Wiki — CC BY-SA 4.0
15
+
16
+ - `src/art/pixel-8a.svg`
17
+ - `src/art/pixel-9a.svg`
18
+
19
+ ## Mliu92 — CC BY-SA 4.0
20
+
21
+ - `src/art/pixel-10-pro-xl.svg`
22
+ - `src/art/pixel-10-pro.svg`
23
+ - `src/art/pixel-10.svg`
24
+ - `src/art/pixel-8-pro.svg`
25
+ - `src/art/pixel-8.svg`
26
+ - `src/art/pixel-9-pro-xl.svg`
27
+ - `src/art/pixel-9-pro.svg`
28
+
29
+ ## Rafael Fernandez — CC BY-SA 4.0
30
+
31
+ - `src/art/iphone-12.svg`
32
+ - `src/art/iphone-13-pro.svg`
33
+ - `src/art/iphone-13.svg`
34
+ - `src/art/iphone-14.svg`
35
+ - `src/art/iphone-15-pro.svg`
36
+ - `src/art/iphone-15.svg`
37
+ - `src/art/iphone-16-plus.svg`
38
+ - `src/art/iphone-16-pro-max.svg`
39
+ - `src/art/iphone-16-pro.svg`
40
+ - `src/art/iphone-16.svg`
41
+ - `src/art/iphone-17-pro-max.svg`
42
+ - `src/art/iphone-17.svg`
43
+ - `src/art/iphone-air.svg`
44
+ - `src/art/iphone-se-3.svg`
45
+ - `src/art/iphone-x.svg`
46
+
47
+ Source: Wikimedia Commons. Search the filename for the original file page, which carries
48
+ the author's own attribution string and the full licence text.
package/LICENSE ADDED
@@ -0,0 +1,67 @@
1
+ Forest Design System — Licence
2
+ Copyright (c) 2026 HumanForest Ltd. All rights reserved.
3
+
4
+ This software is published openly so that people building Forest products can install it
5
+ without access to its source repository. It is not open-source software.
6
+
7
+
8
+ 1. GRANT
9
+
10
+ You may install, use, copy and modify this software, and incorporate it into applications,
11
+ prototypes, documents and presentations, for the purpose of building or operating Forest
12
+ products, services and communications.
13
+
14
+ This grant is free of charge and requires no separate agreement. It runs to anyone who
15
+ obtains the software from a public registry.
16
+
17
+
18
+ 2. RESERVED
19
+
20
+ All rights not granted above are reserved. In particular, you may not:
21
+
22
+ (a) redistribute this software, modified or unmodified, as a design system, component
23
+ library, template or theme of your own;
24
+
25
+ (b) use the HumanForest and Forest names, logos, wordmarks, sub-brand marks or any
26
+ confusingly similar mark. Nothing here grants any trademark right. The brand marks
27
+ included in these packages are provided to render Forest's own products correctly,
28
+ not as artwork you may adopt;
29
+
30
+ (c) represent your product as being made, endorsed or approved by HumanForest.
31
+
32
+
33
+ 3. THIRD-PARTY MATERIAL
34
+
35
+ Parts of these packages are not HumanForest's to license, and the grant in section 1 does
36
+ not extend to them. Each keeps its own terms:
37
+
38
+ TYPEFACES — Mohr and GT Haptik are licensed to HumanForest by their foundries and are NOT
39
+ sublicensed to you. No font binary is included in any published package. The stylesheets
40
+ reference https://assets.forest.bike/fonts/, which serves them to those entitled to use
41
+ them. If you are not covered by Forest's licences, substitute your own faces. JetBrains
42
+ Mono is licensed under the SIL Open Font License 1.1 and may be used under those terms.
43
+
44
+ GLYPH OUTLINES — packages/tokens ships `glyphs/glyphs.json`, letterforms of the above
45
+ typefaces outlined as vector paths so sub-brand lockups can be composed at runtime. It is
46
+ covered by the same foundry terms as the typefaces themselves, not by section 1.
47
+
48
+ DEVICE ARTWORK — packages/frames ships device renders from Wikimedia Commons under the
49
+ Creative Commons Attribution-ShareAlike 4.0 International licence
50
+ (https://creativecommons.org/licenses/by-sa/4.0/). They are unmodified. Attribution is a
51
+ condition of that licence: see ATTRIBUTION.md in that package, and keep it with the art
52
+ wherever the art travels. Works derived from the artwork must themselves be CC BY-SA 4.0.
53
+
54
+ DEPENDENCIES — third-party packages installed alongside these keep their own licences.
55
+
56
+
57
+ 4. NO WARRANTY
58
+
59
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
60
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
61
+ PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
62
+ FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
63
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
64
+ DEALINGS IN THE SOFTWARE.
65
+
66
+
67
+ Questions about use beyond this grant: design@forest.bike
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @humanforest/frames
2
+
3
+ Presentation frames for staging prototypes at true scale.
4
+
5
+ | Component | What it frames |
6
+ | --- | --- |
7
+ | `FDeviceFrame` | A phone. Photoreal hardware, live HTML screen at the model's true logical points, OS-authentic status bar, measured safe-area insets. |
8
+ | `FBrowserFrame` | A desktop browser window, screen sized to a real display aspect (16:10 or 16:9). |
9
+ | `FFitFrame` | Neither — it lays a slot out at a fixed logical width and scales the result down to fit. Use it to place either frame on a page narrower than the frame itself. |
10
+
11
+ ```vue
12
+ <script setup lang="ts">
13
+ import { FDeviceFrame } from '@humanforest/frames';
14
+ </script>
15
+
16
+ <template>
17
+ <FDeviceFrame model="iphone-16-pro" full-bleed touch>
18
+ <RiderApp />
19
+ </FDeviceFrame>
20
+ </template>
21
+ ```
22
+
23
+ The device registry (`DEVICES`, `findDevice`) carries screen points, safe-area insets, cutout type and
24
+ art metadata for 25 models. `artUrl(device)` resolves the bundled render.
25
+
26
+ ## Distribution
27
+
28
+ **Private channel only.** This package is bundled into the giget layer (`packages/nuxt-layer`) and is
29
+ never published to public npm — see the licence below for why.
30
+
31
+ ## Art licence
32
+
33
+ The hardware renders in `src/art/` are unmodified Wikimedia Commons vectors under **CC BY-SA 4.0**.
34
+ Attribution is a condition of the licence and must be displayed wherever the art is shown; `ART_CREDITS`
35
+ exports the deduped list for exactly that.
36
+
37
+ - Rafael Fernandez (Goldenbox) — most iPhone renders
38
+ - Mliu92 — Pixel 8/9/10 renders
39
+ - DM115Wiki — Pixel 8a / 9a renders
40
+ - Hajoon0102 — iPhone SE (3rd gen)
41
+
42
+ Share-alike attaches to the SVGs. Redistributing them outside the private channel would extend that
43
+ obligation to whoever receives them, which is why the public npm packages ship no device art.
44
+
45
+ ## Artwork licence
46
+
47
+ The device renders in `src/art/` are unmodified Wikimedia Commons vectors under
48
+ [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), by Rafael Fernandez, Mliu92 and
49
+ DM115Wiki. Attribution is a condition of that licence rather than a courtesy: see
50
+ [ATTRIBUTION.md](./ATTRIBUTION.md), and keep that notice with the art wherever it is redistributed
51
+ or displayed — a published build, an exported PDF, a hosted deck. The art stays CC BY-SA; the rest
52
+ of this package does not become CC BY-SA by containing it.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@humanforest/frames",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "files": [
6
+ "src",
7
+ "README.md",
8
+ "ATTRIBUTION.md"
9
+ ],
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./devices": "./src/devices.ts",
13
+ "./art": "./src/art.ts"
14
+ },
15
+ "peerDependencies": {
16
+ "vue": "^3.5",
17
+ "@nuxt/ui": "^4"
18
+ },
19
+ "devDependencies": {
20
+ "vue": "^3.5.22"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "license": "SEE LICENSE IN LICENSE"
26
+ }
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ // Desktop browser chrome — the web counterpart to DeviceFrame. A neutral macOS-style window
4
+ // (traffic lights + address pill) wrapping a live app screen rendered at the chosen logical width.
5
+ // The screen is a `@container`, so the slotted shell reflows against the frame width (not the
6
+ // viewport) — switching the Viewport control genuinely resizes the layout. Flip `dark` to theme the
7
+ // screen subtree (adds `.dark` over `bg-default text-default`, exactly like DeviceFrame's screen).
8
+ // ★ The SCREEN carries a real display aspect — 16:10 or 16:9, the two shapes a desktop actually comes in
9
+ // — and the height is derived from it rather than typed in. A hand-picked height drifts into ratios no
10
+ // monitor has, which makes a specimen's reflow decisions untestable against anything real.
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ width: number;
14
+ aspect?: '16/10' | '16/9';
15
+ /** Overrides the aspect. Only for a screen that is deliberately not a whole display. */
16
+ height?: number;
17
+ dark?: boolean;
18
+ url?: string;
19
+ }>(),
20
+ { aspect: '16/10', dark: false, url: 'console.forest.bike/fleet' },
21
+ );
22
+
23
+ const screenH = computed(() => props.height ?? Math.round(props.width * (props.aspect === '16/9' ? 9 / 16 : 10 / 16)));
24
+ </script>
25
+
26
+ <template>
27
+ <div
28
+ class="shrink-0 overflow-hidden rounded-xl border border-default bg-default"
29
+ :style="{ width: width + 'px', filter: 'drop-shadow(0 25px 45px rgba(0,0,0,.28))' }"
30
+ >
31
+ <!-- title bar -->
32
+ <div class="flex items-center gap-3 border-b border-default bg-elevated/60 px-4 py-2.5">
33
+ <div class="flex items-center gap-1.5">
34
+ <span class="size-3 rounded-full bg-[#ff5f57]"></span>
35
+ <span class="size-3 rounded-full bg-[#febc2e]"></span>
36
+ <span class="size-3 rounded-full bg-[#28c840]"></span>
37
+ </div>
38
+ <div class="mx-auto flex w-full max-w-xs items-center justify-center gap-1.5 rounded-md border border-default bg-default px-3 py-1 text-xs text-muted">
39
+ <UIcon name="i-lucide-lock" class="size-3 text-dimmed" />
40
+ {{ url }}
41
+ </div>
42
+ <UIcon name="i-lucide-plus" class="size-4 shrink-0 text-dimmed" />
43
+ </div>
44
+
45
+ <!-- live screen — its own theming context + container -->
46
+ <div
47
+ :class="['@container relative overflow-hidden bg-default text-default', { dark }]"
48
+ :style="{ height: screenH + 'px' }"
49
+ >
50
+ <slot />
51
+ </div>
52
+ </div>
53
+ </template>
@@ -0,0 +1,242 @@
1
+ <script setup lang="ts">
2
+ // Photoreal device shell: an unmodified Wikimedia SVG render is the hardware; the live HTML screen is
3
+ // overlaid at the SVG's measured screen insets and clipped to the SVG's own screen path. We draw the
4
+ // cutout (island/notch/punch-hole) + an OS-authentic status bar on top.
5
+ //
6
+ // Status bar matches the platform: iOS uses the system font (SF Pro via system-ui) with hand-drawn
7
+ // SF-style glyphs (cellular bars, wifi cone, capsule battery); Android uses Roboto with Material
8
+ // Symbols glyphs. Sizes follow each platform's status bar. Rendered at true 1× logical points.
9
+ import { computed, useId } from 'vue';
10
+ import { findDevice, type DeviceModel } from './devices';
11
+ import { artUrl } from './art';
12
+
13
+ const clipId = `screenclip-${useId()}`;
14
+
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ model: string | DeviceModel;
18
+ dark?: boolean;
19
+ statusBar?: boolean;
20
+ time?: string;
21
+ /** Overlay the safe-area insets (top/bottom) for inspection. */
22
+ showSafe?: boolean;
23
+ /** Slot content runs edge-to-edge under the status bar (maps, camera). Pad with `safe.top`. */
24
+ fullBleed?: boolean;
25
+ /** A real phone is a COARSE pointer. Opt in to stamp `[data-force-coarse]` on the screen so the
26
+ * preset's `coarse:` touch bumps fire for the content inside — the truthful render for a
27
+ * web-cut surface (standard/marketing) viewed on a device. The mobile cut stays put on its own
28
+ * (its `cut-fixed` opts the bump out — it already baked its size), and components with no
29
+ * `coarse:` bump are unaffected. Default off so existing frames don't shift. */
30
+ touch?: boolean;
31
+ }>(),
32
+ { dark: false, statusBar: true, time: '9:41', showSafe: false, fullBleed: false, touch: false },
33
+ );
34
+
35
+ const device = computed<DeviceModel>(() =>
36
+ typeof props.model === 'string' ? findDevice(props.model) : props.model,
37
+ );
38
+ const art = computed(() => device.value.art!);
39
+ const artSrc = computed(() => artUrl(device.value));
40
+ const os = computed<'ios' | 'android'>(() => (device.value.brand === 'Apple' ? 'ios' : 'android'));
41
+
42
+ const dims = computed(() => {
43
+ const a = art.value;
44
+ const w = device.value.screen.w / (1 - (a.inset.left + a.inset.right) / 100);
45
+ return { w, h: w * a.aspect };
46
+ });
47
+
48
+ const screenStyle = computed(() => {
49
+ const a = art.value;
50
+ return {
51
+ top: a.inset.top + '%',
52
+ left: a.inset.left + '%',
53
+ right: a.inset.right + '%',
54
+ bottom: a.inset.bottom + '%',
55
+ ...(a.clip
56
+ ? { clipPath: `url(#${clipId})` }
57
+ : { borderRadius: (device.value.screen.w * (a.radiusPct ?? 0)) / 100 + 'px' }),
58
+ };
59
+ });
60
+
61
+ const screenW = computed(() => device.value.screen.w);
62
+ const statusBarH = computed(() => device.value.safe.top);
63
+ const cutout = computed(() => device.value.cutout);
64
+ const island = computed(() => ({ w: Math.round(screenW.value * 0.3), h: 36, top: 10 }));
65
+ const notch = computed(() => ({ w: Math.round(screenW.value * 0.42), h: 30 }));
66
+ const compact = computed(() => cutout.value === 'home' || cutout.value === 'none');
67
+
68
+ // Safe-area bridge. A mock phone has no notch for the browser to report, so `env(safe-area-inset-*)`
69
+ // resolves to 0 in here and chrome would tuck under the island. The frame publishes the device's
70
+ // measured insets as the vars FCanvasHud reads first; on a real device nothing sets them and the
71
+ // `env()` fallback takes over. Top is only owed when the content runs full-bleed — otherwise the
72
+ // wrapper is already offset below the status bar and charging for it twice would double the gap.
73
+ const safeVars = computed(() => ({
74
+ '--forest-safe-top': (props.fullBleed ? device.value.safe.top : 0) + 'px',
75
+ '--forest-safe-bottom': device.value.safe.bottom + 'px',
76
+ }));
77
+
78
+ // Status-bar metrics, computed per device so the clock/icons sit at the cutout's vertical centre and
79
+ // clear the rounded-corner curve (no per-device hand-tuning, no clipped glyphs).
80
+ const sb = computed(() => {
81
+ const d = device.value;
82
+ const ios = os.value === 'ios';
83
+ // vertical centre of the row = centre of the cutout (island/punch-hole) or middle of a short bar
84
+ const centerY =
85
+ cutout.value === 'island' ? island.value.top + island.value.h / 2
86
+ : cutout.value === 'punch-hole' ? 19
87
+ : statusBarH.value * 0.5;
88
+ // screen corner radius (px) → how far the corner curves in at centerY
89
+ const cornerR = d.art.radiusPct ? (d.screen.w * d.art.radiusPct) / 100 : d.screen.w * (ios ? 0.13 : 0.12);
90
+ const cornerInset = centerY >= cornerR ? 0 : cornerR - Math.sqrt(cornerR * cornerR - (cornerR - centerY) ** 2);
91
+ const basePad = ios ? (compact.value ? 16 : 22) : 14;
92
+ const padX = Math.max(basePad, Math.ceil(cornerInset) + 8);
93
+ return ios
94
+ ? { padX, barH: centerY * 2, clockPx: 15, clockFont: '-apple-system, "SF Pro Text", system-ui, sans-serif', clockWeight: 600, iconGap: 5 }
95
+ : { padX, barH: centerY * 2, clockPx: 14, clockFont: 'Roboto, system-ui, sans-serif', clockWeight: 500, iconGap: 6 };
96
+ });
97
+
98
+ </script>
99
+
100
+ <template>
101
+ <div
102
+ class="device relative shrink-0"
103
+ :style="{ width: dims.w + 'px', height: dims.h + 'px', filter: 'drop-shadow(0 25px 45px rgba(0,0,0,.28))' }"
104
+ >
105
+ <img :src="artSrc" alt="" class="pointer-events-none absolute inset-0 h-full w-full select-none" draggable="false" />
106
+
107
+ <svg v-if="art.clip" class="absolute size-0" aria-hidden="true">
108
+ <defs>
109
+ <clipPath :id="clipId" clipPathUnits="objectBoundingBox"><path :d="art.clip" /></clipPath>
110
+ </defs>
111
+ </svg>
112
+
113
+ <!-- LIVE SCREEN -->
114
+ <div :class="['screen absolute overflow-hidden bg-default text-default', { dark }]" :style="screenStyle">
115
+ <!-- cutout -->
116
+ <div
117
+ v-if="cutout === 'island'"
118
+ class="absolute z-30 -translate-x-1/2 rounded-full bg-black"
119
+ :style="{ left: '50%', top: island.top + 'px', width: island.w + 'px', height: island.h + 'px' }"
120
+ ></div>
121
+ <div
122
+ v-else-if="cutout === 'notch' && !art.clip"
123
+ class="absolute left-1/2 top-0 z-30 -translate-x-1/2 bg-black"
124
+ :style="{ width: notch.w + 'px', height: notch.h + 'px', borderRadius: '0 0 18px 18px' }"
125
+ ></div>
126
+ <div
127
+ v-else-if="cutout === 'punch-hole'"
128
+ class="absolute left-1/2 top-3 z-30 size-3.5 -translate-x-1/2 rounded-full bg-black"
129
+ ></div>
130
+
131
+ <!-- OS-authentic status bar -->
132
+ <div
133
+ v-if="statusBar"
134
+ class="absolute inset-x-0 top-0 z-20 flex items-center justify-between text-highlighted"
135
+ :style="{ height: sb.barH + 'px', paddingInline: sb.padX + 'px' }"
136
+ >
137
+ <span
138
+ class="tabular-nums"
139
+ :style="{ fontFamily: sb.clockFont, fontSize: sb.clockPx + 'px', fontWeight: sb.clockWeight, letterSpacing: os === 'ios' ? '.01em' : '0' }"
140
+ >{{ time }}</span>
141
+
142
+ <!-- iOS glyphs: cellular bars · wifi cone · capsule battery -->
143
+ <div v-if="os === 'ios'" class="flex items-center" :style="{ gap: sb.iconGap + 'px' }">
144
+ <svg viewBox="0 0 18 12" height="11" fill="currentColor" aria-hidden="true">
145
+ <rect x="0" y="8" width="3" height="4" rx="1" /><rect x="5" y="5.5" width="3" height="6.5" rx="1" />
146
+ <rect x="10" y="3" width="3" height="9" rx="1" /><rect x="15" y="0" width="3" height="12" rx="1" />
147
+ </svg>
148
+ <svg viewBox="0 0 18 13" height="12" fill="currentColor" aria-hidden="true">
149
+ <path d="M9 2.4c2.9 0 5.6 1.05 7.7 2.9a.6.6 0 0 1 .03.87L9.5 13.5a.7.7 0 0 1-1 0L1.27 6.17a.6.6 0 0 1 .03-.87C3.4 3.45 6.1 2.4 9 2.4Z" />
150
+ </svg>
151
+ <svg viewBox="0 0 28 13" height="12" aria-hidden="true">
152
+ <rect x=".5" y=".5" width="23" height="12" rx="3.6" fill="none" stroke="currentColor" stroke-opacity=".4" />
153
+ <rect x="2" y="2" width="18" height="9" rx="2.2" fill="currentColor" />
154
+ <path d="M25.3 4.3c1.1.25 1.1 4.15 0 4.4Z" fill="currentColor" fill-opacity=".4" />
155
+ </svg>
156
+ </div>
157
+
158
+ <!-- Android glyphs: Material Symbols (cellular · wifi · battery) -->
159
+ <div v-else class="flex items-center" :style="{ gap: sb.iconGap + 'px' }">
160
+ <svg viewBox="0 -960 960 960" height="15" fill="currentColor" aria-hidden="true">
161
+ <path d="M260-160q-25 0-42.5-17.5T200-220v-120q0-25 17.5-42.5T260-400q25 0 42.5 17.5T320-340v120q0 25-17.5 42.5T260-160Zm240 0q-25 0-42.5-17.5T440-220v-320q0-25 17.5-42.5T500-600q25 0 42.5 17.5T560-540v320q0 25-17.5 42.5T500-160Zm240 0q-25 0-42.5-17.5T680-220v-520q0-25 17.5-42.5T740-800q25 0 42.5 17.5T800-740v520q0 25-17.5 42.5T740-160Z" />
162
+ </svg>
163
+ <svg viewBox="0 -960 960 960" height="15" fill="currentColor" aria-hidden="true">
164
+ <path d="M174-540q67-48 145-74t161-26q83 0 161 26t145 74l58-58q-79-60-172-91t-192-31q-99 0-192 31t-172 91l58 58Zm249 363L61-539q-12-12-18-27t-6-30q0-17 7-32.5T65-656q82-71 195-107.5T480-800q107 0 220 36.5T895-656q14 12 21 27.5t7 32.5q0 15-6 30t-18 27L537-177q-12 12-27 18t-30 6q-15 0-30-6t-27-18Z" />
165
+ </svg>
166
+ <svg viewBox="0 -960 960 960" height="15" fill="currentColor" aria-hidden="true">
167
+ <path d="M320-80q-17 0-28.5-11.5T280-120v-640q0-17 11.5-28.5T320-800h80v-40q0-17 11.5-28.5T440-880h80q17 0 28.5 11.5T560-840v40h80q17 0 28.5 11.5T680-760v640q0 17-11.5 28.5T640-80H320Z" />
168
+ </svg>
169
+ </div>
170
+ </div>
171
+
172
+ <!-- screen content — starts below the status bar unless fullBleed (maps, camera), in which
173
+ case it runs edge-to-edge and the status bar/cutout (z-20/z-30) draw over it -->
174
+ <div
175
+ class="absolute inset-x-0 bottom-0 overflow-hidden"
176
+ :style="{ top: fullBleed ? '0px' : statusBarH + 'px', ...safeVars }"
177
+ :data-force-coarse="touch ? '' : undefined"
178
+ >
179
+ <slot :device="device" :safe="device.safe" />
180
+ </div>
181
+
182
+ <!-- home indicator -->
183
+ <div
184
+ v-if="!compact"
185
+ class="absolute bottom-2 left-1/2 z-20 h-1 -translate-x-1/2 rounded-full bg-highlighted/40"
186
+ :style="{ width: screenW * 0.36 + 'px' }"
187
+ ></div>
188
+
189
+ <!-- safe-area overlay (toggle) -->
190
+ <template v-if="showSafe">
191
+ <div
192
+ class="pointer-events-none absolute inset-x-0 top-0 z-40 flex items-start justify-center"
193
+ :style="{ height: device.safe.top + 'px', background: 'rgba(56,189,248,.28)', borderBottom: '1px dashed rgba(2,132,199,.9)' }"
194
+ >
195
+ <span class="mt-0.5 rounded bg-sky-600 px-1 text-[9px] font-semibold text-white">{{ device.safe.top }}pt</span>
196
+ </div>
197
+ <div
198
+ v-if="device.safe.bottom > 0"
199
+ class="pointer-events-none absolute inset-x-0 bottom-0 z-40 flex items-end justify-center"
200
+ :style="{ height: device.safe.bottom + 'px', background: 'rgba(56,189,248,.28)', borderTop: '1px dashed rgba(2,132,199,.9)' }"
201
+ >
202
+ <span class="mb-0.5 rounded bg-sky-600 px-1 text-[9px] font-semibold text-white">{{ device.safe.bottom }}pt</span>
203
+ </div>
204
+ </template>
205
+ </div>
206
+ </div>
207
+ </template>
208
+
209
+ <style scoped>
210
+ /* A touch glyph over the live screen, so a mouse reading a phone mock reads as a finger, not a
211
+ pointer: a solid grey contact dot inside a translucent halo, the shape a fingertip makes. Forced
212
+ onto descendants too — otherwise every button's own `cursor: pointer` wins back.
213
+ The SVG is baked static (no tokens, no `currentColor`), so the two modes are two encoded copies —
214
+ dark grey on a light screen, light grey on a dark one. Fine pointers only.
215
+ 32px is the ceiling, not a taste call: Chrome refuses a cursor bitmap larger than 32×32 on
216
+ Windows and silently falls back to the arrow. */
217
+ @media (hover: hover) and (pointer: fine) {
218
+ .screen,
219
+ .screen :deep(*) {
220
+ cursor:
221
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%2352525B' fill-opacity='.28'/%3E%3Ccircle cx='16' cy='16' r='9.5' fill='%2352525B'/%3E%3C/svg%3E")
222
+ 16 16,
223
+ pointer;
224
+ }
225
+
226
+ /* Two ways a screen ends up dark: the frame's own `dark` prop, or the whole docs app being in
227
+ dark mode (`html.dark`) — the screen inherits that through `bg-default` without ever carrying
228
+ the class itself. Both need the light-grey glyph.
229
+ Plain descendant selectors, NOT `:global(.dark) .screen` — Vue compiles that down to bare
230
+ `.dark`, which is on `<html>`, and `cursor` inherits to the whole page. Scoping only stamps the
231
+ last compound, so an unscoped ancestor matches on its own. */
232
+ .screen.dark,
233
+ .screen.dark :deep(*),
234
+ .dark .screen,
235
+ .dark .screen :deep(*) {
236
+ cursor:
237
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%23D4D4D8' fill-opacity='.28'/%3E%3Ccircle cx='16' cy='16' r='9.5' fill='%23D4D4D8'/%3E%3C/svg%3E")
238
+ 16 16,
239
+ pointer;
240
+ }
241
+ }
242
+ </style>
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ // Lays its slot out at a fixed LOGICAL width and scales the result down to fit the space available.
3
+ //
4
+ // Why a specimen wants this: a shell only reflows honestly at a real width, and its measures are absolute
5
+ // — `railTop: 17rem` is 20% of 1360px and 65% of a 420px diagram box. Laying out at the real width and
6
+ // shrinking the picture keeps every proportion true; shrinking the width instead changes the design.
7
+ //
8
+ // ★ SCALED WITH `transform`, NEVER WITH `zoom`. Both shrink the frame; only one keeps a Mapbox canvas
9
+ // correct. Mapbox sizes its canvas from the container's LAYOUT box (clientWidth) and writes that back as
10
+ // the canvas's CSS size. `zoom` changes the layout box, so the factor lands twice: MEASURED at zoom 0.72,
11
+ // an 862px container held a 621px canvas — dead strips down two edges, and `map.resize()` cannot fix it
12
+ // because it recomputes the same wrong value. A `transform` leaves the layout box alone, so Mapbox writes
13
+ // the logical size and the transform scales the result once.
14
+ //
15
+ // The cost of a transform is that the element still OCCUPIES its unscaled box, which is what the rest of
16
+ // this component is for.
17
+ import { computed, onBeforeUnmount, ref, watch } from 'vue';
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ /** Logical width the slot lays out at, whatever the space available. */
22
+ width: number;
23
+ /** Largest scale to draw at, so a wide page does not render the specimen at 1:1. */
24
+ maxScale?: number;
25
+ }>(),
26
+ { maxScale: 1 },
27
+ );
28
+
29
+ // The wrapper's own padding, added back to the height it reserves. Weighted downwards because a frame's
30
+ // shadow is cast downwards, so that is the side that needs the room.
31
+ const PAD_TOP = 8;
32
+ const PAD_BOTTOM = 24;
33
+
34
+ const inner = ref<HTMLElement | null>(null);
35
+ const wrapper = ref<HTMLElement | null>(null);
36
+ const naturalH = ref(0);
37
+ const availW = ref(0);
38
+
39
+ // Watched rather than read in onMounted: the refs are not guaranteed to be populated by the time this
40
+ // component's mounted hook runs, and a missed attach leaves the wrapper unsized with no visible error.
41
+ // No loop between the two: the slot's unscaled height is fixed by its own content, and the wrapper's
42
+ // width is its block width — neither depends on the scale derived from them.
43
+ const innerRO = new ResizeObserver(([e]) => { naturalH.value = e.contentRect.height; });
44
+ const wrapRO = new ResizeObserver(([e]) => { availW.value = e.contentRect.width; });
45
+ watch(inner, (el) => { innerRO.disconnect(); if (el) innerRO.observe(el); }, { immediate: true, flush: 'post' });
46
+ watch(wrapper, (el) => { wrapRO.disconnect(); if (el) wrapRO.observe(el); }, { immediate: true, flush: 'post' });
47
+ onBeforeUnmount(() => { innerRO.disconnect(); wrapRO.disconnect(); });
48
+
49
+ // ★ SCALE TO FIT, capped. A fixed factor overflows its container the moment the page is narrower than
50
+ // width × scale — the slot's layout width never changes, so nothing else absorbs it.
51
+ const fit = computed(() => (availW.value ? Math.min(props.maxScale, availW.value / props.width) : props.maxScale));
52
+
53
+ // Height the scaled frame actually takes on the page. 0 until measured, so nothing jumps.
54
+ const reservedH = computed(() =>
55
+ naturalH.value ? Math.round(naturalH.value * fit.value) + PAD_TOP + PAD_BOTTOM : 0,
56
+ );
57
+ </script>
58
+
59
+ <template>
60
+ <!-- The wrapper reserves the SCALED height and the shadow's room in its own padding. It does NOT clip:
61
+ the height already stops the unscaled leftover box from pushing the page around, and that leftover
62
+ box paints nothing. Any `overflow` value here costs the shadow — `hidden` cuts it off square, and
63
+ `hidden`/`clip` on one axis forces the other to `auto`, which adds a scrollbar. -->
64
+ <div
65
+ ref="wrapper"
66
+ class="flex justify-center pt-2 pb-6"
67
+ :style="reservedH ? { height: `${reservedH}px` } : undefined"
68
+ >
69
+ <!-- `self-start` is load-bearing: without it the flex default stretches this to the wrapper's
70
+ reserved height, which the wrapper derives from THIS element's height — a loop that collapses
71
+ both to nothing. `shrink-0` only guards the main axis.
72
+ `pointer-events-none` because this box keeps its UNSCALED height, so part of it hangs past the
73
+ frame over whatever follows. Transparent, but it would still swallow clicks and text selection
74
+ there. The slot opts back in. -->
75
+ <div
76
+ ref="inner"
77
+ class="pointer-events-none shrink-0 self-start [&>*]:pointer-events-auto"
78
+ :style="{ transform: `scale(${fit})`, transformOrigin: 'top center' }"
79
+ >
80
+ <slot />
81
+ </div>
82
+ </div>
83
+ </template>