@uniweb/runtime 1.3.13 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "1.3.13",
3
+ "version": "1.4.0",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,7 +44,7 @@
44
44
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0 || ^0.28.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "1.14.2"
47
+ "@uniweb/build": "1.14.4"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -1,34 +1,45 @@
1
1
  /**
2
- * What the runtime puts on a layout-area wrapper.
3
- *
4
- * When a foundation enables view transitions (the default), the runtime gives
5
- * each layout area a `view-transition-name` so the browser animates it on its
6
- * own — persistent chrome (header, sidebar, footer) morphs in place while
7
- * everything else, the body included, crossfades as the page. With no names the
8
- * whole layout (chrome included) would flicker on every navigation.
9
- *
10
- * ⛔ **The body takes no name by default, which is what keeps a phone usable.** A
11
- * named element is captured as one image. WebKit captures it whole, off-screen part
12
- * included, at device resolution, painting every filter in it on the main thread; the
13
- * page itself is captured at the size of the viewport. Measured 2026-10-30 on a live
14
- * site, in WebKit on a 3× phone viewport with a desktop CPU: a named body was a
15
- * 1179 × 22710 capture, 107 MB against 9 MB, and a tap stalled the main thread for up to
16
- * 842 ms, against 271 ms with no name. Chromium did not stall at all. On an iPhone, that
17
- * site froze for about 30 s on a tap. The body still crossfades, with the page.
18
- *
19
- * Naming is only half of it. A `view-transition-name` **makes its element a
20
- * stacking context**, so the moment the runtime adds these wrappers it has
21
- * decided how the areas paint relative to one another — and with no `z-index`
22
- * on them they all sit at `auto` and paint in DOM order, which puts the body
23
- * over the header on any layout that renders the header first.
24
- *
25
- * That is not theoretical. It is the same mechanism `@uniweb/kit`'s `Overlay`
26
- * exists for (a modal opened from the header, trapped inside `uw-header`), and
27
- * it made a real docs page's fixed header unclickable while the identical
28
- * header on the marketing layout was fine — because that layout's markup
29
- * happened to wrap its header area in `relative z-40`. A framework that
30
- * creates stacking contexts owes its users an order; leaving it to DOM order
31
- * means "does my header work" is answered by an accident of someone's JSX.
2
+ * What the runtime puts on a layout-area wrapper: its stacking layer, and the
3
+ * `view-transition-name` its layout asks for, if any.
4
+ *
5
+ * ⭐ **An area takes no name unless its layout asks.** A navigation crossfades the
6
+ * page, and chrome that stays in place stays steady, since the old page and the new
7
+ * one agree there (measured 2026-11-02 in Chromium and WebKit). A name takes a box out
8
+ * of that crossfade and animates it from where it was on screen to where it lands.
9
+ * That is worth having for a morph a layout means, and does harm to a box nobody
10
+ * thought about. Until 2026-11-02 the runtime named every area, with these results:
11
+ *
12
+ * - a bar `fixed` in a box that scrolls with the page moved by the whole scroll on a
13
+ * switch from a scrolled page: the old bar dropped through the page as it faded,
14
+ * and the new one came down from above;
15
+ * - a visible footer moved the same way, off the bottom of the screen;
16
+ * - a box with no height is captured as nothing, so a bar overlaying the page was
17
+ * gone for the length of every switch;
18
+ * - in WebKit a bar kept in place, `fixed` or stuck, faded out over a switch and came
19
+ * back at the end;
20
+ * - a name makes a Backdrop Root, so a bar's `backdrop-blur` saw nothing past its
21
+ * wrapper, at rest too.
22
+ *
23
+ * ⛔ **A name is also a capture.** A named element is captured as one image. WebKit
24
+ * captures it whole, off-screen part included, at device resolution, painting every
25
+ * filter in it on the main thread; the page itself is captured at the size of the
26
+ * viewport. Measured 2026-10-30 on a live site, in WebKit on a 3× phone viewport with a
27
+ * desktop CPU: a named body was a 1179 × 22710 capture, 107 MB against 9 MB, and a tap
28
+ * stalled the main thread for up to 842 ms, against 271 ms with no name. Chromium did
29
+ * not stall at all. On an iPhone, that site froze for about 30 s on a tap.
30
+ *
31
+ * The wrapper's standing job is the order areas paint in. A layer makes its wrapper
32
+ * **a stacking context**, as a section's own `z-index` and background routinely make
33
+ * theirs, and with no order they paint by position in the DOM, which put the body over
34
+ * the header on any layout that renders the header first.
35
+ *
36
+ * That is not theoretical. It is the same mechanism `@uniweb/kit`'s `Overlay` exists
37
+ * for (a modal opened from the header, trapped inside the header's wrapper), and it
38
+ * made a real docs page's fixed header unclickable while the identical header on the
39
+ * marketing layout was fine — because that layout's markup happened to wrap its header
40
+ * area in `relative z-40`. A framework that creates stacking contexts owes its users an
41
+ * order; leaving it to DOM order means "does my header work" is answered by an accident
42
+ * of someone's JSX.
32
43
  *
33
44
  * So this module resolves BOTH halves of the wrapper — the transition name and
34
45
  * the stacking layer — and hands back the finished style. It is pure (no
@@ -39,38 +50,22 @@
39
50
 
40
51
  import { stickyStyle } from './sticky.js'
41
52
 
42
- // Namespace so generated names can't collide with `view-transition-name`s a
43
- // foundation sets inside its own component CSS. The prefix also guarantees a
44
- // valid CSS <custom-ident> (starts with a letter).
45
- const NS = 'uw-'
46
-
47
- const toIdent = (name) => NS + String(name).replace(/[^a-zA-Z0-9_-]/g, '-')
48
-
49
53
  /**
50
- * Build the effective view-transition-name map for a layout.
51
- *
52
- * Default: every rendered area gets a stable, namespaced name (`uw-<area>`), so
53
- * same-named areas across layouts share a name and morph between layouts
54
- * automatically. The body gets none and crossfades as the page (the header above).
54
+ * A layout's view-transition names: what its `meta.js` `transitions` asks for.
55
55
  *
56
- * The layout's `meta.js` `transitions` value overrides this:
57
- * - an object overrides per region (`{ left: 'sidebar' }` to group across
58
- * layouts, `{ left: null }` to opt one region out, or `{ body: 'main' }` for a
59
- * body that morphs between layouts, which WebKit captures whole);
60
- * - `false` opts the whole layout out (back to the full-page crossfade).
56
+ * - nothing: no area takes a name, and the page crossfades as one (the header above);
57
+ * - an object names regions (`{ left: 'sidebar' }`), and two layouts that give a
58
+ * region the same name morph it between them. A body may be named (`{ body: 'main' }`),
59
+ * and WebKit captures it whole;
60
+ * - `false` opts the layout out, which leaves its body with no wrapper
61
+ * (`areaWrapperStyle`).
61
62
  *
62
- * @param {string[]} areaNames - Names of the areas rendered for this page (excludes `body`).
63
63
  * @param {Object|false|null|undefined} explicit - `layoutMeta.transitions`.
64
64
  * @returns {Object|null} region → view-transition-name; `null` when opted out.
65
- * A region whose value is null/empty in the returned map gets no name.
65
+ * A region whose value is absent or empty gets no name.
66
66
  */
67
- export function resolveLayoutTransitions(areaNames, explicit) {
68
- if (explicit === false) return null
69
-
70
- const transitions = {}
71
- for (const name of areaNames) transitions[name] = toIdent(name)
72
-
73
- return explicit ? { ...transitions, ...explicit } : transitions
67
+ export function resolveLayoutTransitions(explicit) {
68
+ return explicit === false ? null : explicit || {}
74
69
  }
75
70
 
76
71
  /**
@@ -83,14 +83,12 @@ export default function Layout({ page, website }) {
83
83
  const allBlockGroups = [bodyBlocks, ...Object.values(areas)]
84
84
  initializeAllBlocks(...allBlockGroups)
85
85
 
86
- // Pre-render each area as React elements.
87
- // When the foundation enables view transitions, wrap areas in thin divs with
88
- // view-transition-name so the browser can animate them independently. Names
89
- // default to one per area and none for the body (see resolveLayoutTransitions);
90
- // the layout's `transitions` meta overrides or opts out.
86
+ // Pre-render each area as React elements, each in a thin div with its stacking layer.
87
+ // A region takes a view-transition-name only where the layout's `transitions` meta asks
88
+ // for one (see resolveLayoutTransitions); everything else crossfades as the page.
91
89
  const areaNames = Object.keys(areas)
92
90
  const transitions = website.viewTransitions
93
- ? resolveLayoutTransitions(areaNames, layoutMeta?.transitions)
91
+ ? resolveLayoutTransitions(layoutMeta?.transitions)
94
92
  : null
95
93
  // An area stays in view when any of its sections is sticky (`../sticky.js`): in a foundation's
96
94
  // layout its wrapper sticks; in the built-in one, the zone around the wrapper does. Either way
@@ -248,12 +248,12 @@ export function renderLayout(page, website) {
248
248
  for (const block of blocks || []) block.initComponent()
249
249
  }
250
250
 
251
- // Mirror Layout.jsx: wrap the body and each area in a thin div, an area's carrying its
252
- // view-transition-name, so the prerendered HTML matches the page the SPA renders
253
- // in its place and the browser can animate regions independently on client navigation.
251
+ // Mirror Layout.jsx: wrap the body and each area in a thin div, with its stacking layer and
252
+ // any view-transition-name the layout asks for, so the prerendered HTML matches the page the
253
+ // SPA renders in its place.
254
254
  const areaNames = Object.keys(areas)
255
255
  const transitions = website.viewTransitions
256
- ? resolveLayoutTransitions(areaNames, layoutMeta?.transitions)
256
+ ? resolveLayoutTransitions(layoutMeta?.transitions)
257
257
  : null
258
258
 
259
259
  // Mirror Layout.jsx: an area stays in view when any of its sections is sticky — in a