@marianmeres/stuic 3.151.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.
Files changed (34) hide show
  1. package/AGENTS.md +3 -0
  2. package/API.md +1 -0
  3. package/README.md +72 -0
  4. package/dist/actions/dim-behind/dim-behind.fixture.svelte +54 -0
  5. package/dist/actions/dim-behind/dim-behind.fixture.svelte.d.ts +9 -0
  6. package/dist/actions/dim-behind/dim-behind.svelte.d.ts +10 -0
  7. package/dist/actions/dim-behind/dim-behind.svelte.js +72 -41
  8. package/dist/actions/popover/README.md +37 -17
  9. package/dist/actions/popover/popover.container.fixture.svelte +26 -0
  10. package/dist/actions/popover/popover.container.fixture.svelte.d.ts +7 -0
  11. package/dist/actions/popover/popover.svelte.d.ts +10 -0
  12. package/dist/actions/popover/popover.svelte.js +20 -7
  13. package/dist/actions/spotlight/spotlight.container.fixture.svelte +33 -0
  14. package/dist/actions/spotlight/spotlight.container.fixture.svelte.d.ts +7 -0
  15. package/dist/actions/spotlight/spotlight.svelte.d.ts +9 -0
  16. package/dist/actions/spotlight/spotlight.svelte.js +95 -37
  17. package/dist/components/DropdownMenu/DropdownMenu.svelte +14 -7
  18. package/dist/components/DropdownMenu/README.md +1 -0
  19. package/dist/components/Float/Float.svelte +21 -0
  20. package/dist/components/Float/README.md +1 -1
  21. package/dist/components/HoverExpandableWidth/HoverExpandableWidth.svelte +30 -5
  22. package/dist/css/frame.css +109 -0
  23. package/dist/index.css +3 -0
  24. package/dist/utils/anchor-position.d.ts +12 -3
  25. package/dist/utils/anchor-position.js +35 -15
  26. package/dist/utils/containing-block.d.ts +55 -0
  27. package/dist/utils/containing-block.js +131 -0
  28. package/dist/utils/overlay-container.d.ts +16 -0
  29. package/dist/utils/overlay-container.js +12 -0
  30. package/docs/RATIO_LOCKED_FRAME.md +466 -0
  31. package/docs/architecture.md +6 -0
  32. package/docs/domains/css-presets.md +304 -0
  33. package/docs/domains/theming.md +2 -0
  34. package/package.json +12 -12
@@ -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.