@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 +48 -0
- package/LICENSE +67 -0
- package/README.md +52 -0
- package/package.json +26 -0
- package/src/FBrowserFrame.vue +53 -0
- package/src/FDeviceFrame.vue +242 -0
- package/src/FFitFrame.vue +83 -0
- package/src/art/iphone-11.svg +124 -0
- package/src/art/iphone-12.svg +601 -0
- package/src/art/iphone-13-pro.svg +820 -0
- package/src/art/iphone-13.svg +849 -0
- package/src/art/iphone-14.svg +681 -0
- package/src/art/iphone-15-pro.svg +633 -0
- package/src/art/iphone-15.svg +713 -0
- package/src/art/iphone-16-plus.svg +685 -0
- package/src/art/iphone-16-pro-max.svg +611 -0
- package/src/art/iphone-16-pro.svg +631 -0
- package/src/art/iphone-16.svg +713 -0
- package/src/art/iphone-17-pro-max.svg +355 -0
- package/src/art/iphone-17.svg +305 -0
- package/src/art/iphone-air.svg +389 -0
- package/src/art/iphone-se-3.svg +473 -0
- package/src/art/iphone-x.svg +652 -0
- package/src/art/pixel-10-pro-xl.svg +32 -0
- package/src/art/pixel-10-pro.svg +32 -0
- package/src/art/pixel-10.svg +32 -0
- package/src/art/pixel-8-pro.svg +208 -0
- package/src/art/pixel-8.svg +208 -0
- package/src/art/pixel-8a.svg +8 -0
- package/src/art/pixel-9-pro-xl.svg +32 -0
- package/src/art/pixel-9-pro.svg +34 -0
- package/src/art/pixel-9a.svg +25 -0
- package/src/art.ts +29 -0
- package/src/devices.ts +337 -0
- package/src/index.ts +15 -0
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>
|