@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 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
+ ---
@@ -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.
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.152.0",
3
+ "version": "3.153.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",