@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.
- package/AGENTS.md +3 -0
- package/API.md +1 -0
- package/README.md +72 -0
- package/dist/actions/dim-behind/dim-behind.fixture.svelte +54 -0
- package/dist/actions/dim-behind/dim-behind.fixture.svelte.d.ts +9 -0
- package/dist/actions/dim-behind/dim-behind.svelte.d.ts +10 -0
- package/dist/actions/dim-behind/dim-behind.svelte.js +72 -41
- package/dist/actions/popover/README.md +37 -17
- package/dist/actions/popover/popover.container.fixture.svelte +26 -0
- package/dist/actions/popover/popover.container.fixture.svelte.d.ts +7 -0
- package/dist/actions/popover/popover.svelte.d.ts +10 -0
- package/dist/actions/popover/popover.svelte.js +20 -7
- package/dist/actions/spotlight/spotlight.container.fixture.svelte +33 -0
- package/dist/actions/spotlight/spotlight.container.fixture.svelte.d.ts +7 -0
- package/dist/actions/spotlight/spotlight.svelte.d.ts +9 -0
- package/dist/actions/spotlight/spotlight.svelte.js +95 -37
- package/dist/components/DropdownMenu/DropdownMenu.svelte +14 -7
- package/dist/components/DropdownMenu/README.md +1 -0
- package/dist/components/Float/Float.svelte +21 -0
- package/dist/components/Float/README.md +1 -1
- package/dist/components/HoverExpandableWidth/HoverExpandableWidth.svelte +30 -5
- package/dist/css/frame.css +109 -0
- package/dist/index.css +3 -0
- package/dist/utils/anchor-position.d.ts +12 -3
- package/dist/utils/anchor-position.js +35 -15
- package/dist/utils/containing-block.d.ts +55 -0
- package/dist/utils/containing-block.js +131 -0
- package/dist/utils/overlay-container.d.ts +16 -0
- package/dist/utils/overlay-container.js +12 -0
- package/docs/RATIO_LOCKED_FRAME.md +466 -0
- package/docs/architecture.md +6 -0
- package/docs/domains/css-presets.md +304 -0
- package/docs/domains/theming.md +2 -0
- package/package.json +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
|
+
---
|
package/docs/architecture.md
CHANGED
|
@@ -55,6 +55,9 @@ src/lib/
|
|
|
55
55
|
│
|
|
56
56
|
├── icons/ # Icon re-exports from @marianmeres/icons-fns
|
|
57
57
|
│
|
|
58
|
+
├── css/ # CSS-only presets (classes + tokens, no JS)
|
|
59
|
+
│ └── frame.css # Ratio-locked frame (letterbox)
|
|
60
|
+
│
|
|
58
61
|
├── index.css # CENTRALIZED CSS imports
|
|
59
62
|
└── index.ts # Main barrel export
|
|
60
63
|
```
|
|
@@ -76,6 +79,9 @@ src/lib/
|
|
|
76
79
|
@import "./components/Modal/index.css";
|
|
77
80
|
@import "./components/Input/index.css";
|
|
78
81
|
/* ... all component CSS ... */
|
|
82
|
+
|
|
83
|
+
/* Layout preset CSS (classes only, no component) */
|
|
84
|
+
@import "./css/frame.css";
|
|
79
85
|
```
|
|
80
86
|
|
|
81
87
|
**DO NOT** use `import './index.css'` inside component `.svelte` files.
|