@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
@@ -3,6 +3,7 @@ import { twMerge } from "../../utils/tw-merge.js";
3
3
  import { addAnchorName, removeAnchorName } from "../../utils/anchor-name.js";
4
4
  import { buildPositionTryFallbacks, clampIntoViewport, } from "../../utils/anchor-position.js";
5
5
  import { BodyScroll } from "../../utils/body-scroll-locker.js";
6
+ import { resolveContainerOption } from "../../utils/overlay-container.js";
6
7
  import SpotlightContent from "./SpotlightContent.svelte";
7
8
  //
8
9
  const TRANSITION = 200;
@@ -85,23 +86,35 @@ const _classAnnotation = `
85
86
  border border-(--stuic-spotlight-annotation-border)
86
87
  z-50
87
88
  `;
89
+ const VIEWPORT_ORIGIN = () => ({
90
+ left: 0,
91
+ top: 0,
92
+ width: window.innerWidth,
93
+ height: window.innerHeight,
94
+ scaleX: 1,
95
+ scaleY: 1,
96
+ });
88
97
  /**
89
- * Builds the clip-path value for the backdrop overlay with a rounded-rectangle hole.
98
+ * Builds the clip-path value for the backdrop overlay with a rounded-rectangle
99
+ * hole. `rect` is the target's viewport rect; `o` is the backdrop's own
100
+ * coordinate space (see {@link OverlayOrigin}) — clip-path coordinates are
101
+ * local to the backdrop element, so the hole is translated by the origin and
102
+ * the outer box is the backdrop's layout size.
90
103
  */
91
- function buildClipPath(rect, padding, borderRadius) {
92
- const vw = window.innerWidth;
93
- const vh = window.innerHeight;
94
- const x = rect.left - padding;
95
- const y = rect.top - padding;
96
- const w = rect.width + padding * 2;
97
- const h = rect.height + padding * 2;
104
+ function buildClipPath(rect, padding, borderRadius, o) {
105
+ const ow = o.width;
106
+ const oh = o.height;
107
+ const x = (rect.left - o.left) / o.scaleX - padding;
108
+ const y = (rect.top - o.top) / o.scaleY - padding;
109
+ const w = rect.width / o.scaleX + padding * 2;
110
+ const h = rect.height / o.scaleY + padding * 2;
98
111
  const r = Math.min(borderRadius, w / 2, h / 2);
99
112
  if (r <= 0) {
100
113
  // Simple rectangular hole (no rounding)
101
- return `polygon(evenodd, 0 0, ${vw}px 0, ${vw}px ${vh}px, 0 ${vh}px, 0 0, ${x}px ${y}px, ${x}px ${y + h}px, ${x + w}px ${y + h}px, ${x + w}px ${y}px, ${x}px ${y}px)`;
114
+ return `polygon(evenodd, 0 0, ${ow}px 0, ${ow}px ${oh}px, 0 ${oh}px, 0 0, ${x}px ${y}px, ${x}px ${y + h}px, ${x + w}px ${y + h}px, ${x + w}px ${y}px, ${x}px ${y}px)`;
102
115
  }
103
116
  // Rounded rectangular hole using SVG path syntax
104
- return `path(evenodd, "M 0 0 L ${vw} 0 L ${vw} ${vh} L 0 ${vh} Z M ${x + r} ${y} L ${x + w - r} ${y} A ${r} ${r} 0 0 1 ${x + w} ${y + r} L ${x + w} ${y + h - r} A ${r} ${r} 0 0 1 ${x + w - r} ${y + h} L ${x + r} ${y + h} A ${r} ${r} 0 0 1 ${x} ${y + h - r} L ${x} ${y + r} A ${r} ${r} 0 0 1 ${x + r} ${y} Z")`;
117
+ return `path(evenodd, "M 0 0 L ${ow} 0 L ${ow} ${oh} L 0 ${oh} Z M ${x + r} ${y} L ${x + w - r} ${y} A ${r} ${r} 0 0 1 ${x + w} ${y + r} L ${x + w} ${y + h - r} A ${r} ${r} 0 0 1 ${x + w - r} ${y + h} L ${x + r} ${y + h} A ${r} ${r} 0 0 1 ${x} ${y + h - r} L ${x} ${y + r} A ${r} ${r} 0 0 1 ${x + r} ${y} Z")`;
105
118
  }
106
119
  /**
107
120
  * Checks if content is simple (string/html) vs complex (component/snippet).
@@ -216,6 +229,35 @@ export function spotlight(targetEl, fn) {
216
229
  hide();
217
230
  }
218
231
  }
232
+ /**
233
+ * The coordinate space the overlay's fixed elements resolve against. The
234
+ * backdrop is `position: fixed; inset: 0`, so its own box IS that space —
235
+ * the containing block's padding box when a CB-forming ancestor exists
236
+ * (overlay portalled into a contained/transformed shell via `container`),
237
+ * the viewport otherwise. Measuring it handles both with no special-casing.
238
+ */
239
+ function overlayOrigin() {
240
+ if (!backdropEl)
241
+ return VIEWPORT_ORIGIN();
242
+ const r = backdropEl.getBoundingClientRect();
243
+ // offsetWidth/Height are layout-space; the rect is visual — the ratio is
244
+ // the accumulated ancestor scale. offset* are integer-rounded, so treat
245
+ // sub-pixel differences as "unscaled" to avoid noise on the default path.
246
+ const w = backdropEl.offsetWidth || r.width;
247
+ const h = backdropEl.offsetHeight || r.height;
248
+ const sx = w && Math.abs(r.width - w) > 1 ? r.width / w : 1;
249
+ const sy = h && Math.abs(r.height - h) > 1 ? r.height / h : 1;
250
+ return { left: r.left, top: r.top, width: w, height: h, scaleX: sx, scaleY: sy };
251
+ }
252
+ /** Place the invisible anchor element over the (padded) hole. */
253
+ function positionAnchor(rect, padding, o) {
254
+ if (!anchorEl)
255
+ return;
256
+ anchorEl.style.left = `${(rect.left - o.left) / o.scaleX - padding}px`;
257
+ anchorEl.style.top = `${(rect.top - o.top) / o.scaleY - padding}px`;
258
+ anchorEl.style.width = `${rect.width / o.scaleX + padding * 2}px`;
259
+ anchorEl.style.height = `${rect.height / o.scaleY + padding * 2}px`;
260
+ }
219
261
  /**
220
262
  * Update the clip-path hole position to match the current target rect.
221
263
  */
@@ -225,15 +267,11 @@ export function spotlight(targetEl, fn) {
225
267
  const rect = targetEl.getBoundingClientRect();
226
268
  const padding = currentOptions.padding ?? 8;
227
269
  const borderRadius = currentOptions.borderRadius ?? 8;
270
+ const o = overlayOrigin();
228
271
  debug("updateHolePosition()", rect);
229
- backdropEl.style.clipPath = buildClipPath(rect, padding, borderRadius);
272
+ backdropEl.style.clipPath = buildClipPath(rect, padding, borderRadius, o);
230
273
  // Update the invisible anchor element position
231
- if (anchorEl) {
232
- anchorEl.style.left = `${rect.left - padding}px`;
233
- anchorEl.style.top = `${rect.top - padding}px`;
234
- anchorEl.style.width = `${rect.width + padding * 2}px`;
235
- anchorEl.style.height = `${rect.height + padding * 2}px`;
236
- }
274
+ positionAnchor(rect, padding, o);
237
275
  // Reposition / re-clamp the annotation. The fallback path recomputes its
238
276
  // base left/top here; the anchor path is re-placed by the browser. Either
239
277
  // way we re-clamp so an edge-anchored annotation stays on-screen as the
@@ -281,17 +319,21 @@ export function spotlight(targetEl, fn) {
281
319
  lastRect = null;
282
320
  }
283
321
  /**
284
- * Position annotation without CSS Anchor Positioning (fallback).
322
+ * Position annotation without CSS Anchor Positioning (fallback). All values
323
+ * are expressed in the overlay's coordinate space (see {@link overlayOrigin})
324
+ * — for a fixed element, `left/top/right/bottom` resolve against the
325
+ * containing block, which is the viewport only in the un-portalled default.
285
326
  */
286
327
  function positionAnnotationFallback(rect, padding) {
287
328
  if (!annotationEl)
288
329
  return;
289
330
  const pos = currentOptions.position || "bottom";
290
331
  const offset = 8; // px fallback offset
291
- const x = rect.left - padding;
292
- const y = rect.top - padding;
293
- const w = rect.width + padding * 2;
294
- const h = rect.height + padding * 2;
332
+ const o = overlayOrigin();
333
+ const x = (rect.left - o.left) / o.scaleX - padding;
334
+ const y = (rect.top - o.top) / o.scaleY - padding;
335
+ const w = rect.width / o.scaleX + padding * 2;
336
+ const h = rect.height / o.scaleY + padding * 2;
295
337
  // Reset position
296
338
  annotationEl.style.left = "";
297
339
  annotationEl.style.top = "";
@@ -300,14 +342,14 @@ export function spotlight(targetEl, fn) {
300
342
  annotationEl.style.transform = "";
301
343
  if (pos.startsWith("top")) {
302
344
  annotationEl.style.left = `${x}px`;
303
- annotationEl.style.bottom = `${window.innerHeight - y + offset}px`;
345
+ annotationEl.style.bottom = `${o.height - y + offset}px`;
304
346
  }
305
347
  else if (pos.startsWith("bottom")) {
306
348
  annotationEl.style.left = `${x}px`;
307
349
  annotationEl.style.top = `${y + h + offset}px`;
308
350
  }
309
351
  else if (pos === "left") {
310
- annotationEl.style.right = `${window.innerWidth - x + offset}px`;
352
+ annotationEl.style.right = `${o.width - x + offset}px`;
311
353
  annotationEl.style.top = `${y}px`;
312
354
  }
313
355
  else if (pos === "right") {
@@ -328,7 +370,11 @@ export function spotlight(targetEl, fn) {
328
370
  function clampAnnotationIntoViewport() {
329
371
  if (!annotationEl || !annotationShown)
330
372
  return;
331
- clampIntoViewport(annotationEl);
373
+ // Pass the empirically measured CB rect (the inset-0 backdrop's box) so
374
+ // the clamp uses the SAME coordinate space as the hole/anchor math even
375
+ // where the heuristic ancestor walk and the engine disagree (WebKit
376
+ // filter shells).
377
+ clampIntoViewport(annotationEl, undefined, backdropEl ? backdropEl.getBoundingClientRect() : undefined);
332
378
  }
333
379
  function renderContent() {
334
380
  if (!annotationEl || !currentOptions.content)
@@ -374,8 +420,12 @@ export function spotlight(targetEl, fn) {
374
420
  requestAnimationFrame(() => {
375
421
  if (!isVisible)
376
422
  return; // may have been hidden in the meantime
423
+ const container = resolveContainerOption(currentOptions.container) ?? document.body;
377
424
  const rect = targetEl.getBoundingClientRect();
378
- // 1. Create backdrop overlay
425
+ // 1. Create backdrop overlay. Append BEFORE building the clip-path:
426
+ // the hole coordinates are relative to the backdrop's own box, which
427
+ // is only measurable once it is in the DOM (same JS turn — nothing
428
+ // paints unclipped).
379
429
  backdropEl = document.createElement("div");
380
430
  backdropEl.style.cssText = `
381
431
  position: fixed;
@@ -385,26 +435,29 @@ export function spotlight(targetEl, fn) {
385
435
  transition-duration: ${TRANSITION}ms;
386
436
  `;
387
437
  backdropEl.classList.add(...twMerge("stuic-spotlight-backdrop", currentOptions.classBackdrop).split(/\s/));
388
- backdropEl.style.clipPath = buildClipPath(rect, padding, borderRadius);
389
- document.body.appendChild(backdropEl);
438
+ container.appendChild(backdropEl);
439
+ const o = overlayOrigin();
440
+ backdropEl.style.clipPath = buildClipPath(rect, padding, borderRadius, o);
390
441
  // 2. Create invisible anchor element for CSS Anchor Positioning
391
442
  anchorEl = document.createElement("div");
392
443
  anchorEl.style.cssText = `
393
444
  position: fixed;
394
- left: ${rect.left - padding}px;
395
- top: ${rect.top - padding}px;
396
- width: ${rect.width + padding * 2}px;
397
- height: ${rect.height + padding * 2}px;
398
445
  pointer-events: none;
399
446
  z-index: -1;
400
447
  `;
448
+ positionAnchor(rect, padding, o);
401
449
  addAnchorName(anchorEl, anchorName);
402
- document.body.appendChild(anchorEl);
450
+ container.appendChild(anchorEl);
403
451
  // 3. Create annotation element (if content provided)
404
452
  if (currentOptions.content) {
405
453
  annotationEl = document.createElement("div");
406
454
  annotationEl.setAttribute("role", "dialog");
407
455
  if (isSupported) {
456
+ // NOTE: keep `vw`/`vh` here — this is the ANCHORED branch, where
457
+ // the element's containing block is the `position-area` region (a
458
+ // slice of the CB, often much smaller), so `%` would shrink the
459
+ // annotation. Overflow is handled by position-try + the CB-aware
460
+ // clamp.
408
461
  annotationEl.style.cssText = `
409
462
  position: fixed;
410
463
  position-anchor: ${anchorName};
@@ -420,17 +473,18 @@ export function spotlight(targetEl, fn) {
420
473
  annotationEl.classList.add(...twMerge("stuic-spotlight-annotation", _classAnnotation, currentOptions.class).split(/\s/));
421
474
  }
422
475
  else {
423
- // Fallback positioning
476
+ // Fallback positioning. `90%` (not `90vw`): the left/top values
477
+ // are containing-block-relative, so the size must be too.
424
478
  annotationEl.style.cssText = `
425
479
  position: fixed;
426
480
  transition-duration: ${TRANSITION}ms;
427
481
  z-index: 50;
428
- max-width: 90vw;
482
+ max-width: 90%;
429
483
  `;
430
484
  annotationEl.classList.add(...twMerge("stuic-spotlight-annotation-fallback", _classAnnotation, currentOptions.class).split(/\s/));
431
485
  positionAnnotationFallback(rect, padding);
432
486
  }
433
- document.body.appendChild(annotationEl);
487
+ container.appendChild(annotationEl);
434
488
  renderContent();
435
489
  }
436
490
  // 4. Lock body scroll
@@ -457,9 +511,12 @@ export function spotlight(targetEl, fn) {
457
511
  if (currentOptions.closeOnBackdropClick !== false) {
458
512
  backdropEl.addEventListener("click", onBackdropClick);
459
513
  }
460
- // 7. Watch for target position changes
514
+ // 7. Watch for target position changes — and for the overlay's own
515
+ // coordinate space changing size (the backdrop tracks its container,
516
+ // which resize/scroll listeners and target-rect comparison won't see)
461
517
  resizeObserver = new ResizeObserver(updateHolePosition);
462
518
  resizeObserver.observe(targetEl);
519
+ resizeObserver.observe(backdropEl);
463
520
  window.addEventListener("resize", updateHolePosition);
464
521
  window.addEventListener("scroll", updateHolePosition, true);
465
522
  // 8. Per-frame compare-loop to catch layout shifts (sibling collapses,
@@ -527,6 +584,7 @@ export function spotlight(targetEl, fn) {
527
584
  onHide: opts.onHide,
528
585
  debug: opts.debug,
529
586
  id: opts.id,
587
+ container: opts.container,
530
588
  };
531
589
  do_debug = !!opts.debug;
532
590
  // Register in global registry if id provided
@@ -266,6 +266,7 @@
266
266
  import Thc from "../Thc/Thc.svelte";
267
267
  import ListItemButton from "../ListItemButton/ListItemButton.svelte";
268
268
  import { BodyScroll } from "../../utils/body-scroll-locker.js";
269
+ import { fixedContainingBlockRect } from "../../utils/containing-block.js";
269
270
  import { waitForTwoRepaints } from "../../utils/paint.js";
270
271
  import {
271
272
  extractSearchableItems,
@@ -542,15 +543,17 @@
542
543
  await waitForTwoRepaints();
543
544
  if (!dropdownEl || !isOpen) return;
544
545
 
546
+ // Measure against the dropdown's containing block, not the viewport —
547
+ // an ancestor with e.g. `transform` or `contain: layout|paint` is what
548
+ // the fixed dropdown actually resolves (and gets clipped) against.
545
549
  const rect = dropdownEl.getBoundingClientRect();
546
- const viewportWidth = window.innerWidth;
547
- const viewportHeight = window.innerHeight;
550
+ const cb = fixedContainingBlockRect(dropdownEl);
548
551
 
549
552
  if (
550
- rect.left < 0 ||
551
- rect.right > viewportWidth ||
552
- rect.top < 0 ||
553
- rect.bottom > viewportHeight
553
+ rect.left < cb.left ||
554
+ rect.right > cb.right ||
555
+ rect.top < cb.top ||
556
+ rect.bottom > cb.bottom
554
557
  ) {
555
558
  switchingToFallback = true;
556
559
  runtimeFallback = true;
@@ -707,12 +710,16 @@
707
710
  const heightStyle = searchConfig
708
711
  ? `height: ${maxHeight};`
709
712
  : `max-height: ${maxHeight};`;
713
+ // `90%` (not `90vw`): the top/left/transform centering is relative to
714
+ // the containing block, so the size must be too — `%` resolves against
715
+ // the CB (identical to `vw` when the CB is the viewport, correct when
716
+ // an ancestor with `transform`/`contain` establishes one).
710
717
  return `
711
718
  position: fixed;
712
719
  top: 50%;
713
720
  left: 50%;
714
721
  transform: translate(-50%, -50%);
715
- max-width: 90vw;
722
+ max-width: 90%;
716
723
  ${heightStyle}
717
724
  ${gutterStyle}
718
725
  z-index: 50;
@@ -361,6 +361,7 @@ Use `contentBefore` for leading content (icons) and `contentAfter` for trailing
361
361
  ## Features
362
362
 
363
363
  - **CSS Anchor Positioning**: Uses modern CSS anchor positioning with automatic fallback for unsupported browsers
364
+ - **Containing-block aware**: Overflow detection and the fallback modal measure against the dropdown's actual containing block — inside a `transform`ed or `contain: layout|paint` shell they use the shell's box, not the viewport
364
365
  - **Full Keyboard Navigation**: Complete arrow key navigation with Home/End support
365
366
  - **Expandable Sections**: Collapsible groups with independent toggle state
366
367
  - **ARIA Compliant**: Proper menu roles and keyboard interaction
@@ -67,6 +67,7 @@
67
67
  <script lang="ts">
68
68
  import { untrack } from "svelte";
69
69
  import { twMerge } from "../../utils/tw-merge.js";
70
+ import { fixedContainingBlockRect } from "../../utils/containing-block.js";
70
71
  import { localStorageState } from "../../utils/persistent-state.svelte.js";
71
72
  import { draggable as draggableAction } from "../../actions/draggable.svelte.js";
72
73
  import { iconChevronDown, iconX } from "../../icons/index.js";
@@ -130,6 +131,26 @@
130
131
 
131
132
  function viewport(): FloatSize {
132
133
  if (typeof window === "undefined") return { width: 0, height: 0 };
134
+ // The panel is `position: fixed`, so its `left`/`top` (and therefore all
135
+ // placement/clamping math) resolve against its containing block — the
136
+ // viewport, unless an ancestor (`transform`, `contain: layout|paint`, …)
137
+ // establishes one. `x`/`y` are written as `left`/`top`, i.e. LAYOUT px,
138
+ // while the CB rect is visual — divide out the accumulated ancestor
139
+ // scale (measured on the panel itself; sub-pixel offset* rounding is
140
+ // treated as unscaled).
141
+ if (el) {
142
+ const cb = fixedContainingBlockRect(el);
143
+ const r = el.getBoundingClientRect();
144
+ const sx =
145
+ el.offsetWidth && Math.abs(r.width - el.offsetWidth) > 1
146
+ ? r.width / el.offsetWidth
147
+ : 1;
148
+ const sy =
149
+ el.offsetHeight && Math.abs(r.height - el.offsetHeight) > 1
150
+ ? r.height / el.offsetHeight
151
+ : 1;
152
+ return { width: cb.width / sx, height: cb.height / sy };
153
+ }
133
154
  return { width: window.innerWidth, height: window.innerHeight };
134
155
  }
135
156
 
@@ -5,7 +5,7 @@ dev/inspector tweak panel (dat.GUI / Tweakpane style). It has a header (optional
5
5
  icon, a `THC` title, an actions slot, and minimize/close buttons) and an arbitrary body.
6
6
 
7
7
  - **Positioned by params**: numeric `x`/`y` **or** a named `placement` preset (corners / edges / center).
8
- - **`position: fixed`** relative to the viewport, with drag **clamped** so it never leaves the screen.
8
+ - **`position: fixed`** relative to the viewport — or to the nearest containing-block ancestor (`transform`, `contain: layout|paint`, …) when one exists — with drag **clamped** so it never leaves that box.
9
9
  - **Draggable** by the whole header (buttons excepted).
10
10
  - **Minimizable** to just the title bar (header button, double-click header, or methods).
11
11
  - **Imperative control** via a `bind:this` ref (mirrors `Modal`/`ModalDialog`).
@@ -24,7 +24,10 @@
24
24
  </script>
25
25
 
26
26
  <script lang="ts">
27
- import { innerHeight, innerWidth } from "svelte/reactivity/window";
27
+ import {
28
+ fixedContainingBlockAncestor,
29
+ fixedContainingBlockRect,
30
+ } from "../../utils/containing-block.js";
28
31
  import { DevicePointer } from "../../utils/device-pointer.svelte.js";
29
32
  import { waitForNextRepaint, waitForTransitionEnd } from "../../utils/paint.js";
30
33
  import { prefersReducedMotion } from "../../utils/prefers-reduced-motion.svelte.js";
@@ -82,12 +85,34 @@
82
85
  isExpanded = true;
83
86
  isExpanding = true;
84
87
 
88
+ // Pin the element in place: the inset values below resolve against the
89
+ // containing block once `position: fixed` is applied — the viewport,
90
+ // unless an ancestor (`transform`, `contain: layout|paint`, …)
91
+ // establishes one. Insets are LAYOUT px in the CB's content space, while
92
+ // rects are visual: divide out the accumulated ancestor scale, add the
93
+ // CB's scroll offsets (a fixed element whose CB is a scroll container
94
+ // behaves like an absolute one — it scrolls with the content), and
95
+ // derive bottom/right from the CB size so the top+height+bottom
96
+ // constraint stays exact. With no CB ancestor this reduces to the plain
97
+ // viewport-edge distances.
85
98
  box = el.getBoundingClientRect();
99
+ const cbEl = fixedContainingBlockAncestor(el);
100
+ const cb = fixedContainingBlockRect(el);
101
+ const sx =
102
+ el.offsetWidth && Math.abs(box.width - el.offsetWidth) > 1
103
+ ? box.width / el.offsetWidth
104
+ : 1;
105
+ const sy =
106
+ el.offsetHeight && Math.abs(box.height - el.offsetHeight) > 1
107
+ ? box.height / el.offsetHeight
108
+ : 1;
109
+ const top = (box.top - cb.top) / sy + (cbEl?.scrollTop ?? 0);
110
+ const left = (box.left - cb.left) / sx + (cbEl?.scrollLeft ?? 0);
86
111
  const pos = {
87
- top: box.top,
88
- bottom: (innerHeight.current ?? 0) - box.bottom,
89
- left: box.left,
90
- right: (innerWidth.current ?? 0) - box.right,
112
+ top,
113
+ left,
114
+ bottom: cb.height / sy - top - box.height / sy,
115
+ right: cb.width / sx - left - box.width / sx,
91
116
  };
92
117
 
93
118
  // <offset-x>, <offset-y>, <blur-radius>, <spread-radius>
@@ -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),
@@ -19,7 +19,11 @@
19
19
  */
20
20
  export declare function buildPositionTryFallbacks(position: string): string;
21
21
  /**
22
- * Pull an element fully into the viewport with a corrective `transform`.
22
+ * Pull an element fully into its containing block with a corrective
23
+ * `transform`. For a fixed element that is the viewport — unless an ancestor
24
+ * (`transform`, `contain: layout|paint`, …) establishes a containing block, in
25
+ * which case the element is clamped into that ancestor's box instead (see
26
+ * {@link fixedContainingBlockRect}).
23
27
  *
24
28
  * This is the backstop for CSS Anchor Positioning: `position-try` can only swap
25
29
  * between discrete declared positions and cannot slide a centered annotation
@@ -36,6 +40,11 @@ export declare function buildPositionTryFallbacks(position: string): string;
36
40
  * correction applies instantly. The caller owns the element's `transform`.
37
41
  *
38
42
  * @param el - The (anchored, position:fixed) element to clamp
39
- * @param margin - Minimum gap from each viewport edge, in px (default 8)
43
+ * @param margin - Minimum gap from each containing-block edge, in px (default 8)
44
+ * @param cb - Optional explicit containing-block rect (viewport/visual
45
+ * coordinates). Callers that can measure the CB empirically (e.g. spotlight,
46
+ * via its own `inset: 0` backdrop) pass it to stay self-consistent even
47
+ * where the heuristic walker and the engine disagree (WebKit filter cases);
48
+ * defaults to {@link fixedContainingBlockRect}.
40
49
  */
41
- export declare function clampIntoViewport(el: HTMLElement, margin?: number): void;
50
+ export declare function clampIntoViewport(el: HTMLElement, margin?: number, cb?: DOMRectReadOnly): void;
@@ -2,6 +2,7 @@
2
2
  * Shared helpers for CSS Anchor Positioning based actions (spotlight, popover,
3
3
  * tooltip).
4
4
  */
5
+ import { fixedContainingBlockRect } from "./containing-block.js";
5
6
  /**
6
7
  * Builds the `position-try-fallbacks` value for an anchored element at a given
7
8
  * position.
@@ -28,7 +29,11 @@ export function buildPositionTryFallbacks(position) {
28
29
  return flips;
29
30
  }
30
31
  /**
31
- * Pull an element fully into the viewport with a corrective `transform`.
32
+ * Pull an element fully into its containing block with a corrective
33
+ * `transform`. For a fixed element that is the viewport — unless an ancestor
34
+ * (`transform`, `contain: layout|paint`, …) establishes a containing block, in
35
+ * which case the element is clamped into that ancestor's box instead (see
36
+ * {@link fixedContainingBlockRect}).
32
37
  *
33
38
  * This is the backstop for CSS Anchor Positioning: `position-try` can only swap
34
39
  * between discrete declared positions and cannot slide a centered annotation
@@ -45,25 +50,40 @@ export function buildPositionTryFallbacks(position) {
45
50
  * correction applies instantly. The caller owns the element's `transform`.
46
51
  *
47
52
  * @param el - The (anchored, position:fixed) element to clamp
48
- * @param margin - Minimum gap from each viewport edge, in px (default 8)
53
+ * @param margin - Minimum gap from each containing-block edge, in px (default 8)
54
+ * @param cb - Optional explicit containing-block rect (viewport/visual
55
+ * coordinates). Callers that can measure the CB empirically (e.g. spotlight,
56
+ * via its own `inset: 0` backdrop) pass it to stay self-consistent even
57
+ * where the heuristic walker and the engine disagree (WebKit filter cases);
58
+ * defaults to {@link fixedContainingBlockRect}.
49
59
  */
50
- export function clampIntoViewport(el, margin = 8) {
60
+ export function clampIntoViewport(el, margin = 8, cb = fixedContainingBlockRect(el)) {
51
61
  // Remove any prior correction so we measure the natural (anchored or
52
62
  // left/top) position, then recompute from scratch.
53
63
  el.style.transform = "";
54
64
  const a = el.getBoundingClientRect();
55
- const vw = window.innerWidth;
56
- const vh = window.innerHeight;
57
65
  let dx = 0;
58
66
  let dy = 0;
59
- if (a.left < margin)
60
- dx = margin - a.left;
61
- else if (a.right > vw - margin)
62
- dx = vw - margin - a.right;
63
- if (a.top < margin)
64
- dy = margin - a.top;
65
- else if (a.bottom > vh - margin)
66
- dy = vh - margin - a.bottom;
67
- if (dx || dy)
68
- el.style.transform = `translate(${dx}px, ${dy}px)`;
67
+ if (a.left < cb.left + margin)
68
+ dx = cb.left + margin - a.left;
69
+ else if (a.right > cb.right - margin)
70
+ dx = cb.right - margin - a.right;
71
+ if (a.top < cb.top + margin)
72
+ dy = cb.top + margin - a.top;
73
+ else if (a.bottom > cb.bottom - margin)
74
+ dy = cb.bottom - margin - a.bottom;
75
+ if (dx || dy) {
76
+ // The deltas above are visual (rect) px, but the translate applies in the
77
+ // element's local space — inside a scaled ancestor (`transform: scale()`
78
+ // zoom/preview wrapper) they differ by the accumulated scale factor.
79
+ // offsetWidth is integer-rounded, so treat sub-pixel differences as
80
+ // "unscaled" (a ratio threshold would misfire on small elements).
81
+ const sx = el.offsetWidth && Math.abs(a.width - el.offsetWidth) > 1
82
+ ? a.width / el.offsetWidth
83
+ : 1;
84
+ const sy = el.offsetHeight && Math.abs(a.height - el.offsetHeight) > 1
85
+ ? a.height / el.offsetHeight
86
+ : 1;
87
+ el.style.transform = `translate(${dx / sx}px, ${dy / sy}px)`;
88
+ }
69
89
  }