@marianmeres/stuic 3.193.0 → 3.194.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/AGENTS.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ```
25
25
  src/lib/
26
- ├── components/ # 83 component directories
26
+ ├── components/ # 84 component directories
27
27
  ├── actions/ # 16 Svelte actions (use: directives)
28
28
  ├── attachments/ # Svelte attachments ({@attach} — preferred for new DOM helpers)
29
29
  ├── utils/ # 55 utility modules (48 on the barrel)
@@ -146,7 +146,7 @@ so it is the only confusable pair — do not "fix" one into the other.
146
146
 
147
147
  ### Domain Docs
148
148
 
149
- - [Components](./docs/domains/components.md) — 83 component directories, Props pattern, snippets
149
+ - [Components](./docs/domains/components.md) — 84 component directories, Props pattern, snippets
150
150
  - [Theming](./docs/domains/theming.md) — CSS tokens, dark mode, themes
151
151
  - [CSS presets](./docs/domains/css-presets.md) — ratio-locked frame (letterbox), safe-area, scrollbar
152
152
  - [Actions](./docs/domains/actions.md) — 16 Svelte directives
package/API.md CHANGED
@@ -1363,6 +1363,30 @@ interface ImageCyclerImage {
1363
1363
  }
1364
1364
  ```
1365
1365
 
1366
+ #### `PhoneFrame`
1367
+
1368
+ Phone-shaped bezel around a screenshot (`src`) or any content (`children`). Block-level, sized with `class`; the screen is locked to a `390 / 844` ratio unless `screenHeight` turns it into a fixed-height window showing the top of the content. With `href` the whole frame is a link.
1369
+
1370
+ | Prop | Type | Default | Description |
1371
+ | --------------- | ------------------------------- | -------- | --------------------------------------------------------------------------- |
1372
+ | `src` | `string` | — | Screenshot URL (`object-fit: cover`, anchored to the top) |
1373
+ | `alt` | `string` | `""` | Alt text; the link's accessible name with `href` |
1374
+ | `width` | `number \| string` | — | Intrinsic pixel width of the screenshot (`<img width>`), not the frame size |
1375
+ | `height` | `number \| string` | — | Intrinsic pixel height of the screenshot (`<img height>`) |
1376
+ | `srcset` | `string` | — | `<img srcset>` pass-through |
1377
+ | `sizes` | `string` | — | `<img sizes>` pass-through |
1378
+ | `loading` | `"lazy" \| "eager"` | `"lazy"` | `eager` for a hero screenshot |
1379
+ | `fetchpriority` | `"high" \| "low" \| "auto"` | — | `<img fetchpriority>` pass-through |
1380
+ | `children` | `Snippet` | — | Custom screen content; takes precedence over `src` |
1381
+ | `href` | `string` | — | Renders `<a>` instead of `<div>` |
1382
+ | `target` | `string` | — | Link target |
1383
+ | `rel` | `string` | — | Link rel; `"noopener"` by default when `target` is `"_blank"` |
1384
+ | `aspectRatio` | `string \| number` | token | Screen `aspect-ratio` (`"390 / 844"`); ignored while `screenHeight` is set |
1385
+ | `screenHeight` | `string` | — | Fixed screen height: a clipped window of the top of the content |
1386
+ | `notch` | `"none" \| "island" \| "notch"` | `"none"` | Camera cutout over the top of the screen |
1387
+ | `classScreen` | `string` | — | Classes for the screen (clipping window) |
1388
+ | `classImage` | `string` | — | Classes for the `<img>` |
1389
+
1366
1390
  #### `ThemePreview`
1367
1391
 
1368
1392
  Theme color swatch preview.
@@ -2503,6 +2527,7 @@ Each component defines customization tokens. Override globally in `:root {}` or
2503
2527
  | Notifications | `--stuic-notification-*` | `bg`, `text`, `border` |
2504
2528
  | Tooltip | `--stuic-tooltip-*` | `bg`, `text` |
2505
2529
  | Popover | `--stuic-popover-*` | `bg`, `text`, `border` |
2530
+ | PhoneFrame | `--stuic-phone-frame-*` | `bg`, `padding`, `radius`, `border-color`, `shadow`, `screen-bg`, `screen-radius`, `screen-aspect-ratio`, `screen-height`, `island-width`, `island-top`, `notch-width`, `notch-radius`, `notch-bg`, `ring-color` |
2506
2531
  | Skeleton | `--stuic-skeleton-*` | `bg`, `bg-highlight`, `duration` |
2507
2532
  | Spotlight | `--stuic-spotlight-*` | `backdrop-bg`, `annotation-bg`, `annotation-text`, `annotation-border` |
2508
2533
  | Cart | `--stuic-cart-*` | `gap`, `item-padding`, `item-radius`, `item-border-color`, `item-bg`, `thumbnail-size`, `quantity-border-color`, `remove-color`, `summary-border-color`, `compact-max-height`, `transition` |
package/README.md CHANGED
@@ -191,7 +191,7 @@ CommandMenu, DropdownMenu, TabbedMenu, TypeaheadInput, KbdShortcut
191
191
 
192
192
  ### Display & Utility
193
193
 
194
- Avatar, Pill, Book, BookResponsive, Card, Carousel, Circle, AnimatedElipsis, H, IconSwap, ImageCycler, Separator, ThemePreview, Tree, ColorScheme, Thc, HoverExpandableWidth, AssetsPreview, AssetsPreviewInline, DataTable
194
+ Avatar, Pill, Book, BookResponsive, Card, Carousel, Circle, AnimatedElipsis, H, IconSwap, ImageCycler, PhoneFrame, Separator, ThemePreview, Tree, ColorScheme, Thc, HoverExpandableWidth, AssetsPreview, AssetsPreviewInline, DataTable
195
195
 
196
196
  ### E-commerce
197
197
 
@@ -12,7 +12,7 @@
12
12
  --stuic-empty-state-padding-sm: 1.5rem;
13
13
  --stuic-empty-state-icon-size-sm: 2rem;
14
14
  --stuic-empty-state-title-font-size-sm: var(--text-base);
15
- --stuic-empty-state-description-font-size-sm: var(--text-xs);
15
+ --stuic-empty-state-description-font-size-sm: var(--text-sm);
16
16
 
17
17
  /* Size: md */
18
18
  --stuic-empty-state-padding-md: 3rem;
@@ -0,0 +1,165 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { Snippet } from "svelte";
4
+
5
+ /** Camera cutout drawn over the top of the screen. */
6
+ export type PhoneFrameNotch = "none" | "island" | "notch";
7
+
8
+ export interface Props extends Omit<HTMLAttributes<HTMLElement>, "children" | "class"> {
9
+ /**
10
+ * Screenshot URL. The image fills the screen (`object-fit: cover`, anchored to the
11
+ * top). Omit it and render your own content with `children` instead.
12
+ */
13
+ src?: string;
14
+ /** Alt text of the screenshot. With `href` this is also the link's accessible name. */
15
+ alt?: string;
16
+ /**
17
+ * Intrinsic pixel width of the screenshot (the `<img width>` attribute, so the
18
+ * browser reserves space before the image loads). This does NOT size the frame —
19
+ * size the frame with `class` (e.g. `w-64`).
20
+ */
21
+ width?: number | string;
22
+ /** Intrinsic pixel height of the screenshot (see `width`). */
23
+ height?: number | string;
24
+ /** `<img srcset>` pass-through, for 1×/2× screenshots. */
25
+ srcset?: string;
26
+ /** `<img sizes>` pass-through. */
27
+ sizes?: string;
28
+ /** Image loading. `lazy` by default; use `eager` for a hero (above-the-fold) screenshot. */
29
+ loading?: "lazy" | "eager";
30
+ /** `<img fetchpriority>` pass-through. `high` for the LCP screenshot. */
31
+ fetchpriority?: "high" | "low" | "auto";
32
+ /**
33
+ * Custom screen content (an iframe, a live component, a video...). Takes precedence
34
+ * over `src`. The screen clips it and fixes its size; make the content fill it
35
+ * (`w-full h-full`).
36
+ */
37
+ children?: Snippet;
38
+ /** Makes the whole frame a link: renders `<a>` instead of `<div>`. */
39
+ href?: string;
40
+ /** Link target (e.g. `"_blank"`). Only relevant when `href` is set. */
41
+ target?: string;
42
+ /** Link `rel`. Defaults to `"noopener"` when `target` is `"_blank"`. */
43
+ rel?: string;
44
+ /**
45
+ * Screen aspect ratio as a CSS `aspect-ratio` value (`"390 / 844"`, `390 / 844`,
46
+ * `0.46`). Defaults to the `--stuic-phone-frame-screen-aspect-ratio` token
47
+ * (`390 / 844`, an iPhone 14 class screen in CSS px). Ignored while `screenHeight`
48
+ * is set.
49
+ */
50
+ aspectRatio?: string | number;
51
+ /**
52
+ * Fixed screen height (any CSS length). The screen becomes a window of that height
53
+ * showing the TOP of the content, the rest is clipped and the aspect ratio no
54
+ * longer applies. For a responsive version (clipped on narrow screens, whole phone
55
+ * from a breakpoint up) set the `--stuic-phone-frame-screen-height` token through
56
+ * `class` instead — see the README.
57
+ */
58
+ screenHeight?: string;
59
+ /** Camera cutout drawn over the top of the screen. `none` by default. */
60
+ notch?: PhoneFrameNotch;
61
+ /** Skip all default styling */
62
+ unstyled?: boolean;
63
+ /** Additional CSS classes for the frame (the bezel). Size the frame here. */
64
+ class?: string;
65
+ /** Additional CSS classes for the screen (the clipping window inside the bezel). */
66
+ classScreen?: string;
67
+ /** Additional CSS classes for the `<img>`. */
68
+ classImage?: string;
69
+ /** Bindable element reference (the `<div>` or `<a>`). */
70
+ el?: HTMLElement;
71
+ }
72
+ </script>
73
+
74
+ <script lang="ts">
75
+ import { twMerge } from "../../utils/tw-merge.js";
76
+
77
+ let {
78
+ src,
79
+ alt = "",
80
+ width,
81
+ height,
82
+ srcset,
83
+ sizes,
84
+ loading = "lazy",
85
+ fetchpriority,
86
+ children,
87
+ href,
88
+ target,
89
+ rel,
90
+ aspectRatio,
91
+ screenHeight,
92
+ notch = "none",
93
+ unstyled = false,
94
+ class: classProp,
95
+ classScreen: classScreenProp,
96
+ classImage: classImageProp,
97
+ el = $bindable(),
98
+ ...rest
99
+ }: Props = $props();
100
+
101
+ let _class = $derived(unstyled ? classProp : twMerge("stuic-phone-frame", classProp));
102
+ let _classScreen = $derived(
103
+ unstyled ? classScreenProp : twMerge("stuic-phone-frame-screen", classScreenProp)
104
+ );
105
+ let _classImage = $derived(
106
+ unstyled ? classImageProp : twMerge("stuic-phone-frame-image", classImageProp)
107
+ );
108
+
109
+ // Prop-driven tokens go on the screen itself (closest to where they are read), so
110
+ // `style` from rest props stays untouched on the root.
111
+ let _screenStyle = $derived.by(() => {
112
+ const styles: string[] = [];
113
+ if (aspectRatio !== undefined && aspectRatio !== "") {
114
+ styles.push(`--stuic-phone-frame-screen-aspect-ratio: ${aspectRatio}`);
115
+ }
116
+ if (screenHeight) styles.push(`--stuic-phone-frame-screen-height: ${screenHeight}`);
117
+ return styles.length ? styles.join("; ") : undefined;
118
+ });
119
+
120
+ let _notch = $derived(!unstyled && notch !== "none" ? notch : undefined);
121
+
122
+ let _rel = $derived(rel ?? (target === "_blank" ? "noopener" : undefined));
123
+ </script>
124
+
125
+ {#snippet screen()}
126
+ <div class={_classScreen} style={_screenStyle}>
127
+ {#if children}
128
+ {@render children()}
129
+ {:else if src}
130
+ <img
131
+ {src}
132
+ {alt}
133
+ {width}
134
+ {height}
135
+ {srcset}
136
+ {sizes}
137
+ {loading}
138
+ {fetchpriority}
139
+ decoding="async"
140
+ class={_classImage}
141
+ />
142
+ {/if}
143
+ {#if _notch}
144
+ <div class="stuic-phone-frame-notch" aria-hidden="true"></div>
145
+ {/if}
146
+ </div>
147
+ {/snippet}
148
+
149
+ {#if href}
150
+ <a
151
+ bind:this={el}
152
+ {href}
153
+ {target}
154
+ rel={_rel}
155
+ class={_class}
156
+ data-notch={_notch}
157
+ {...rest}
158
+ >
159
+ {@render screen()}
160
+ </a>
161
+ {:else}
162
+ <div bind:this={el} class={_class} data-notch={_notch} {...rest}>
163
+ {@render screen()}
164
+ </div>
165
+ {/if}
@@ -0,0 +1,71 @@
1
+ import type { HTMLAttributes } from "svelte/elements";
2
+ import type { Snippet } from "svelte";
3
+ /** Camera cutout drawn over the top of the screen. */
4
+ export type PhoneFrameNotch = "none" | "island" | "notch";
5
+ export interface Props extends Omit<HTMLAttributes<HTMLElement>, "children" | "class"> {
6
+ /**
7
+ * Screenshot URL. The image fills the screen (`object-fit: cover`, anchored to the
8
+ * top). Omit it and render your own content with `children` instead.
9
+ */
10
+ src?: string;
11
+ /** Alt text of the screenshot. With `href` this is also the link's accessible name. */
12
+ alt?: string;
13
+ /**
14
+ * Intrinsic pixel width of the screenshot (the `<img width>` attribute, so the
15
+ * browser reserves space before the image loads). This does NOT size the frame —
16
+ * size the frame with `class` (e.g. `w-64`).
17
+ */
18
+ width?: number | string;
19
+ /** Intrinsic pixel height of the screenshot (see `width`). */
20
+ height?: number | string;
21
+ /** `<img srcset>` pass-through, for 1×/2× screenshots. */
22
+ srcset?: string;
23
+ /** `<img sizes>` pass-through. */
24
+ sizes?: string;
25
+ /** Image loading. `lazy` by default; use `eager` for a hero (above-the-fold) screenshot. */
26
+ loading?: "lazy" | "eager";
27
+ /** `<img fetchpriority>` pass-through. `high` for the LCP screenshot. */
28
+ fetchpriority?: "high" | "low" | "auto";
29
+ /**
30
+ * Custom screen content (an iframe, a live component, a video...). Takes precedence
31
+ * over `src`. The screen clips it and fixes its size; make the content fill it
32
+ * (`w-full h-full`).
33
+ */
34
+ children?: Snippet;
35
+ /** Makes the whole frame a link: renders `<a>` instead of `<div>`. */
36
+ href?: string;
37
+ /** Link target (e.g. `"_blank"`). Only relevant when `href` is set. */
38
+ target?: string;
39
+ /** Link `rel`. Defaults to `"noopener"` when `target` is `"_blank"`. */
40
+ rel?: string;
41
+ /**
42
+ * Screen aspect ratio as a CSS `aspect-ratio` value (`"390 / 844"`, `390 / 844`,
43
+ * `0.46`). Defaults to the `--stuic-phone-frame-screen-aspect-ratio` token
44
+ * (`390 / 844`, an iPhone 14 class screen in CSS px). Ignored while `screenHeight`
45
+ * is set.
46
+ */
47
+ aspectRatio?: string | number;
48
+ /**
49
+ * Fixed screen height (any CSS length). The screen becomes a window of that height
50
+ * showing the TOP of the content, the rest is clipped and the aspect ratio no
51
+ * longer applies. For a responsive version (clipped on narrow screens, whole phone
52
+ * from a breakpoint up) set the `--stuic-phone-frame-screen-height` token through
53
+ * `class` instead — see the README.
54
+ */
55
+ screenHeight?: string;
56
+ /** Camera cutout drawn over the top of the screen. `none` by default. */
57
+ notch?: PhoneFrameNotch;
58
+ /** Skip all default styling */
59
+ unstyled?: boolean;
60
+ /** Additional CSS classes for the frame (the bezel). Size the frame here. */
61
+ class?: string;
62
+ /** Additional CSS classes for the screen (the clipping window inside the bezel). */
63
+ classScreen?: string;
64
+ /** Additional CSS classes for the `<img>`. */
65
+ classImage?: string;
66
+ /** Bindable element reference (the `<div>` or `<a>`). */
67
+ el?: HTMLElement;
68
+ }
69
+ declare const PhoneFrame: import("svelte").Component<Props, {}, "el">;
70
+ type PhoneFrame = ReturnType<typeof PhoneFrame>;
71
+ export default PhoneFrame;
@@ -0,0 +1,260 @@
1
+ # PhoneFrame
2
+
3
+ A phone-shaped bezel around a screenshot — the "picture of the product" on a landing page, a
4
+ preview of a page you are about to publish, a device mockup in docs. The screen holds an `<img>`
5
+ (`src`), or any content you render yourself (`children`: an iframe, a live component, a video).
6
+ With `href` the whole frame is a link to the page it pictures.
7
+
8
+ The frame is a block element and fills its container: **size it with `class`** (`w-64`,
9
+ `max-w-xs mx-auto`, a grid column). The screen is locked to a phone aspect ratio (`390 / 844`,
10
+ an iPhone 14 class screen in CSS px) and derives its height from the width, so the frame scales
11
+ with its column. Set `screenHeight` to turn the screen into a fixed-height window that shows only
12
+ the top of the content — what a visitor sees first — with the rest clipped.
13
+
14
+ ## Props
15
+
16
+ | Prop | Type | Default | Description |
17
+ | --------------- | ------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
18
+ | `src` | `string` | - | Screenshot URL. Fills the screen (`object-fit: cover`, anchored to the top) |
19
+ | `alt` | `string` | `""` | Alt text of the screenshot. With `href` it is also the link's accessible name |
20
+ | `width` | `number \| string` | - | Intrinsic pixel width of the screenshot (`<img width>`, reserves space before load). Does **not** size the frame — use `class` |
21
+ | `height` | `number \| string` | - | Intrinsic pixel height of the screenshot (`<img height>`) |
22
+ | `srcset` | `string` | - | `<img srcset>` pass-through (1×/2× screenshots) |
23
+ | `sizes` | `string` | - | `<img sizes>` pass-through |
24
+ | `loading` | `"lazy" \| "eager"` | `"lazy"` | Image loading. `eager` for a hero (above-the-fold) screenshot |
25
+ | `fetchpriority` | `"high" \| "low" \| "auto"` | - | `<img fetchpriority>` pass-through. `high` for the LCP screenshot |
26
+ | `children` | `Snippet` | - | Custom screen content; takes precedence over `src`. Make it fill the screen (`w-full h-full`) |
27
+ | `href` | `string` | - | Makes the whole frame a link (`<a>` instead of `<div>`) |
28
+ | `target` | `string` | - | Link target (e.g. `"_blank"`). Only with `href` |
29
+ | `rel` | `string` | - | Link `rel`. Defaults to `"noopener"` when `target` is `"_blank"` |
30
+ | `aspectRatio` | `string \| number` | token | Screen aspect ratio, any CSS `aspect-ratio` value (`"390 / 844"`, `390 / 844`, `0.46`). Ignored while `screenHeight` is set |
31
+ | `screenHeight` | `string` | - | Fixed screen height (CSS length): a window showing the top of the content, the rest clipped, the ratio dropped. See Clipped window |
32
+ | `notch` | `"none" \| "island" \| "notch"` | `"none"` | Camera cutout drawn over the top of the screen |
33
+ | `unstyled` | `boolean` | `false` | Skip all default styling |
34
+ | `class` | `string` | - | Classes for the frame (the bezel). Size the frame here |
35
+ | `classScreen` | `string` | - | Classes for the screen (the clipping window inside the bezel) |
36
+ | `classImage` | `string` | - | Classes for the `<img>` |
37
+ | `el` | `HTMLElement` | - | Bindable element reference (the `<div>` or `<a>`) |
38
+
39
+ Other attributes (`style`, `title`, `aria-*`, `data-*`, `onclick`, …) are spread onto the frame
40
+ element.
41
+
42
+ ## Usage
43
+
44
+ ### A screenshot
45
+
46
+ ```svelte
47
+ <script lang="ts">
48
+ import { PhoneFrame } from "@marianmeres/stuic";
49
+ </script>
50
+
51
+ <PhoneFrame
52
+ class="w-64"
53
+ src="/examples/apartment-page.webp"
54
+ alt="The apartment's page on a phone: Wi-Fi name and password, the key box code"
55
+ width={780}
56
+ height={1688}
57
+ />
58
+ ```
59
+
60
+ Take the screenshot at the screen's CSS size (390×844) on a 2× display and pass the file's pixel
61
+ size as `width`/`height`. With `object-fit: cover` a screenshot of a different ratio still fills the
62
+ screen; the top stays visible and the bottom is cropped.
63
+
64
+ ### A link to the live page
65
+
66
+ ```svelte
67
+ <PhoneFrame
68
+ class="w-64"
69
+ src="/examples/apartment-page.webp"
70
+ alt="Open the live apartment page"
71
+ width={780}
72
+ height={1688}
73
+ href="/i/abc123"
74
+ target="_blank"
75
+ />
76
+ ```
77
+
78
+ `target="_blank"` gets `rel="noopener"` unless you pass `rel` yourself. The `alt` is the link's
79
+ name — write it as the action, not as a picture description.
80
+
81
+ ### The hero screenshot (LCP)
82
+
83
+ Everything below the fold stays lazy; the one in the hero should not:
84
+
85
+ ```svelte
86
+ <PhoneFrame
87
+ class="w-72"
88
+ {src}
89
+ {alt}
90
+ {width}
91
+ {height}
92
+ loading="eager"
93
+ fetchpriority="high"
94
+ />
95
+ ```
96
+
97
+ ### Clipped window: the first screen only
98
+
99
+ On a narrow viewport a whole phone is a tall picture of mostly scrolled-away content. Show the
100
+ top rows at a fixed height instead:
101
+
102
+ ```svelte
103
+ <PhoneFrame class="w-64" {src} {alt} {width} {height} screenHeight="20rem" />
104
+ ```
105
+
106
+ For the common responsive version — clipped on narrow screens, the whole phone from a breakpoint
107
+ up — set the token through `class` (it is inherited by the screen) and leave `screenHeight` unset:
108
+
109
+ ```svelte
110
+ <PhoneFrame
111
+ class="w-full lg:w-72 [--stuic-phone-frame-screen-height:20rem] lg:[--stuic-phone-frame-screen-height:auto]"
112
+ {src}
113
+ {alt}
114
+ {width}
115
+ {height}
116
+ />
117
+ ```
118
+
119
+ Don't combine the two: the `screenHeight` prop is an inline declaration on the screen and beats
120
+ an inherited token at every breakpoint.
121
+
122
+ ### Phone cut off by the section edge
123
+
124
+ The other hero pattern — a phone emerging from the bottom of the section — needs no component
125
+ support. Clip the frame with your own wrapper:
126
+
127
+ ```svelte
128
+ <div class="max-h-80 overflow-hidden">
129
+ <PhoneFrame class="w-72" {src} {alt} {width} {height} />
130
+ </div>
131
+ ```
132
+
133
+ ### Camera cutout
134
+
135
+ ```svelte
136
+ <PhoneFrame class="w-64" {src} {alt} notch="island" />
137
+ <PhoneFrame class="w-64" {src} {alt} notch="notch" />
138
+ ```
139
+
140
+ The cutout is painted over the screen content in the bezel color, so a screenshot taken in a plain
141
+ browser window (no status bar) gets its top few pixels covered. It is `pointer-events: none` and
142
+ `aria-hidden`.
143
+
144
+ ### Your own screen content
145
+
146
+ ```svelte
147
+ <PhoneFrame class="w-72">
148
+ <iframe src="/preview" title="Live preview" class="w-full h-full border-0"></iframe>
149
+ </PhoneFrame>
150
+
151
+ <PhoneFrame class="w-72" notch="island">
152
+ <div class="w-full h-full overflow-y-auto bg-white p-4 pt-10">
153
+ <!-- a live component tree -->
154
+ </div>
155
+ </PhoneFrame>
156
+ ```
157
+
158
+ The screen clips (`overflow: hidden`) and has a definite height, so `h-full` on the content
159
+ works. Scrolling content needs its own `overflow-y-auto`.
160
+
161
+ ### Another device ratio
162
+
163
+ ```svelte
164
+ <!-- a tablet: 3:4 screen, bigger bezel radius -->
165
+ <PhoneFrame
166
+ class="w-96"
167
+ aspectRatio="3 / 4"
168
+ style="--stuic-phone-frame-radius: 1.25rem; --stuic-phone-frame-padding: 0.75rem;"
169
+ {src}
170
+ {alt}
171
+ />
172
+ ```
173
+
174
+ ### Unstyled
175
+
176
+ ```svelte
177
+ <PhoneFrame
178
+ unstyled
179
+ class="rounded-3xl bg-zinc-800 p-3"
180
+ classScreen="aspect-[9/19.5] overflow-hidden rounded-2xl"
181
+ classImage="h-full w-full object-cover"
182
+ {src}
183
+ {alt}
184
+ />
185
+ ```
186
+
187
+ `unstyled` drops every `stuic-phone-frame*` class and the cutout; the three class props are then
188
+ the only styling.
189
+
190
+ ## CSS Variables
191
+
192
+ ### Component Tokens
193
+
194
+ Override globally in `:root` or locally via the `style` prop:
195
+
196
+ | Variable | Default | Description |
197
+ | ----------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------- |
198
+ | `--stuic-phone-frame-bg` | `#171717` | Bezel color |
199
+ | `--stuic-phone-frame-padding` | `0.5rem` | Bezel thickness |
200
+ | `--stuic-phone-frame-radius` | `1.75rem` | Bezel (outer) corner radius |
201
+ | `--stuic-phone-frame-border-width` | `--stuic-border-width` | Hairline around the bezel (fallback: the shared border-width tier) |
202
+ | `--stuic-phone-frame-border-color` | `rgb(0 0 0 / 0.4)` | Hairline color (`rgb(255 255 255 / 0.15)` in dark mode) |
203
+ | `--stuic-phone-frame-shadow` | `--stuic-shadow-dialog` | Drop shadow (fallback: the shared dialog shadow tier) |
204
+ | `--stuic-phone-frame-screen-bg` | `--stuic-color-background` | Screen color, visible before the image loads or behind letterboxed content |
205
+ | `--stuic-phone-frame-screen-radius` | concentric | Screen corner radius. Default: bezel radius − padding − border width |
206
+ | `--stuic-phone-frame-screen-aspect-ratio` | `390 / 844` | Screen ratio while the height is `auto`. The `aspectRatio` prop sets it inline |
207
+ | `--stuic-phone-frame-screen-height` | `auto` | A length makes the screen a clipped window of that height. The `screenHeight` prop sets it inline |
208
+ | `--stuic-phone-frame-island-width` | `32%` | Dynamic-island width, relative to the screen width (the height follows, 126:37) |
209
+ | `--stuic-phone-frame-island-top` | `3%` | Dynamic-island offset from the top. A `%` is relative to the screen **width** (it is a margin) |
210
+ | `--stuic-phone-frame-notch-width` | `54%` | Classic notch width, relative to the screen width (the height follows, 209:30) |
211
+ | `--stuic-phone-frame-notch-radius` | `1rem` | Classic notch bottom corner radius |
212
+ | `--stuic-phone-frame-notch-bg` | `--stuic-phone-frame-bg` | Cutout color (both variants) |
213
+ | `--stuic-phone-frame-ring-width` | `3px` | Focus ring width (linked frames, `:focus-visible`) |
214
+ | `--stuic-phone-frame-ring-offset` | `2px` | Focus ring offset |
215
+ | `--stuic-phone-frame-ring-color` | `--stuic-color-ring` | Focus ring color |
216
+
217
+ The radius, border width and shadow intentionally do **not** follow `--stuic-radius-container`:
218
+ a device bezel is a hardware shape, not UI chrome. The hairline and shadow do follow the shared
219
+ tiers, so a flat theme (`--stuic-border-width: 0; --stuic-shadow-dialog: none`) flattens the phone
220
+ too.
221
+
222
+ ### Customization Examples
223
+
224
+ ```css
225
+ /* Global — a light "silver" phone */
226
+ :root {
227
+ --stuic-phone-frame-bg: #e5e5e5;
228
+ --stuic-phone-frame-border-color: rgb(0 0 0 / 0.15);
229
+ }
230
+
231
+ /* Global — thicker bezel, keeps the screen corners concentric */
232
+ :root {
233
+ --stuic-phone-frame-padding: 0.75rem;
234
+ --stuic-phone-frame-radius: 2.25rem;
235
+ }
236
+ ```
237
+
238
+ ```svelte
239
+ <!-- Local — no shadow, flat corners -->
240
+ <PhoneFrame
241
+ style="--stuic-phone-frame-shadow: none; --stuic-phone-frame-radius: 0.5rem;"
242
+ {src}
243
+ {alt}
244
+ />
245
+ ```
246
+
247
+ ## Accessibility
248
+
249
+ - With `href` the frame is a link whose accessible name is the image's `alt` (or your
250
+ `children`'s text). Give it one: an empty `alt` on a linked frame is an unnamed link.
251
+ - Without `href` the frame is a plain container; the image is the only content a screen reader
252
+ sees, so a decorative screenshot can keep the default empty `alt`.
253
+ - The camera cutout is `aria-hidden` and ignores pointer events.
254
+ - Linked frames show a focus ring on `:focus-visible` (`--stuic-phone-frame-ring-*`).
255
+
256
+ ## Theming
257
+
258
+ Only the screen background (`--stuic-color-background`) and the focus ring (`--stuic-color-ring`)
259
+ come from the theme; the bezel is a fixed near-black in both schemes, with a lighter hairline in
260
+ dark mode so it keeps an edge against dark page backgrounds.
@@ -0,0 +1,114 @@
1
+ /* ============================================================================
2
+ PHONE FRAME COMPONENT TOKENS
3
+ Override globally: :root { --stuic-phone-frame-bg: #e5e5e5; }
4
+ Override locally: <PhoneFrame style="--stuic-phone-frame-padding: 0.75rem;">
5
+ ============================================================================ */
6
+
7
+ /* prettier-ignore */
8
+ :root {
9
+ /* Bezel */
10
+ --stuic-phone-frame-bg: #171717;
11
+ --stuic-phone-frame-padding: 0.5rem;
12
+ --stuic-phone-frame-radius: 1.75rem;
13
+ --stuic-phone-frame-border-color: rgb(0 0 0 / 0.4);
14
+
15
+ /* Screen */
16
+ --stuic-phone-frame-screen-bg: var(--stuic-color-background);
17
+ --stuic-phone-frame-screen-aspect-ratio: 390 / 844; /* iPhone 14 class, CSS px */
18
+ --stuic-phone-frame-screen-height: auto; /* a length = clipped window */
19
+
20
+ /* Dynamic-island cutout (notch="island"); sizes relative to the screen width */
21
+ --stuic-phone-frame-island-width: 32%;
22
+ --stuic-phone-frame-island-top: 3%;
23
+
24
+ /* Classic notch cutout (notch="notch"); sizes relative to the screen width */
25
+ --stuic-phone-frame-notch-width: 54%;
26
+ --stuic-phone-frame-notch-radius: 1rem;
27
+
28
+ /* Focus ring (linked frames) */
29
+ --stuic-phone-frame-ring-width: 3px;
30
+ --stuic-phone-frame-ring-offset: 2px;
31
+ --stuic-phone-frame-ring-color: var(--stuic-color-ring);
32
+ }
33
+
34
+ :root.dark {
35
+ --stuic-phone-frame-border-color: rgb(255 255 255 / 0.15);
36
+ }
37
+
38
+ @layer components {
39
+ /* The bezel. Block-level so it fills its container: size it with `class`. */
40
+ .stuic-phone-frame {
41
+ display: block;
42
+ box-sizing: border-box;
43
+ padding: var(--stuic-phone-frame-padding);
44
+ background: var(--stuic-phone-frame-bg);
45
+ border-style: solid;
46
+ border-width: var(--stuic-phone-frame-border-width, var(--stuic-border-width));
47
+ border-color: var(--stuic-phone-frame-border-color);
48
+ border-radius: var(--stuic-phone-frame-radius);
49
+ box-shadow: var(--stuic-phone-frame-shadow, var(--stuic-shadow-dialog));
50
+ color: inherit;
51
+ text-decoration: none;
52
+ }
53
+
54
+ a.stuic-phone-frame:focus-visible {
55
+ outline: var(--stuic-phone-frame-ring-width) solid var(--stuic-phone-frame-ring-color);
56
+ outline-offset: var(--stuic-phone-frame-ring-offset);
57
+ }
58
+
59
+ /* The screen: a clipping window. `width: 100%` + `height: auto` lets `aspect-ratio`
60
+ derive the height; a length in --stuic-phone-frame-screen-height makes both axes
61
+ definite, so `aspect-ratio` is ignored and the window shows the top of the content
62
+ (see RATIO_LOCKED_FRAME.md, G14). The radius is concentric with the bezel's unless
63
+ overridden. */
64
+ .stuic-phone-frame-screen {
65
+ position: relative;
66
+ width: 100%;
67
+ height: var(--stuic-phone-frame-screen-height);
68
+ aspect-ratio: var(--stuic-phone-frame-screen-aspect-ratio);
69
+ overflow: hidden;
70
+ border-radius: var(
71
+ --stuic-phone-frame-screen-radius,
72
+ calc(
73
+ var(--stuic-phone-frame-radius) - var(--stuic-phone-frame-padding) -
74
+ var(--stuic-phone-frame-border-width, var(--stuic-border-width))
75
+ )
76
+ );
77
+ background: var(--stuic-phone-frame-screen-bg);
78
+ }
79
+
80
+ .stuic-phone-frame-image {
81
+ display: block;
82
+ width: 100%;
83
+ height: 100%;
84
+ object-fit: cover;
85
+ object-position: top;
86
+ }
87
+
88
+ /* Camera cutout, overlaid on the screen content. Width-relative so it keeps its
89
+ shape in a clipped (fixed-height) screen too: the height comes from aspect-ratio,
90
+ and the island's top offset is a margin (a % margin resolves against the
91
+ containing block's WIDTH, even vertically). */
92
+ .stuic-phone-frame-notch {
93
+ position: absolute;
94
+ top: 0;
95
+ left: 50%;
96
+ transform: translateX(-50%);
97
+ pointer-events: none;
98
+ background: var(--stuic-phone-frame-notch-bg, var(--stuic-phone-frame-bg));
99
+ }
100
+
101
+ .stuic-phone-frame[data-notch="island"] .stuic-phone-frame-notch {
102
+ width: var(--stuic-phone-frame-island-width);
103
+ aspect-ratio: 126 / 37;
104
+ margin-top: var(--stuic-phone-frame-island-top);
105
+ border-radius: 9999px;
106
+ }
107
+
108
+ .stuic-phone-frame[data-notch="notch"] .stuic-phone-frame-notch {
109
+ width: var(--stuic-phone-frame-notch-width);
110
+ aspect-ratio: 209 / 30;
111
+ border-radius: 0 0 var(--stuic-phone-frame-notch-radius)
112
+ var(--stuic-phone-frame-notch-radius);
113
+ }
114
+ }
@@ -0,0 +1 @@
1
+ export { default as PhoneFrame, type Props as PhoneFrameProps, type PhoneFrameNotch, } from "./PhoneFrame.svelte";
@@ -0,0 +1 @@
1
+ export { default as PhoneFrame, } from "./PhoneFrame.svelte";
package/dist/index.css CHANGED
@@ -119,6 +119,7 @@ In practice:
119
119
  @import "./components/Notifications/index.css";
120
120
  @import "./components/OtpInput/index.css";
121
121
  @import "./components/Pagination/index.css";
122
+ @import "./components/PhoneFrame/index.css";
122
123
  @import "./components/Pill/index.css";
123
124
  @import "./components/PricingTable/index.css";
124
125
  @import "./components/Progress/index.css";
package/dist/index.d.ts CHANGED
@@ -75,6 +75,7 @@ export * from "./components/Nav/index.js";
75
75
  export * from "./components/Notifications/index.js";
76
76
  export * from "./components/OtpInput/index.js";
77
77
  export * from "./components/Pagination/index.js";
78
+ export * from "./components/PhoneFrame/index.js";
78
79
  export * from "./components/Pill/index.js";
79
80
  export * from "./components/PricingTable/index.js";
80
81
  export * from "./components/Progress/index.js";
package/dist/index.js CHANGED
@@ -81,6 +81,7 @@ export * from "./components/Nav/index.js";
81
81
  export * from "./components/Notifications/index.js";
82
82
  export * from "./components/OtpInput/index.js";
83
83
  export * from "./components/Pagination/index.js";
84
+ export * from "./components/PhoneFrame/index.js";
84
85
  export * from "./components/Pill/index.js";
85
86
  export * from "./components/PricingTable/index.js";
86
87
  export * from "./components/Progress/index.js";
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- 83 Svelte 5 component directories with consistent API patterns. All use runes-based reactivity.
5
+ 84 Svelte 5 component directories with consistent API patterns. All use runes-based reactivity.
6
6
 
7
7
  ## Component Categories
8
8
 
@@ -118,6 +118,7 @@
118
118
  | Tree | Hierarchical tree view with keyboard nav and drag-and-drop |
119
119
  | X | Styled close/multiply SVG icon |
120
120
  | ImageCycler | Auto-cycling image carousel with fade transitions and preloading |
121
+ | PhoneFrame | Phone-shaped bezel around a screenshot (or any content): link, clipped top window, notch, tokens |
121
122
 
122
123
  ### E-commerce
123
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.193.0",
3
+ "version": "3.194.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -172,13 +172,13 @@
172
172
  "prettier": "^3.9.9",
173
173
  "prettier-plugin-svelte": "^3.5.2",
174
174
  "publint": "^0.3.25",
175
- "svelte": "^5.57.1",
175
+ "svelte": "^5.57.2",
176
176
  "svelte-check": "^4.7.6",
177
177
  "tailwindcss": "^4.3.3",
178
178
  "tsx": "^4.23.15",
179
179
  "typescript": "^5.9.3",
180
- "typescript-eslint": "^8.71.0",
181
- "vite": "^7.3.6",
180
+ "typescript-eslint": "^8.71.1",
181
+ "vite": "^7.3.7",
182
182
  "vitest": "^4.1.11",
183
183
  "vitest-browser-svelte": "^2.2.1"
184
184
  },