@marianmeres/stuic 3.152.0 → 3.153.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 +3 -0
- package/API.md +1 -0
- package/README.md +72 -0
- package/dist/css/frame.css +109 -0
- package/dist/index.css +3 -0
- package/docs/RATIO_LOCKED_FRAME.md +466 -0
- package/docs/architecture.md +6 -0
- package/docs/domains/css-presets.md +304 -0
- package/docs/domains/theming.md +2 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -28,6 +28,7 @@ src/lib/
|
|
|
28
28
|
├── attachments/ # Svelte attachments ({@attach} — preferred for new DOM helpers)
|
|
29
29
|
├── utils/ # 44 utility modules
|
|
30
30
|
├── icons/ # Icon re-exports from @marianmeres/icons-fns
|
|
31
|
+
├── css/ # CSS-only presets (ratio-locked frame / letterbox)
|
|
31
32
|
├── index.css # Centralized CSS imports
|
|
32
33
|
└── index.ts # Main exports
|
|
33
34
|
```
|
|
@@ -130,6 +131,7 @@ Global tokens that control cross-component visual properties. Defined in `src/li
|
|
|
130
131
|
|
|
131
132
|
- [Components](./docs/domains/components.md) — 63 component directories, Props pattern, snippets
|
|
132
133
|
- [Theming](./docs/domains/theming.md) — CSS tokens, dark mode, themes
|
|
134
|
+
- [CSS presets](./docs/domains/css-presets.md) — ratio-locked frame (letterbox), safe-area, scrollbar
|
|
133
135
|
- [Actions](./docs/domains/actions.md) — 15 Svelte directives
|
|
134
136
|
- [Attachments](./docs/domains/attachments.md) — `{@attach}` DOM helpers (preferred for new ones)
|
|
135
137
|
- [Utils](./docs/domains/utils.md) — 44 utility modules
|
|
@@ -138,6 +140,7 @@ Global tokens that control cross-component visual properties. Defined in `src/li
|
|
|
138
140
|
|
|
139
141
|
- [Design Tokens Manual](./docs/DESIGN_TOKENS_MANUAL.md) — Token philosophy
|
|
140
142
|
- [Tailwind v4 Variables](./docs/TAILWIND_V4_CSS_VARIABLES.md) — CSS variable reference
|
|
143
|
+
- [Ratio-Locked Frame](./docs/RATIO_LOCKED_FRAME.md) — Letterbox recipes + the measured gotcha list
|
|
141
144
|
|
|
142
145
|
---
|
|
143
146
|
|
package/API.md
CHANGED
|
@@ -2193,6 +2193,7 @@ Each component defines customization tokens. Override globally in `:root {}` or
|
|
|
2193
2193
|
| 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` |
|
|
2194
2194
|
| LoginForm | `--stuic-login-form-*` | `gap`, `gap-row`, `forgot-margin-y`, `forgot-margin-x`, `social-margin-top`, `social-gap`, `social-divider-color`, `social-divider-font-size`, `social-divider-margin-bottom` |
|
|
2195
2195
|
| Checkout | `--stuic-checkout-*` | `input-border`, `input-bg`, `input-focus-ring`, `input-radius`, `card-border`, `card-bg`, `card-radius`, `step-gap`, `progress-*`, `summary-*`, `guest-*`, `login-*`, `address-*`, `delivery-*`, `review-*`, `confirmation-*` |
|
|
2196
|
+
| Frame (CSS preset) | `--stuic-frame-*` | `aspect-ratio`, `width`, `height` — the ratio-locked frame / letterbox preset (see [CSS presets](docs/domains/css-presets.md)) |
|
|
2196
2197
|
|
|
2197
2198
|
### CSS Variable Naming Convention
|
|
2198
2199
|
|
package/README.md
CHANGED
|
@@ -248,6 +248,78 @@ All three are no-ops in a browser tab and need no prop.
|
|
|
248
248
|
|
|
249
249
|
**Not covered:** remaining fixed/edge-anchored components (e.g. `Float`, or a bare `ModalDialog` used directly) do not auto-handle insets — apply a `stuic-safe-area-*` class or the variables to their content as needed.
|
|
250
250
|
|
|
251
|
+
## Ratio-locked frame (letterbox)
|
|
252
|
+
|
|
253
|
+
Lock a box to an aspect ratio, size it to whichever axis binds first, centre it, and let the leftover space become letterboxing — a phone-proportioned column on a desktop, a portrait game board, a 16:9 scene nested under a header. The whole idea is one line:
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
width = min(available-width, available-height × ratio) /* aspect-ratio supplies the height */
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
That formula (plus two guards nobody remembers) is all stuic ships, because it is the only part Tailwind cannot express. The letterbox parent itself is plain utilities: `grid`, `overflow-hidden`, `fixed inset-0`, `bg-*`, and — where you need them — `contain-layout contain-paint`, `overflow-y-auto`, `@container-size`.
|
|
260
|
+
|
|
261
|
+
**Classes:**
|
|
262
|
+
|
|
263
|
+
- `.stuic-frame` — the ratio-locked box. Sized against the viewport (`100vw` / `100dvh`), centred with `margin: auto`, `overflow: hidden`.
|
|
264
|
+
- `.stuic-frame-cq` — the same box sized in container-query units, for a frame nested inside a layout rather than anchored to the window. Combine with `.stuic-frame`. **Requires** an ancestor with `container-type: size` (Tailwind `@container-size`); `inline-size` is not enough — `cqh` then falls through to the next container, or silently to the viewport, and you get a ratio-correct but wrongly-scaled frame that tracks the window as you resize.
|
|
265
|
+
- `.stuic-frame-col` — re-align a viewport-space element (a top-layer `<dialog>`, or an overlay portalled to `<body>`) onto the frame's column.
|
|
266
|
+
|
|
267
|
+
**Tokens** — all three are your inputs. stuic declares none of them anywhere; the defaults below live only as `var()` fallbacks at the usage sites, so a scoped override on the frame element or on any ancestor works:
|
|
268
|
+
|
|
269
|
+
| Token | Default | Meaning |
|
|
270
|
+
| ---------------------------- | ----------------------------------- | ---------------------------------------------------------------- |
|
|
271
|
+
| `--stuic-frame-aspect-ratio` | `1` | width ÷ height — anything `aspect-ratio:` accepts |
|
|
272
|
+
| `--stuic-frame-width` | `min(100vw, 100dvh × aspect-ratio)` | wholesale width override (bail-out value: `100vw`) |
|
|
273
|
+
| `--stuic-frame-height` | `auto` (⇒ ratio-locked) | wholesale height override (`100dvh` ⇒ fill height, derive width) |
|
|
274
|
+
|
|
275
|
+
**Viewport letterbox** — full screen, bars on exactly one axis:
|
|
276
|
+
|
|
277
|
+
```svelte
|
|
278
|
+
<div class="fixed inset-0 grid overflow-hidden bg-neutral-800">
|
|
279
|
+
<div
|
|
280
|
+
class="stuic-frame bg-[var(--stuic-color-surface)]"
|
|
281
|
+
style="--stuic-frame-aspect-ratio: 0.5"
|
|
282
|
+
>
|
|
283
|
+
…
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**Nested frame** — sized against its parent box instead of the window:
|
|
289
|
+
|
|
290
|
+
```svelte
|
|
291
|
+
<div class="flex h-dvh flex-col">
|
|
292
|
+
<header>…</header>
|
|
293
|
+
<div class="@container-size grid min-h-0 grow overflow-hidden bg-neutral-800">
|
|
294
|
+
<div
|
|
295
|
+
class="stuic-frame stuic-frame-cq bg-[var(--stuic-color-surface)]"
|
|
296
|
+
style="--stuic-frame-aspect-ratio: calc(16 / 9)"
|
|
297
|
+
>
|
|
298
|
+
…
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
> ⚠️ **Don't reach for `max-width: 100%; max-height: 100%; aspect-ratio: R`** — the formulation everyone tries first. `max-*` never _grows_ a box, so in a centred grid/flex parent an empty frame measures **0×0**, and one with content shrink-wraps that content and overflows the parent. The ratio usually survives; the size is what's wrong.
|
|
305
|
+
|
|
306
|
+
> ⚠️ **Never make the frame both the fixed containing block and the scroll container.** `contain-layout contain-paint` together with `overflow-y-auto` on the same element demotes every `position: fixed` descendant to `absolute` against the scroll origin: at `scrollTop: 600` a `Drawer` and its backdrop render at `y = -600`, so the user taps and nothing appears — and `BodyScroll` cannot rescue it, because `document.body` has nothing to scroll in that layout. Scroll an inner element instead, or keep overlays in viewport space and reconcile them with `.stuic-frame-col`.
|
|
307
|
+
|
|
308
|
+
> ⚠️ **`--stuic-frame-height: 100dvh` is not ratio-locking.** An explicit height beats `aspect-ratio` unconditionally (which is exactly why the bail-out below needs no `!important`), so whenever `100vw < 100dvh × ratio` the frame degenerates to the raw viewport with zero bars on both axes. It looks perfect on a wide desktop and is wrong on the handset you were aiming at. Set it only inside a deliberate bail-out query, and gate that query on more than width:
|
|
309
|
+
|
|
310
|
+
```css
|
|
311
|
+
@media (max-width: 40rem) and (max-aspect-ratio: 3 / 5) {
|
|
312
|
+
:root {
|
|
313
|
+
--stuic-frame-width: 100vw;
|
|
314
|
+
--stuic-frame-height: 100dvh;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
See [CSS presets](docs/domains/css-presets.md) for the classes, the token contract and the decision tree, and [Ratio-Locked Frame](docs/RATIO_LOCKED_FRAME.md) for the full recipe set (including the unit-free `max-*` variant that _does_ work, given a positioned parent) and the measured gotcha list.
|
|
320
|
+
|
|
321
|
+
> **This preset does not make stuic's own overlays frame-aware.** `Backdrop`, `Modal`, `Drawer` and `Notifications` measure in viewport units — and top-layer geometry (`showModal()`, `popover`) ignores the frame outright, even for a DOM descendant of it. The portalled actions (`popover`, `spotlight`, `dimBehind`) additionally default to `document.body`, so they leave the frame entirely. Either way they fill the window, not the frame. Pass their `container` option where one exists, apply `.stuic-frame-col`, or tweak the affected call sites.
|
|
322
|
+
|
|
251
323
|
## TypeScript
|
|
252
324
|
|
|
253
325
|
All components export their Props types:
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
RATIO-LOCKED FRAME (letterbox)
|
|
3
|
+
|
|
4
|
+
Lock a box to an aspect ratio, size it to whichever axis binds first, centre
|
|
5
|
+
it, and let the leftover space become letterboxing.
|
|
6
|
+
|
|
7
|
+
See docs/domains/css-presets.md for the classes, the token contract and the
|
|
8
|
+
decision tree, and docs/RATIO_LOCKED_FRAME.md for the recipes and the measured
|
|
9
|
+
gotcha list — the CSS here is nine declarations; the knowledge is the deliverable.
|
|
10
|
+
|
|
11
|
+
LAYERED (unlike the `.scrollbar-thin` / `.stuic-safe-area-*` utilities at the
|
|
12
|
+
end of index.css) because these are opt-in layout presets a consumer puts on
|
|
13
|
+
their OWN element and WILL tweak: Tailwind emits
|
|
14
|
+
`@layer theme, base, components, utilities`, so a utility always wins —
|
|
15
|
+
`class="stuic-frame h-dvh overflow-y-auto bg-white"` overrides everything
|
|
16
|
+
below. That is the escape hatch, by design.
|
|
17
|
+
|
|
18
|
+
This preset deliberately ships NO background, NO containment, NO scroll
|
|
19
|
+
container and NO letterbox-bars class: `grid`, `overflow-hidden`,
|
|
20
|
+
`fixed inset-0`, `bg-*`, `contain-layout`, `contain-paint`, `@container-size`
|
|
21
|
+
and `overflow-y-auto` are all Tailwind v4 utilities (stuic already requires
|
|
22
|
+
Tailwind v4). Only the sizing formula is ours, because it is the only part
|
|
23
|
+
Tailwind cannot express.
|
|
24
|
+
============================================================================ */
|
|
25
|
+
|
|
26
|
+
@layer components {
|
|
27
|
+
/* The ratio-locked box. Centres itself in a block, grid or flex parent
|
|
28
|
+
(`margin: auto` centres both axes in grid/flex; in normal flow the block-
|
|
29
|
+
axis autos compute to 0 and it behaves as `margin-inline: auto`).
|
|
30
|
+
|
|
31
|
+
One parent shape is NOT safe: a flex COLUMN. The ratio-derived height becomes
|
|
32
|
+
the flex base size and `min-height: 0` below removes the floor that would stop
|
|
33
|
+
it shrinking, so the frame silently goes off-ratio (measured 400x740, r=0.5405,
|
|
34
|
+
where a grid parent gives 400x800, r=0.5). Add `shrink-0`. See G22.
|
|
35
|
+
|
|
36
|
+
Why min() and not `max-width:100%; max-height:100%; aspect-ratio:R` — the
|
|
37
|
+
formulation everyone tries first: `max-*` never GROWS a box, so in a
|
|
38
|
+
centred grid/flex parent an empty frame measures 0x0, and one with content
|
|
39
|
+
shrink-wraps that content and overflows the parent. The ratio usually
|
|
40
|
+
survives; the SIZE is what's wrong. (There is a working `max-*` variant —
|
|
41
|
+
it needs a positioned parent — see Recipe D in the docs.)
|
|
42
|
+
|
|
43
|
+
`aspect-ratio` supplies the height, so the frame is ratio-locked at every
|
|
44
|
+
viewport, with bars on exactly one axis. Deriving the width from the
|
|
45
|
+
height and then letting `aspect-ratio` derive the height back is not
|
|
46
|
+
circular: `width` is resolved first, `aspect-ratio` only ever fills an
|
|
47
|
+
`auto` axis.
|
|
48
|
+
|
|
49
|
+
`min-height: 0` and `overflow: hidden` are load-bearing for the ratio, not
|
|
50
|
+
cosmetics: a grid/flex item's automatic minimum size overrides
|
|
51
|
+
`aspect-ratio` outright, so a tall child stretches an otherwise correct
|
|
52
|
+
frame off-ratio. Either one alone fixes it; both are set so a consumer's
|
|
53
|
+
`overflow-visible` stays survivable. */
|
|
54
|
+
.stuic-frame {
|
|
55
|
+
width: var(
|
|
56
|
+
--stuic-frame-width,
|
|
57
|
+
min(100vw, calc(100dvh * (var(--stuic-frame-aspect-ratio, 1))))
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/* `auto` = ratio-locked (the default). Any explicit length here WINS over
|
|
61
|
+
`aspect-ratio` unconditionally — that is the supported way to opt into
|
|
62
|
+
"fill the height, derive the width" inside a bail-out media query, with
|
|
63
|
+
no `!important` and no specificity game. */
|
|
64
|
+
height: var(--stuic-frame-height, auto);
|
|
65
|
+
|
|
66
|
+
aspect-ratio: var(--stuic-frame-aspect-ratio, 1);
|
|
67
|
+
min-height: 0;
|
|
68
|
+
margin: auto;
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Opt-in: size against the nearest ANCESTOR query container instead of the
|
|
73
|
+
viewport — the nested case (a frame under a header, inside a flex column).
|
|
74
|
+
Combine with `.stuic-frame`; this rule must stay AFTER it in source order,
|
|
75
|
+
since both declare `width` at equal specificity.
|
|
76
|
+
|
|
77
|
+
REQUIRES an ancestor with `container-type: size` (Tailwind:
|
|
78
|
+
`@container-size`). `inline-size` is NOT enough: `cqh` then falls THROUGH
|
|
79
|
+
to the next container, or silently to the small viewport, and you get a
|
|
80
|
+
ratio-correct but wrongly-scaled frame that overflows its parent and
|
|
81
|
+
tracks the window as you resize. Same failure with no container ancestor
|
|
82
|
+
at all. Size containment is safe here precisely because this frame's own
|
|
83
|
+
height is always determined. */
|
|
84
|
+
.stuic-frame-cq {
|
|
85
|
+
width: var(
|
|
86
|
+
--stuic-frame-width,
|
|
87
|
+
min(100cqw, calc(100cqh * (var(--stuic-frame-aspect-ratio, 1))))
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Re-align a VIEWPORT-space element onto the frame's column: a top-layer
|
|
92
|
+
`<dialog>`, or an overlay portalled to `<body>` (stuic's popover /
|
|
93
|
+
spotlight / dimBehind default to `document.body` — pass their `container`
|
|
94
|
+
option instead where you can).
|
|
95
|
+
|
|
96
|
+
The whole fallback expression is repeated on purpose. Nothing declares
|
|
97
|
+
`--stuic-frame-width`, so a bare `var(--stuic-frame-width)` would be
|
|
98
|
+
invalid-at-computed-value-time -> `width: auto` -> silently full-bleed. */
|
|
99
|
+
.stuic-frame-col {
|
|
100
|
+
width: min(
|
|
101
|
+
100%,
|
|
102
|
+
var(
|
|
103
|
+
--stuic-frame-width,
|
|
104
|
+
min(100vw, calc(100dvh * (var(--stuic-frame-aspect-ratio, 1))))
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
margin-inline: auto;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/dist/index.css
CHANGED
|
@@ -118,6 +118,9 @@ In practice:
|
|
|
118
118
|
@import "./actions/spotlight/index.css";
|
|
119
119
|
@import "./actions/tooltip/index.css";
|
|
120
120
|
|
|
121
|
+
/* Layout preset CSS (classes only, no component) */
|
|
122
|
+
@import "./css/frame.css";
|
|
123
|
+
|
|
121
124
|
/* Base styles for STUIC components */
|
|
122
125
|
@layer base {
|
|
123
126
|
button:not(:disabled),
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
# Ratio-Locked Frame — Reference
|
|
2
|
+
|
|
3
|
+
Recipes, the measured gotcha list, and stuic overlay interop for the `.stuic-frame` CSS preset
|
|
4
|
+
(`src/lib/css/frame.css`).
|
|
5
|
+
|
|
6
|
+
**Start with [CSS Presets](./domains/css-presets.md)** — it has the classes, the token contract and
|
|
7
|
+
the decision tree. This document is the long-form companion: it is what you read when you are about
|
|
8
|
+
to write the CSS, or when something looks right and measures wrong.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Recipes
|
|
13
|
+
|
|
14
|
+
### Recipe A — viewport letterbox (bars painted by a wrapper)
|
|
15
|
+
|
|
16
|
+
The full-screen shape: a fixed wrapper paints the bars, the frame sits centred in it.
|
|
17
|
+
|
|
18
|
+
```svelte
|
|
19
|
+
<div class="fixed inset-0 grid overflow-hidden bg-neutral-800">
|
|
20
|
+
<div
|
|
21
|
+
class="stuic-frame bg-white dark:bg-neutral-900"
|
|
22
|
+
style="--stuic-frame-aspect-ratio: 0.5"
|
|
23
|
+
>
|
|
24
|
+
<!-- app -->
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`place-items: center` is unnecessary — `.stuic-frame`'s own `margin: auto` centres it on both axes
|
|
30
|
+
in a grid parent.
|
|
31
|
+
|
|
32
|
+
Bail out to full bleed on a real handset (**note the second condition — G6**):
|
|
33
|
+
|
|
34
|
+
```css
|
|
35
|
+
@media (max-width: 40rem) and (max-aspect-ratio: 3 / 5) {
|
|
36
|
+
:root {
|
|
37
|
+
--stuic-frame-width: 100vw;
|
|
38
|
+
--stuic-frame-height: 100dvh;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If the frame needs to scroll, add `overflow-y-auto` to it — **and then read G4b before adding
|
|
44
|
+
`contain-layout contain-paint`.**
|
|
45
|
+
|
|
46
|
+
### Recipe B — no wrapper, `body` is the letterbox
|
|
47
|
+
|
|
48
|
+
For a phone-proportioned column app clamped on tablet-and-up: there is exactly one element. `body`'s
|
|
49
|
+
background _is_ the letterbox and `.stuic-frame`'s `margin: auto` does the centring (in normal flow
|
|
50
|
+
the block-axis autos compute to `0`, so it behaves as `margin-inline: auto`).
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<body>
|
|
54
|
+
<div class="stuic-frame bg-[var(--stuic-color-background)]">…</div>
|
|
55
|
+
</body>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```css
|
|
59
|
+
:root {
|
|
60
|
+
--stuic-frame-aspect-ratio: 0.48;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
body {
|
|
64
|
+
background: var(--stuic-color-surface-1); /* the letterbox */
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* Bail out to full bleed on a real handset (note the second condition — G6) */
|
|
68
|
+
@media (max-width: 40rem) and (max-aspect-ratio: 3 / 5) {
|
|
69
|
+
:root {
|
|
70
|
+
--stuic-frame-width: 100vw;
|
|
71
|
+
--stuic-frame-height: 100dvh;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Two things to decide consciously here.**
|
|
77
|
+
|
|
78
|
+
**A token cannot clear `aspect-ratio`.** The bail-out above works because
|
|
79
|
+
`--stuic-frame-height: 100dvh` beats `aspect-ratio` (**G14**). If instead you want the _content_ to
|
|
80
|
+
determine the height on a phone, you need the `aspect-auto` utility in the markup — and markup
|
|
81
|
+
cannot be media-queried.
|
|
82
|
+
|
|
83
|
+
**So if the frame only exists above a breakpoint, take the token and keep your own class.** That
|
|
84
|
+
is a legitimate, supported use of this preset — the value is the named formula, not the class:
|
|
85
|
+
|
|
86
|
+
```css
|
|
87
|
+
@media (min-width: 700px) and (min-height: 600px) {
|
|
88
|
+
body {
|
|
89
|
+
background: var(--stuic-color-surface-1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.app-frame {
|
|
93
|
+
width: var(
|
|
94
|
+
--stuic-frame-width,
|
|
95
|
+
min(100vw, calc(100dvh * (var(--stuic-frame-aspect-ratio))))
|
|
96
|
+
);
|
|
97
|
+
height: 100dvh;
|
|
98
|
+
margin-inline: auto;
|
|
99
|
+
overflow-y: auto;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Applying `.stuic-frame` _unconditionally_ in a phone-first app, together with `overflow-y-auto` on
|
|
105
|
+
the frame, makes the frame the scroll container on phones too. iOS then stops collapsing the address
|
|
106
|
+
bar, and pull-to-refresh semantics change — on the one path that matters most. That is usually the
|
|
107
|
+
reason to keep the class conditional.
|
|
108
|
+
|
|
109
|
+
### Recipe C — nested under a header, container units
|
|
110
|
+
|
|
111
|
+
For a 16:9 scene inside a flex column below a header. The frame must fit whatever box it is handed,
|
|
112
|
+
not the screen.
|
|
113
|
+
|
|
114
|
+
```svelte
|
|
115
|
+
<header class="shrink-0">…</header>
|
|
116
|
+
|
|
117
|
+
<!-- the letterbox: a size container, so cqh resolves -->
|
|
118
|
+
<div class="@container-size grid min-h-0 grow overflow-hidden bg-neutral-900">
|
|
119
|
+
<!-- the frame: sized in cq units; declares its own size container so
|
|
120
|
+
descendants scale with the FRAME, not the letterbox -->
|
|
121
|
+
<div
|
|
122
|
+
class="stuic-frame stuic-frame-cq @container-size relative bg-[var(--stuic-color-surface)]"
|
|
123
|
+
style="--stuic-frame-aspect-ratio: calc(16 / 9)"
|
|
124
|
+
>
|
|
125
|
+
<div class="text-[clamp(0.75rem,1.25cqw,1.0625rem)]">…</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**`@container-size` on the ancestor is mandatory, and `@container` is not enough.** With only
|
|
131
|
+
`container-type: inline-size`, `cqh` falls **through** to the next container — or silently to the
|
|
132
|
+
small viewport — and you get a ratio-correct, wrongly-scaled frame that overflows its parent and
|
|
133
|
+
tracks the window as you resize. See **G5** for the numbers.
|
|
134
|
+
|
|
135
|
+
Also note `min-h-0` on the letterbox: without it, a flex item's automatic minimum size can push the
|
|
136
|
+
whole thing off (**G13**, one level up).
|
|
137
|
+
|
|
138
|
+
### Recipe D — the unit-free `max-*` variant (needs a positioned parent)
|
|
139
|
+
|
|
140
|
+
There **is** a working `max-width`/`max-height` formulation, contrary to widespread belief. It needs
|
|
141
|
+
`position: absolute; inset: 0; margin: auto` inside a positioned parent with a definite size:
|
|
142
|
+
|
|
143
|
+
```html
|
|
144
|
+
<div class="relative h-dvh w-full overflow-hidden bg-neutral-800">
|
|
145
|
+
<div
|
|
146
|
+
class="absolute inset-0 m-auto max-h-full max-w-full bg-white"
|
|
147
|
+
style="aspect-ratio: 0.5"
|
|
148
|
+
>
|
|
149
|
+
…
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Measured ratio-perfect and correctly centred in 4/4 parent × ratio combinations in both engines
|
|
155
|
+
(bars 287.5 / 300 / 44.4px). It is unit-free, which is genuinely attractive — no `vw`, no `dvh`, no
|
|
156
|
+
`cq`, no container declaration.
|
|
157
|
+
|
|
158
|
+
**Leave both axes `auto`.** The whole thing works because an absolutely-positioned box with
|
|
159
|
+
`inset: 0`, no stated axis and a non-`auto` `aspect-ratio` is sized like a replaced element — fit
|
|
160
|
+
inside the containing block, ratio preserved — and `margin: auto` then centres the leftover.
|
|
161
|
+
Adding `width: 100%` (or `w-full`) states an axis and destroys it: that is one of the only two
|
|
162
|
+
measured **engine divergences** in this whole study — Chromium 800×400 vs WebKit 200×400 for the
|
|
163
|
+
same markup (**G1**).
|
|
164
|
+
|
|
165
|
+
Its cost: it needs a positioned parent with a definite size, so it cannot express the one-element,
|
|
166
|
+
normal-flow shape of Recipe B, and it is the _only_ parent context where the max-pair works (**G1**).
|
|
167
|
+
`.stuic-frame` is not this shape because it has to work in normal flow, grid, flex, absolute and
|
|
168
|
+
container-unit parents. Use Recipe D when you already have the wrapper anyway.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Gotchas
|
|
173
|
+
|
|
174
|
+
**Engine provenance, stated once.** Every number below was measured with Playwright on
|
|
175
|
+
**Chromium 151.0.7922.34** and **WebKit 26.5**, plus **Firefox 153** for the containment rows.
|
|
176
|
+
Results are identical across engines unless a row says otherwise. This says **nothing about old
|
|
177
|
+
Safari** — see G7. Four items (G6, G7, G8, G20) are explicitly _not_ browser measurements and are
|
|
178
|
+
labelled as such.
|
|
179
|
+
|
|
180
|
+
> **The one that will actually bite you — frame as containing block AND scroll container — is the
|
|
181
|
+
> callout at the top of [CSS Presets](./domains/css-presets.md#gotchas), referenced below as G4b.**
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
**G1 — `max-width: 100%; max-height: 100%; aspect-ratio: R` — the popular formulation. The usual
|
|
186
|
+
explanation of why it fails is wrong.**
|
|
187
|
+
The ratio is _not_ what gives. **`max-*` never grows a box**, so with `width: auto` the frame
|
|
188
|
+
shrink-wraps its content instead of filling the letterbox. Measured in a
|
|
189
|
+
`display: grid; place-items: center` parent: **0×0** with an empty child (both engines, all 4
|
|
190
|
+
parent/ratio combos), and **280.13×560.25 overflowing an 800×400 parent** with one line of text —
|
|
191
|
+
ratio correct, size wrong. Flex behaves differently from grid: flex _does_ clamp (200×400 for the
|
|
192
|
+
same markup; 800×400 once `width: 100%` is stated); grid does not, because `max-height: 100%` cannot
|
|
193
|
+
resolve against an auto row.
|
|
194
|
+
The rule that fits all 450 measured rows: **the axis you state explicitly wins, `aspect-ratio`
|
|
195
|
+
derives the other, and `max-*` truncates the derived axis without shrinking the stated one back.**
|
|
196
|
+
So `max-pair + width: 100%` is ratio-correct exactly when the letterboxing happens to fall on the
|
|
197
|
+
axis you did not state — half the time. The only two real engine divergences in the whole study live
|
|
198
|
+
here: block-level max-pair (Chromium 200×400 vs **WebKit 800×400**) and absolutely-positioned
|
|
199
|
+
`max-pair + width: 100%` (Chromium 800×400 vs WebKit 200×400).
|
|
200
|
+
There **is** a working `max-*` variant — see **Recipe D**. The `min()` formula in `.stuic-frame` was
|
|
201
|
+
16/16 ratio-correct, then 12/12, then 6/6, in both engines, with zero overflow and zero degenerate
|
|
202
|
+
cases.
|
|
203
|
+
|
|
204
|
+
**G2 — `container-type` does NOT create a fixed containing block. Readers arrive believing the
|
|
205
|
+
opposite.**
|
|
206
|
+
Neither `container-type: inline-size` nor `container-type: size` makes an element a containing block
|
|
207
|
+
for `position: fixed` (or `position: absolute`) descendants. Measured `0,0 1200x800` — untrapped —
|
|
208
|
+
on Chromium 149, Chromium 151, WebKit 26.5 and Firefox 153, at 1200×800 / 400×900 / 900×400.
|
|
209
|
+
Computed `contain` reads back `"none"`. The CSSWG removed layout containment from `container-type`
|
|
210
|
+
in 2024 (csswg-drafts#10544) and every engine shipped the change.
|
|
211
|
+
`src/lib/utils/containing-block.ts` already documents this and deliberately does not check
|
|
212
|
+
`container-type`; `src/lib/css/frame.svelte.test.ts` locks it.
|
|
213
|
+
**What _does_ trap, measured:** `contain: layout` alone is sufficient; `contain: paint` alone is
|
|
214
|
+
also sufficient; so are `content`, `strict`, `transform`, `perspective`,
|
|
215
|
+
`will-change: transform`, `backdrop-filter`, `content-visibility: auto`. `contain: size`,
|
|
216
|
+
`contain: style`, `overflow: hidden` and `container-type: *` do **not**. Containment and container
|
|
217
|
+
queries are orthogonal knobs — you opt into trapping explicitly.
|
|
218
|
+
Version caveat that _does_ matter: `filter: blur(0px)` **does** form a fixed containing block in
|
|
219
|
+
WebKit 26.5. `containing-block.ts` deliberately ignores `filter` on WebKit because older Safari did
|
|
220
|
+
not, so quote engine versions whenever you rely on this.
|
|
221
|
+
|
|
222
|
+
**G3 — the top layer escapes geometry, but NOT inheritance, and NOT container units.**
|
|
223
|
+
Geometry: confirmed and total. A `<dialog>.showModal()` or `[popover]` that is a DOM child of the
|
|
224
|
+
frame measures `0,0 1200x800` regardless of `contain: layout paint`, `contain: strict`,
|
|
225
|
+
`container-type: size`, `transform`, `filter`, `overflow: hidden`, or page scroll. Hit-testing
|
|
226
|
+
agrees. With the UA `max-width` left alone it measured `1162x762` at a 1200×800 viewport
|
|
227
|
+
(`calc(100% - 6px - 2em)` against the **viewport**).
|
|
228
|
+
**But custom properties inherit into the top layer normally.** Measured: a dialog that is a DOM child
|
|
229
|
+
of the frame reads `--fw: "137px"` and sizes to 137px; the same dialog moved to `<body>` reads `""`
|
|
230
|
+
and falls back. So the reason to hoist a var to `:root` is **portalling**, not the top layer.
|
|
231
|
+
**And `cq` units in the top layer are not lost either.** `50cqw`/`50cqh` inside an open modal dialog
|
|
232
|
+
that is a DOM descendant of a `container-type: size` frame measured **150×100** — resolved against
|
|
233
|
+
the frame. Only after the dialog is moved to `document.body` does it fall back to the viewport
|
|
234
|
+
(600×400).
|
|
235
|
+
|
|
236
|
+
**G4a — `contain: paint` clips, and without it the overflow escapes onto the bars.**
|
|
237
|
+
`paint`, `layout paint`, `content`, `strict` and plain `overflow: hidden` all clip and make the
|
|
238
|
+
overflow unhittable; `contain: layout` alone does not clip. The sharper version of this gotcha:
|
|
239
|
+
_without_ clipping, overflow doesn't disappear — it **escapes the letterbox and paints on the bars**,
|
|
240
|
+
which is arguably worse. Measured with `contain: layout` alone: content at `y = 620` outside a frame
|
|
241
|
+
that ends at `y = 260`, `scrollTop` pinned at 0, and **hittable** — it paints on the bars. Add
|
|
242
|
+
`paint` (or `overflow: hidden`) and the same content becomes unreachable (`elementFromPoint` →
|
|
243
|
+
`HTML`).
|
|
244
|
+
Also measured, so nobody re-derives it: `contain: layout paint` does **not** block scroll
|
|
245
|
+
reachability when the frame _is_ a scroll container — `scrollTop` reaches 400/400 and the bottom
|
|
246
|
+
element is hittable. The problem with that combination is G4b, not scrolling.
|
|
247
|
+
|
|
248
|
+
**G4b — see the callout above.** Frame as fixed containing block + frame as scroll container =
|
|
249
|
+
every `position: fixed` descendant at `y = -scrollTop`.
|
|
250
|
+
|
|
251
|
+
**G5 — `cq` units DO resolve on the element that declares `container-type`. They just resolve
|
|
252
|
+
against something else — silently.**
|
|
253
|
+
Readers commonly believe the single-element version "doesn't resolve". It resolves fine. An
|
|
254
|
+
element's own `container-type` never applies to itself, so `cq` units on it query the nearest
|
|
255
|
+
**ancestor** query container — and **fall back to the small viewport when there is none**, with a
|
|
256
|
+
plausible number and no warning.
|
|
257
|
+
Measured: `#frame { container-type: size; width: 50cqw; height: 25cqh }` with no ancestor container
|
|
258
|
+
→ **600×200** at 1200×800, **200×225** at 400×900, **450×100** at 900×400 — i.e. it tracks the
|
|
259
|
+
_window_. With an ancestor `container-type: size` sized 500×300 → **250×75 at every viewport**.
|
|
260
|
+
The nested shape (frame consumes the parent's `cq` units _and_ declares its own `container-type`)
|
|
261
|
+
measured **150×300, identical with or without the self-declaration**. The two-element split is
|
|
262
|
+
required **for the descendants**, not because the frame's own `min()` breaks.
|
|
263
|
+
**Axis eligibility, with a number:** ancestor `size` 500×300, frame `inline-size` 250 wide,
|
|
264
|
+
descendant `50cqw`/`50cqh` → **125×150**. `cqw` came from the frame; `cqh` fell _through_ it to the
|
|
265
|
+
ancestor. `container-type: inline-size` is safe only if nothing inside ever uses
|
|
266
|
+
`cqh`/`cqb`/`cqmin`/`cqmax`. **Use `size` (`@container-size`), not `inline-size`.**
|
|
267
|
+
|
|
268
|
+
**G6 — a width-only bail-out breakpoint is usually wrong. (Design guidance, NOT a browser
|
|
269
|
+
measurement.)**
|
|
270
|
+
A ratio-derived width collapses on a landscape phone, and a short-but-narrow desktop window is not a
|
|
271
|
+
handset. Both known viewport call sites needed a second condition — a `min-height: 600px`, or a
|
|
272
|
+
`max-aspect-ratio: 3 / 5`. This is a content fact about your layout, not something a probe can
|
|
273
|
+
measure.
|
|
274
|
+
|
|
275
|
+
**G7 — prefer classic `max-width:` / `max-aspect-ratio:` over media-query range syntax. (NOT
|
|
276
|
+
measured here.)**
|
|
277
|
+
Media-query range syntax (`(width <= 40rem)`) landed in Safari 16.4. On an older engine the query
|
|
278
|
+
fails to parse and is dropped **whole**, silently restoring the desktop layout on a phone. The
|
|
279
|
+
WebKit build used for this study was 26.5, so no pre-16.4 datapoint exists. Treat this as a
|
|
280
|
+
deployment-target rule, not a measurement.
|
|
281
|
+
|
|
282
|
+
**G8 — `dvh` vs `vh`: a real trade-off, with no measured mobile-chrome magnitude.**
|
|
283
|
+
Headless Chromium and WebKit report `100vh == 100dvh == 100svh == 100lvh`, so the mobile browser
|
|
284
|
+
chrome behaviour **was not exercised**. What _was_ measured: `100dvh` is live — resizing
|
|
285
|
+
1200×800 → 1200×400 moved the frame width from 400px to 200px.
|
|
286
|
+
Mechanically, therefore: on a browser where `dvh` shrinks as chrome expands, a height-derived width
|
|
287
|
+
shrinks by the same factor, so the frame's **horizontal** edges move during a vertical scroll.
|
|
288
|
+
`svh` pins them, at the cost of being wrong in the other chrome state. `.stuic-frame` uses `dvh`;
|
|
289
|
+
swapping it is a one-token change (`--stuic-frame-width: min(100vw, calc(100svh * (…)))`). Do not
|
|
290
|
+
quote a Safari magnitude — nobody measured one.
|
|
291
|
+
|
|
292
|
+
**G9 — `--stuic-frame-aspect-ratio: 16 / 9` plus an unparenthesised division is 81× too small.**
|
|
293
|
+
`calc(100vw / var(--r))` becomes `calc(100vw / 16 / 9)` = viewport ÷ 144. Measured **8.33px instead
|
|
294
|
+
of 675px** at 1200×800, **2.77px instead of 225px** at 400×900, and **8.33 / 2.77 / 6.25 instead of
|
|
295
|
+
675 / 225 / 506.25** in container-unit space. No parse error, no console warning.
|
|
296
|
+
Multiplication is coincidentally correct (`calc(100dvh * 16 / 9)` = 1422.22 = the right answer),
|
|
297
|
+
which is exactly what teaches the wrong habit. **Parenthesise every `var()` in every `calc()`, for
|
|
298
|
+
both operators.** `frame.css` does; your code must too. If the ratio arrives from config or YAML,
|
|
299
|
+
wrap it at the injection site: `style="--stuic-frame-aspect-ratio: calc({aspect})"` is valid for
|
|
300
|
+
both `"16 / 9"` and `"0.48"`.
|
|
301
|
+
|
|
302
|
+
**G10 — `@property`-registering the ratio converts a loud failure into a quiet one.**
|
|
303
|
+
`syntax: "<number>"` rejects `16 / 9` outright (both engines) and substitutes the initial/inherited
|
|
304
|
+
value — indistinguishable from a typo, with the page rendering plausibly at the library's default
|
|
305
|
+
ratio. It also collapses `calc(16 / 9)` to a rounded number at computed-value time (675 → 674.98).
|
|
306
|
+
stuic does not register it, deliberately.
|
|
307
|
+
|
|
308
|
+
**G11 — deriving the width at `:root` makes scoped ratio overrides a silent no-op.**
|
|
309
|
+
With `:root { --w: min(100vw, calc(100dvh * var(--r))) }`, `getComputedStyle(:root)['--w']` is
|
|
310
|
+
already the string `"min(100vw, calc(100dvh * 0.5))"` — the ratio is substituted **eagerly**, the
|
|
311
|
+
math is not evaluated, and descendants inherit that frozen string. Measured: overriding `--r` on the
|
|
312
|
+
frame element **or on any ancestor** → **400px, unchanged, no error**. Only a `:root` or
|
|
313
|
+
media-query override works.
|
|
314
|
+
This is the repo's documented anti-pattern (see the Fallback Pattern in
|
|
315
|
+
[conventions.md](./conventions.md)). Shipping **both** a `:root` declaration _and_ a usage-site
|
|
316
|
+
fallback is the worst of the three options: the fallback becomes dead code and the scoped override
|
|
317
|
+
still does nothing.
|
|
318
|
+
|
|
319
|
+
**G12 — `var(--stuic-frame-width)` without the repeated fallback goes full-bleed.**
|
|
320
|
+
Because nothing declares the token, a bare read is invalid-at-computed-value-time → the whole
|
|
321
|
+
declaration falls back to `unset` → `width: auto`. Measured **1200px instead of 400px**.
|
|
322
|
+
Repeat the entire expression at every read site — `.stuic-frame-col` in `frame.css` does exactly
|
|
323
|
+
that, on purpose — or alias it once at `:root` using the recipe in the token-contract section above.
|
|
324
|
+
|
|
325
|
+
**G13 — a grid/flex item's automatic minimum size overrides `aspect-ratio`.**
|
|
326
|
+
A 3000px-tall child stretches an otherwise-correct frame to **350×3000, ratio 0.117**, in both
|
|
327
|
+
engines. `min-height: 0` fixes it; `overflow: hidden` fixes it; `.stuic-frame` sets **both**, so a
|
|
328
|
+
consumer's `overflow-visible` stays survivable. A future "cleanup" that deletes `min-height: 0`
|
|
329
|
+
reintroduces this — `frame.svelte.test.ts` locks it.
|
|
330
|
+
|
|
331
|
+
**G14 — any specified `height`, `min-height` or biting `max-height` beats `aspect-ratio` outright.**
|
|
332
|
+
Measured: `aspect-ratio: .5; width: 300px; height: 100px` → 300×100 (r = 3.0);
|
|
333
|
+
`aspect-ratio: .5; width: 300px; min-height: 900px` → 300×900 (r = 0.333);
|
|
334
|
+
`aspect-ratio: .5; width: 100%; height: 100cqh` in a 300×800 container → 300×800 (r = 0.375).
|
|
335
|
+
`aspect-ratio` only ever supplies an `auto` axis. This is _why_ `--stuic-frame-height: 100dvh` needs
|
|
336
|
+
no `!important` and no specificity trick.
|
|
337
|
+
|
|
338
|
+
**G15 — `height: 100dvh` is not ratio-locking.**
|
|
339
|
+
Whenever `100vw < 100dvh × R`, the frame degenerates to **exactly the viewport**, with bars of 0 on
|
|
340
|
+
both axes, taking the viewport's own ratio. That happened in **6 of 12** measured viewport × ratio
|
|
341
|
+
combinations — including **every ratio at 390×844** (390×844, r = 0.462, where 0.48 / 0.5 / 1.778
|
|
342
|
+
were asked for) and 16/9 at 1200×800, 1440×900 and 900×1400.
|
|
343
|
+
It looks perfect on a 1440×900 desktop and is wrong on the target handset. That is why
|
|
344
|
+
`.stuic-frame` defaults to `height: auto`, and why `--stuic-frame-height: 100dvh` is a deliberate,
|
|
345
|
+
documented opt-out rather than the default.
|
|
346
|
+
|
|
347
|
+
**G16 — `container-type: size` zeroes intrinsic block size.**
|
|
348
|
+
Measured **500×0** on an auto-height element carrying 150px of content — the same as `contain: size`
|
|
349
|
+
and `contain: strict`. `inline-size` gives the correct 500×150.
|
|
350
|
+
`@container-size` is safe on `.stuic-frame` **only because the frame's height is always determined**.
|
|
351
|
+
Slap `@container-size` on an auto-height box and you get a 0-tall element.
|
|
352
|
+
|
|
353
|
+
**G17 — `position: fixed` + `overflow: hidden` on the letterbox traps and clips nothing.**
|
|
354
|
+
Measured: `#lb { position: fixed; left: 100; top: 60; 300×200; overflow: hidden }` with a
|
|
355
|
+
`position: fixed; inset: 0` **child** → child at `0,0 1200x800`, and still hittable at (700, 500).
|
|
356
|
+
Being fixed-positioned and clipping does not make an element a containing block; only
|
|
357
|
+
`contain: paint` / `contain: layout` (or a transform) does.
|
|
358
|
+
Consequence: an overlay dropped **beside** the frame — a sibling of the frame, i.e. a child of the
|
|
359
|
+
letterbox — escapes to the viewport. Only descendants of a `contain:`-ed **frame** are captured.
|
|
360
|
+
|
|
361
|
+
**G18 — stuic's own components measure in viewport units that know nothing about your frame.**
|
|
362
|
+
`src/lib/components/Backdrop/index.css` is `position: fixed; inset: 0; height: 100dvh`. Inside a
|
|
363
|
+
frame shorter than the viewport, `inset` binds to the frame but the explicit `height` wins: measured
|
|
364
|
+
a 300px-tall contained host rendering an 800px-tall fixed child, overflowing by 500px.
|
|
365
|
+
Same family: `Modal.svelte` (`md:max-w-[calc(100vw-2rem)]`, `md:max-h-[80dvh]`), `Drawer.svelte`
|
|
366
|
+
(`sm:w-[75vw]`), `AlertConfirmPrompt` (`max-h-[62vh]`), `Float/index.css` (`max-width: 100vw`,
|
|
367
|
+
`--stuic-float-body-max-height: 70vh`), `popover.svelte.ts` and `spotlight.svelte.ts`
|
|
368
|
+
(`max-width: calc(100vw - 1rem)`).
|
|
369
|
+
**Budget for per-call-site tweaks.** "Toasts stay inside the frame with zero component changes" is
|
|
370
|
+
not true of stuic's `Notifications` — see the interop section.
|
|
371
|
+
|
|
372
|
+
**G19 — the overlay _actions_ still portal to `<body>` by default.**
|
|
373
|
+
`popover`, `spotlight` and `dimBehind` all default their container to `document.body`
|
|
374
|
+
(`popover` first checks for an enclosing `dialog[open]`). A popover anchored inside the frame is
|
|
375
|
+
therefore portalled _out_ of it, and `fixedContainingBlockRect()` then measures the viewport — the
|
|
376
|
+
3.152.0 containing-block awareness never fires, because by then there is no CB ancestor left.
|
|
377
|
+
Pass `container: frameEl`. See the interop section.
|
|
378
|
+
|
|
379
|
+
**G20 — print. (Not measured; stated as a known consequence.)**
|
|
380
|
+
`fixed inset-0` + `overflow: hidden` + `100dvh` prints one page and clips everything else. stuic
|
|
381
|
+
ships no `@media print` rules for the frame. If print matters, write your own query that unsets the
|
|
382
|
+
frame's sizing.
|
|
383
|
+
|
|
384
|
+
**G21 — safe-area insets are unavailable in exactly the deployment that needs them.**
|
|
385
|
+
`.stuic-safe-area-*` and the `--stuic-safe-area-*` variables are gated behind
|
|
386
|
+
`@media (display-mode: standalone), (display-mode: fullscreen)` and hard-zero in a Capacitor
|
|
387
|
+
WKWebView — i.e. unusable in the packaged-app case where a letterboxed layout most wants them.
|
|
388
|
+
Cross-reference, do not try to solve it inside the frame preset. See the safe-area section below.
|
|
389
|
+
|
|
390
|
+
**G22 — `.stuic-frame` silently loses its ratio as a flex-column item.**
|
|
391
|
+
Measured at 1200×800 in a `flex-col` shell with a 60px header: **400×740, ratio 0.5405**, where the
|
|
392
|
+
same frame in a grid parent measures **400×800, ratio 0.5000** — both engines. The ratio-derived
|
|
393
|
+
800px becomes the flex base size, and `min-height: 0` — the declaration **G13** calls load-bearing —
|
|
394
|
+
removes the automatic minimum that would otherwise stop the shrink.
|
|
395
|
+
Nothing stuic ships is affected: every recipe, README example and demo uses a grid, block or
|
|
396
|
+
absolutely-positioned parent. But `<div class="flex flex-col h-dvh"><header/><div class="stuic-frame">`
|
|
397
|
+
is a natural app shell, and it fails with no error and a plausible-looking box.
|
|
398
|
+
`flex-shrink: 0` on the frame restores 400×800 — in markup, add `shrink-0`.
|
|
399
|
+
|
|
400
|
+
**G23 — an _invalid_ `--stuic-frame-aspect-ratio` fails silently and full-bleed.**
|
|
401
|
+
Measured at 1200×800 with content: a valid `0.48` gives **384×800 @ x = 408**, while `16 9`, `red`,
|
|
402
|
+
`1px`, `50%` **and a declared-but-empty value** all give **1200×48 @ x = 0** in normal flow — a
|
|
403
|
+
declared-but-empty value does _not_ fall back to the `var()` default of 1. `width` and
|
|
404
|
+
`aspect-ratio` go invalid-at-computed-value-time together, so the result is indistinguishable from
|
|
405
|
+
"the stylesheet didn't load", with no console warning. In a centred grid parent the same failure
|
|
406
|
+
shrink-wraps instead: **267.73×48**.
|
|
407
|
+
Contrast `--stuic-frame-height: banana`, which degrades gracefully back to 400×800. This is distinct
|
|
408
|
+
from **G10**, which is about `@property` rejecting the `16 / 9` pair form.
|
|
409
|
+
|
|
410
|
+
**Also fine, stated so nobody re-derives it:** RTL is clean. `margin-inline` is a logical
|
|
411
|
+
property and `place-items` is writing-mode-relative; `inset` is the shorthand for the _physical_
|
|
412
|
+
`top`/`right`/`bottom`/`left` (the logical forms are `inset-block`/`inset-inline`) and does not
|
|
413
|
+
appear in `frame.css` at all. Nothing in `frame.css` is direction-sensitive.
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## Working with stuic's overlays
|
|
418
|
+
|
|
419
|
+
See the trapped / escaping / portalled table in
|
|
420
|
+
[CSS Presets](./domains/css-presets.md#interop-with-the-rest-of-stuic) first.
|
|
421
|
+
|
|
422
|
+
**Portalled actions — pass `container`.** `popover`, `spotlight` and `dimBehind` all accept
|
|
423
|
+
`container?: HTMLElement | (() => HTMLElement | null)`, resolved through
|
|
424
|
+
`resolveContainerOption()` in `src/lib/utils/overlay-container.ts`. A factory is useful when the
|
|
425
|
+
frame element does not exist yet at action-setup time; returning `null` means "use the default".
|
|
426
|
+
|
|
427
|
+
```svelte
|
|
428
|
+
<script lang="ts">
|
|
429
|
+
let frameEl = $state<HTMLElement>();
|
|
430
|
+
</script>
|
|
431
|
+
|
|
432
|
+
<div bind:this={frameEl} class="stuic-frame contain-layout contain-paint">
|
|
433
|
+
<button use:popover={() => ({ content: "…", container: () => frameEl ?? null })}>
|
|
434
|
+
…
|
|
435
|
+
</button>
|
|
436
|
+
</div>
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Note that `dimBehind` keeps **one ref-counted backdrop per container**, so mixing framed and
|
|
440
|
+
body-level call sites gives you two independent backdrops — usually what you want.
|
|
441
|
+
|
|
442
|
+
**Top-layer escapees — reconcile with `.stuic-frame-col`.** A modal `<dialog>` and the
|
|
443
|
+
`Notifications` popover are in viewport space no matter what you do to their ancestors. Put
|
|
444
|
+
`.stuic-frame-col` on their **content wrapper** — not on the `<dialog>` itself, where the UA's
|
|
445
|
+
`max-width: calc(100% - 38px)` (**G3**) leaves the column 38px narrow: measured frame 1200 / col
|
|
446
|
+
1162 @ x = 19 at ratio 3, and frame 400 / col 362 @ x = 19 at 400×800 — still centred, but
|
|
447
|
+
detuned, and precisely on the handset/bail-out path. Prefer a full-bleed dialog with the class on
|
|
448
|
+
an inner wrapper, which re-aligns it onto the frame's column exactly. It reads the
|
|
449
|
+
same `--stuic-frame-width` contract, with the fallback expression repeated so it works even when
|
|
450
|
+
nothing declares the token (**G12**).
|
|
451
|
+
|
|
452
|
+
**`BodyScroll` — know what it locks.** `src/lib/utils/body-scroll-locker.ts` pins `document.body`
|
|
453
|
+
(`position: fixed; top: -scrollY`) and ref-counts nested locks. `ModalDialog` calls it on open
|
|
454
|
+
unless `noScrollLock` is set; `Backdrop` takes the same `noScrollLock`, and `Drawer` forwards it as
|
|
455
|
+
`noBackdropScrollLock`. **If your frame is
|
|
456
|
+
the scroll container, this does nothing useful** — the body has no scroll to lock. Either lock the
|
|
457
|
+
frame yourself, or do not make the frame the scroll container (**G4b**).
|
|
458
|
+
|
|
459
|
+
**Containment detection is already correct.** `src/lib/utils/containing-block.ts` implements the
|
|
460
|
+
G2-correct rule set (`contain: layout|paint|strict|content`, transforms, `perspective`,
|
|
461
|
+
`will-change`, `content-visibility: auto`; **not** `container-type`; `filter`/`backdrop-filter`
|
|
462
|
+
ignored on WebKit). It stops at top-layer elements (`:modal`, `:popover-open`, `:fullscreen`) since
|
|
463
|
+
the top layer escapes every ancestor CB. `frame.svelte.test.ts` asserts both halves of that rule
|
|
464
|
+
against the preset, so a regression in either file fails the suite.
|
|
465
|
+
|
|
466
|
+
---
|
package/docs/architecture.md
CHANGED
|
@@ -55,6 +55,9 @@ src/lib/
|
|
|
55
55
|
│
|
|
56
56
|
├── icons/ # Icon re-exports from @marianmeres/icons-fns
|
|
57
57
|
│
|
|
58
|
+
├── css/ # CSS-only presets (classes + tokens, no JS)
|
|
59
|
+
│ └── frame.css # Ratio-locked frame (letterbox)
|
|
60
|
+
│
|
|
58
61
|
├── index.css # CENTRALIZED CSS imports
|
|
59
62
|
└── index.ts # Main barrel export
|
|
60
63
|
```
|
|
@@ -76,6 +79,9 @@ src/lib/
|
|
|
76
79
|
@import "./components/Modal/index.css";
|
|
77
80
|
@import "./components/Input/index.css";
|
|
78
81
|
/* ... all component CSS ... */
|
|
82
|
+
|
|
83
|
+
/* Layout preset CSS (classes only, no component) */
|
|
84
|
+
@import "./css/frame.css";
|
|
79
85
|
```
|
|
80
86
|
|
|
81
87
|
**DO NOT** use `import './index.css'` inside component `.svelte` files.
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# CSS Presets Domain
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`src/lib/css/` holds **CSS presets**: a class plus a custom-property contract, with no JS and
|
|
6
|
+
no Svelte component. A preset is used by putting a class on **your own** element and (optionally)
|
|
7
|
+
setting a token on it or an ancestor.
|
|
8
|
+
|
|
9
|
+
Today there is exactly one: the **ratio-locked frame** (`src/lib/css/frame.css`). This document
|
|
10
|
+
also covers the two remaining global class surfaces that live directly in `src/lib/index.css`
|
|
11
|
+
— `.scrollbar-thin` and `.stuic-safe-area-*` — because they have no other home.
|
|
12
|
+
|
|
13
|
+
> **`G<n>` references** point at the measured gotcha list in
|
|
14
|
+
> [Ratio-Locked Frame Reference](../RATIO_LOCKED_FRAME.md#gotchas) — every entry there carries
|
|
15
|
+
> the number it was measured at, and in which engines.
|
|
16
|
+
|
|
17
|
+
**Two rules govern everything below.**
|
|
18
|
+
|
|
19
|
+
1. **Presets are in `@layer components`, so utilities always win.** Tailwind emits
|
|
20
|
+
`@layer theme, base, components, utilities`. `class="stuic-frame h-dvh overflow-y-auto bg-white"`
|
|
21
|
+
overrides every declaration in the preset. That is the escape hatch, by design. (Contrast:
|
|
22
|
+
`.scrollbar-thin` and `.stuic-safe-area-*` are deliberately **unlayered**, so they beat a
|
|
23
|
+
consumer's `utilities` layer — see their sections.)
|
|
24
|
+
2. **stuic declares none of the preset's tokens anywhere.** Defaults exist only as `var()`
|
|
25
|
+
fallbacks at usage sites, per the Fallback Pattern in [conventions.md](../conventions.md).
|
|
26
|
+
This is what makes a scoped override (`style="--stuic-frame-aspect-ratio: 0.25"` on the element,
|
|
27
|
+
or on any ancestor) actually work. See **G11** for the measured cost of getting this wrong.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Ratio-locked frame (letterbox)
|
|
32
|
+
|
|
33
|
+
Lock a box to an aspect ratio, size it to whichever axis binds first, centre it, and let the
|
|
34
|
+
leftover space become letterboxing.
|
|
35
|
+
|
|
36
|
+
### The pattern in one line
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
parent: centre + clip
|
|
40
|
+
child: width = min(available-width, available-height × ratio)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The child's height then comes from `aspect-ratio`. Deriving the width from the height and letting
|
|
44
|
+
`aspect-ratio` derive the height back is **not** circular: `width` resolves first, and
|
|
45
|
+
`aspect-ratio` only ever fills an `auto` axis (**G14**).
|
|
46
|
+
|
|
47
|
+
### Classes
|
|
48
|
+
|
|
49
|
+
| Class | Does | Use when |
|
|
50
|
+
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
51
|
+
| `.stuic-frame` | The ratio-locked box, sized in `vw`/`dvh`, centred via `margin: auto` | The frame is anchored to the viewport |
|
|
52
|
+
| `.stuic-frame-cq` | Same width formula in `cqw`/`cqh`. **Combine with** `.stuic-frame` | The frame is nested (under a header, inside a flex column) |
|
|
53
|
+
| `.stuic-frame-col` | `width: min(100%, <frame width>)` + `margin-inline: auto`. Nothing else — no ratio. Viewport-space only — it cannot track a `.stuic-frame-cq` frame | Re-align a viewport-space element (top-layer dialog, body-portalled overlay) onto the frame's column |
|
|
54
|
+
|
|
55
|
+
`.stuic-frame` also sets `min-height: 0` and `overflow: hidden`. Both are **load-bearing for the
|
|
56
|
+
ratio**, not cosmetics — see **G13**. Do not "simplify" them away.
|
|
57
|
+
|
|
58
|
+
`.stuic-frame-cq` must stay **after** `.stuic-frame` in source order (equal specificity, same
|
|
59
|
+
layer). It is, in `frame.css`; do not reorder.
|
|
60
|
+
|
|
61
|
+
### Token contract
|
|
62
|
+
|
|
63
|
+
All three are consumer **inputs**. stuic declares none of them.
|
|
64
|
+
|
|
65
|
+
| Token | Default (as a `var()` fallback) | Meaning |
|
|
66
|
+
| ---------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
67
|
+
| `--stuic-frame-aspect-ratio` | `1` | width ÷ height. Any value valid in `aspect-ratio:` |
|
|
68
|
+
| `--stuic-frame-width` | `min(100vw, calc(100dvh * (ratio)))`, or the `cq` form under `.stuic-frame-cq` | Wholesale width override. Bail-out value: `100vw` — **pair with `--stuic-frame-height: 100dvh`**; alone, `aspect-ratio` still supplies the height and you get a `100vw` square |
|
|
69
|
+
| `--stuic-frame-height` | `auto` ⇒ ratio-locked | Wholesale height override. `100dvh` ⇒ fill the height, derive the width |
|
|
70
|
+
|
|
71
|
+
The default ratio is **`1`**, not a plausible app ratio: a square is unmistakably "you forgot to set
|
|
72
|
+
the ratio", where a plausible default would ship a plausible-looking wrong layout.
|
|
73
|
+
|
|
74
|
+
**Ratio form.** Anything `aspect-ratio` accepts. Prefer `calc(16 / 9)` or a bare number. `16 / 9`
|
|
75
|
+
works too — `frame.css` parenthesises every `var()` inside every `calc()`, so the library is safe
|
|
76
|
+
with all three forms. **Your** `calc()`s are not: see **G9**.
|
|
77
|
+
|
|
78
|
+
**The four override scenarios**, each measured:
|
|
79
|
+
|
|
80
|
+
```css
|
|
81
|
+
/* 1. GLOBAL — measured 450x900 at ratio 0.5 on a 1440x900 viewport;
|
|
82
|
+
384px wide at 1200x800 with the 0.48 below */
|
|
83
|
+
:root {
|
|
84
|
+
--stuic-frame-aspect-ratio: 0.48;
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<!-- 2. SCOPED (per element, per ancestor; two frames, two ratios, one page) —
|
|
90
|
+
measured 225x900 at r=0.25 next to 1440x810 at r=1.7778 -->
|
|
91
|
+
<div class="stuic-frame" style="--stuic-frame-aspect-ratio: 0.25"></div>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```css
|
|
95
|
+
/* 3. MEDIA-QUERY WHOLESALE (the bail-out) — measured 320x568 full bleed;
|
|
96
|
+
without it, 284x568 with 18px bars */
|
|
97
|
+
@media (max-width: 40rem) and (max-aspect-ratio: 3 / 5) {
|
|
98
|
+
:root {
|
|
99
|
+
--stuic-frame-width: 100vw;
|
|
100
|
+
--stuic-frame-height: 100dvh;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```html
|
|
106
|
+
<!-- 4. TOP-LAYER READ — a dialog that is a DOM descendant of the frame measures 225px,
|
|
107
|
+
tracking the scoped ratio (custom properties inherit into the top layer; geometry
|
|
108
|
+
does not). The same dialog portalled to <body> measures 450px, the :root ratio. -->
|
|
109
|
+
<dialog class="m-0 h-dvh w-screen max-w-none border-0 p-0">
|
|
110
|
+
<div class="stuic-frame-col">…</div>
|
|
111
|
+
</dialog>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Aliasing the width** for call sites outside the frame (e.g. a dropdown that must not exceed the
|
|
115
|
+
column). Measured working, both engines:
|
|
116
|
+
|
|
117
|
+
```css
|
|
118
|
+
:root {
|
|
119
|
+
--stuic-frame-aspect-ratio: 0.48;
|
|
120
|
+
--app-width: var(
|
|
121
|
+
--stuic-frame-width,
|
|
122
|
+
min(100vw, calc(100dvh * (var(--stuic-frame-aspect-ratio))))
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The alias freezes the ratio at its `:root` value — a scoped `--stuic-frame-aspect-ratio` override
|
|
128
|
+
will not move it (**G11**), so use it only where the ratio is global.
|
|
129
|
+
|
|
130
|
+
### What this preset deliberately does NOT ship
|
|
131
|
+
|
|
132
|
+
Everything else in a letterbox layout is already a Tailwind v4 utility. All of these were compiled
|
|
133
|
+
against this repo's own `tailwindcss@4.3.3`:
|
|
134
|
+
|
|
135
|
+
| Job | Tailwind v4 utility | Compiles to |
|
|
136
|
+
| -------------------------------------- | ----------------------------------------- | ----------------------------- |
|
|
137
|
+
| The letterbox bars | `fixed inset-0 grid overflow-hidden bg-*` | — |
|
|
138
|
+
| Trap `position: fixed` descendants | `contain-layout contain-paint` | `contain: layout paint` |
|
|
139
|
+
| Frame is the scroll container | `overflow-y-auto` | `overflow-y: auto` |
|
|
140
|
+
| `cq` units resolve here | `@container-size` | `container-type: size` |
|
|
141
|
+
| (inline-size only — avoid, see **G5**) | `@container` | `container-type: inline-size` |
|
|
142
|
+
| Clear the ratio entirely | `aspect-auto` | `aspect-ratio: auto` |
|
|
143
|
+
|
|
144
|
+
Only the sizing formula is stuic's, because it is the only part Tailwind cannot express. The preset
|
|
145
|
+
ships **no background** either — three known call sites paint three different ways, one of them on
|
|
146
|
+
`body`.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Decision tree
|
|
151
|
+
|
|
152
|
+
**1. Locked box, or derived-width column?**
|
|
153
|
+
|
|
154
|
+
- The whole composition scales proportionally, bars on one axis → `.stuic-frame` (leave
|
|
155
|
+
`--stuic-frame-height: auto`).
|
|
156
|
+
- You want to fill the height and only clamp the width (a column that scrolls) →
|
|
157
|
+
`--stuic-frame-height: 100dvh`. **This is no longer ratio-locked** — read **G15** before choosing it.
|
|
158
|
+
|
|
159
|
+
**2. Do fixed-positioned descendants belong to the frame, or to the screen?**
|
|
160
|
+
|
|
161
|
+
- **To the frame** → add `contain-layout contain-paint` to the frame element. Everything
|
|
162
|
+
`position: fixed` inside now resolves against the frame, with zero component changes.
|
|
163
|
+
**Then do not also make the frame the scroll container — G4b.**
|
|
164
|
+
- **To the screen** → add no containment. Top-layer dialogs and viewport-space overlays stay in
|
|
165
|
+
viewport space, and you reconcile them onto the column with `.stuic-frame-col`. This is the safer
|
|
166
|
+
default and the one that survives `Drawer`, `Backdrop` and scrolled routes.
|
|
167
|
+
|
|
168
|
+
**3. `vw`/`dvh`, or `cq`?**
|
|
169
|
+
|
|
170
|
+
- Frame anchored to the viewport (fixed wrapper, or `body`) → `.stuic-frame` alone.
|
|
171
|
+
- Frame nested inside a box of unknown size → `.stuic-frame .stuic-frame-cq` **plus**
|
|
172
|
+
`@container-size` on an ancestor. `@container` (inline-size) is a silent trap (**G5**).
|
|
173
|
+
|
|
174
|
+
**4. Where does the bail-out query go?**
|
|
175
|
+
|
|
176
|
+
- On `:root` (or any ancestor), as tokens: `--stuic-frame-width: 100vw` and
|
|
177
|
+
`--stuic-frame-height: 100dvh`. Not as a class override on `.stuic-frame` — a token override
|
|
178
|
+
needs no `!important` and no specificity game, and it works without touching the markup.
|
|
179
|
+
- Give it a **second condition** — a `min-height`, or a `max-aspect-ratio`. A width-only breakpoint
|
|
180
|
+
is almost always wrong (**G6**).
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Gotchas
|
|
185
|
+
|
|
186
|
+
**Engine provenance, stated once.** Every measured number in **both** this document and the
|
|
187
|
+
reference manual was taken with Playwright on **Chromium 151.0.7922.34** and **WebKit 26.5**, plus
|
|
188
|
+
**Firefox 153** for the containment rows; results are identical across engines unless a row says
|
|
189
|
+
otherwise, and none of it says anything about **old Safari**. The full list lives in
|
|
190
|
+
[Ratio-Locked Frame Reference](../RATIO_LOCKED_FRAME.md#gotchas), where the items that are _not_
|
|
191
|
+
browser measurements are labelled as such.
|
|
192
|
+
|
|
193
|
+
> ### G4b — THE ONE THAT WILL ACTUALLY BITE YOU
|
|
194
|
+
>
|
|
195
|
+
> **Do not make the frame BOTH the fixed containing block AND the scroll container.**
|
|
196
|
+
> `contain-layout contain-paint` + `overflow-y-auto` on the same element **breaks every
|
|
197
|
+
> `position: fixed` descendant.**
|
|
198
|
+
>
|
|
199
|
+
> Measured, byte-identical in both engines: at `scrollTop: 600`, a `position: fixed; inset: 0`
|
|
200
|
+
> child renders at **`y = -600`**, and a bottom-right FAB jumps from `y = 732` to `y = 132`. They
|
|
201
|
+
> are not fixed any more; they are effectively `absolute` against the scroll origin.
|
|
202
|
+
>
|
|
203
|
+
> **Concrete stuic consequence:** open a `Drawer` on a route scrolled to 600 and the backdrop and
|
|
204
|
+
> the drawer render at `y = -600`. The user taps, and nothing appears. `BodyScroll.lock()` does
|
|
205
|
+
> **not** rescue this: `src/lib/utils/body-scroll-locker.ts` only pins `document.body`, which in
|
|
206
|
+
> this layout has nothing to scroll (`window.scrollY === 0`), so the wheel keeps scrolling the
|
|
207
|
+
> frame and the invisible drawer travels on to `y = -900`.
|
|
208
|
+
>
|
|
209
|
+
> Modal `<dialog>`s survive (top layer, and they block wheel chaining). `Drawer`, `Backdrop`,
|
|
210
|
+
> `DropdownMenu` and the body-portalled actions do not.
|
|
211
|
+
>
|
|
212
|
+
> **Fix:** pick one. Either scroll an inner element and keep containment on the frame, or keep the
|
|
213
|
+
> frame scrollable and accept viewport-space overlays, reconciling them with `.stuic-frame-col`.
|
|
214
|
+
>
|
|
215
|
+
> This is the trap most likely to be shipped by someone following the naive advice, because the
|
|
216
|
+
> naive advice is internally consistent: _"`contain: paint` clips, so make the frame scroll."_
|
|
217
|
+
|
|
218
|
+
**The full list — G1 through G21, each with its measurement — is in
|
|
219
|
+
[Ratio-Locked Frame Reference](../RATIO_LOCKED_FRAME.md#gotchas).** The four most expensive, in
|
|
220
|
+
short: the popular `max-width`/`max-height`/`aspect-ratio` formulation collapses to 0×0 in a centred
|
|
221
|
+
grid/flex parent (G1); `container-type` does **not** create a fixed containing block (G2); `cq` units
|
|
222
|
+
on the element that declares `container-type` resolve against an _ancestor_ container, or silently
|
|
223
|
+
against the viewport (G5); and an explicit `height: 100dvh` is not ratio-locking (G15).
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Recipes
|
|
228
|
+
|
|
229
|
+
Four worked recipes — viewport letterbox, `body`-as-letterbox, nested container-units, and the
|
|
230
|
+
unit-free `max-*` variant — are in
|
|
231
|
+
[Ratio-Locked Frame Reference](../RATIO_LOCKED_FRAME.md#recipes).
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Interop with the rest of stuic
|
|
236
|
+
|
|
237
|
+
**The honest headline: adopting this preset does NOT make stuic's overlays frame-aware.** The
|
|
238
|
+
preset names an ancestor; it does not teach anything to measure against it. Expect per-call-site
|
|
239
|
+
tweaks.
|
|
240
|
+
|
|
241
|
+
Three categories — trapped, escaping, portalled — each verified against the source:
|
|
242
|
+
|
|
243
|
+
| Component / action | Rendering | Behaviour under a `contain:`-ed frame |
|
|
244
|
+
| ----------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
245
|
+
| `Backdrop` | `position: fixed; inset: 0; height: 100dvh`, in place | **Trapped** by containment — but `height: 100dvh` overrides the inset-derived height, so it overflows a short frame (**G18**) |
|
|
246
|
+
| `Drawer` | wraps `Backdrop`, `position: fixed`, in place | **Trapped** — and the primary victim of **G4b** |
|
|
247
|
+
| `DropdownMenu`, `Float`, `HoverExpandableWidth` | `position: fixed`, in place | **Trapped**, and already **CB-aware**: they measure via `fixedContainingBlockRect()` (3.152.0) |
|
|
248
|
+
| `ModalDialog`, and `Modal` through it | `<dialog>` + **always** `.showModal()`, **no portal** | **Escapes** — top layer, viewport geometry. But it is still a DOM descendant, so custom properties inherit (**G3**): size its content from the inherited `--stuic-frame-aspect-ratio`, or use `.stuic-frame-col`, which repeats the fallback (**G12**). `Modal`'s own box is `md:max-w-[calc(100vw-2rem)]` / `md:max-h-[80dvh]` — viewport units (**G18**) |
|
|
249
|
+
| `Notifications` | `popover="manual"` + `showPopover()`, `position: fixed; inset: 0; width/height: 100%` | **Escapes** — top layer, viewport-sized. Toasts render on the bars, not in the frame |
|
|
250
|
+
| `popover`, `spotlight`, `dimBehind` actions | **portalled to `document.body`** by default | **Escape by portalling** (**G19**). Pass the `container` option added in 3.152.0 |
|
|
251
|
+
|
|
252
|
+
**What to do about each** — passing `container` to the portalled actions, reconciling top-layer
|
|
253
|
+
escapees with `.stuic-frame-col`, and what `BodyScroll` does and does not lock — is in
|
|
254
|
+
[Ratio-Locked Frame Reference](../RATIO_LOCKED_FRAME.md#working-with-stuics-overlays).
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Safe-area insets (`.stuic-safe-area-*`)
|
|
259
|
+
|
|
260
|
+
Global, **unlayered** so a deliberate `pt-*` in a consumer's `utilities` layer cannot silently win
|
|
261
|
+
over a safe-area offset. Declared at the end of `src/lib/index.css`.
|
|
262
|
+
|
|
263
|
+
Two surfaces:
|
|
264
|
+
|
|
265
|
+
| Surface | Semantics |
|
|
266
|
+
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
267
|
+
| `--stuic-safe-area-{top,right,bottom,left}` | `0px` always; the real `env()` inset only under standalone/fullscreen. **Compose** them: `padding-block-start: calc(1rem + var(--stuic-safe-area-top))` |
|
|
268
|
+
| `.stuic-safe-area-{top,right,bottom,left}` | **Set** the padding on that axis (they replace, not add). Only for elements that do not otherwise pad that side |
|
|
269
|
+
|
|
270
|
+
Both are gated behind `@media (display-mode: standalone), (display-mode: fullscreen)` and are
|
|
271
|
+
therefore **inert in a normal browser tab** and **hard-zero in a Capacitor WKWebView** (**G21**).
|
|
272
|
+
Non-zero values also require the consuming app to declare `viewport-fit=cover`.
|
|
273
|
+
|
|
274
|
+
Full detail, including which components handle insets automatically and which do not, is in the
|
|
275
|
+
**PWA safe-area insets** section of [README.md](../../README.md).
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## `.scrollbar-thin`
|
|
280
|
+
|
|
281
|
+
Global, unlayered, one declaration: `scrollbar-width: thin`. Declared in `src/lib/index.css`.
|
|
282
|
+
|
|
283
|
+
Standard-property only — no `::-webkit-scrollbar` rules, no colour token. Apply it to any scroll
|
|
284
|
+
container whose default scrollbar is too heavy. Because it is unlayered, a Tailwind utility will not
|
|
285
|
+
override it; use an inline style or your own more-specific rule if you need to opt an element back
|
|
286
|
+
out.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## Key files
|
|
291
|
+
|
|
292
|
+
| File | Purpose |
|
|
293
|
+
| --------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
294
|
+
| `src/lib/css/frame.css` | The preset. Three classes, nine declarations, and the reasoning inline |
|
|
295
|
+
| `src/lib/index.css` | `@import "./css/frame.css"`; also home to `.scrollbar-thin` and `.stuic-safe-area-*` |
|
|
296
|
+
| `src/lib/css/frame.svelte.test.ts` | Browser (Chromium) regression locks: the formula, plus G11–G15 and the G2 distinction |
|
|
297
|
+
| `src/lib/css-wiring.test.ts` | Asserts every stylesheet under `src/lib` is reachable from `index.css` — the failure nothing else can see |
|
|
298
|
+
| `src/lib/utils/containing-block.ts` | `isFixedContainingBlock()` / `fixedContainingBlockRect()` — the G2 rule set |
|
|
299
|
+
| `src/lib/utils/overlay-container.ts` | The `container` option shared by `popover` / `spotlight` / `dimBehind` (**G19**) |
|
|
300
|
+
| `src/lib/utils/body-scroll-locker.ts` | `BodyScroll.lock()` — locks `document.body` only (**G4b**) |
|
|
301
|
+
| `src/lib/components/Backdrop/index.css` | `position: fixed; inset: 0; height: 100dvh` (**G18**) |
|
|
302
|
+
|
|
303
|
+
Related: [theming.md](./theming.md) for the token system, [components.md](./components.md) for the
|
|
304
|
+
overlay components, [actions.md](./actions.md) for the portalled overlay actions.
|
package/docs/domains/theming.md
CHANGED
|
@@ -249,6 +249,8 @@ Override locally:
|
|
|
249
249
|
<Button style="--stuic-button-radius: 0;">Square</Button>
|
|
250
250
|
```
|
|
251
251
|
|
|
252
|
+
Some token sets belong to a CSS-only preset rather than to a component — e.g. `--stuic-frame-*` (ratio-locked frame / letterbox). Those are consumer **inputs** that stuic never declares; see [CSS presets](./css-presets.md).
|
|
253
|
+
|
|
252
254
|
---
|
|
253
255
|
|
|
254
256
|
## Key Files
|