@uniweb/runtime 1.4.5 → 1.5.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.4.5",
3
+ "version": "1.5.0",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,15 +36,15 @@
36
36
  "node": ">=22.12"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "~1.6.4",
40
- "@uniweb/theming": "~1.2.1"
39
+ "@uniweb/core": "~1.6.6",
40
+ "@uniweb/theming": "~1.3.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@vitejs/plugin-react": "^4.5.2",
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.15.3"
47
+ "@uniweb/build": "1.17.2"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -13,6 +13,7 @@ import Background from './Background.jsx'
13
13
  import { sectionStickyProps, sectionScrollMargin } from '../sticky.js'
14
14
  import { noteBodySection } from '../sticky-top.js'
15
15
  import { noteSectionWrapper } from '../sticky-check.js'
16
+ import { openingUnderHeader, underHeaderProps } from '../under-header.js'
16
17
 
17
18
  /**
18
19
  * Valid color contexts
@@ -173,6 +174,14 @@ export default function BlockRenderer({ block, as = 'section', stickySelf = fals
173
174
  // On a page whose layout has a sticky area, a link to this section lands below it.
174
175
  const margin = sectionScrollMargin(block, stickySelf)
175
176
  if (margin) wrapperProps.style = { ...wrapperProps.style, ...margin }
177
+ // The page's opening section, when the header lies over it: the header's height to clear, the
178
+ // declared one until the browser measures it (`../under-header.js`). Mirrored in `ssr-renderer.js`.
179
+ const overlay = stickySelf === 'section' || stickySelf === true ? openingUnderHeader(block) : null
180
+ if (overlay) {
181
+ const under = underHeaderProps(overlay)
182
+ wrapperProps.style = { ...wrapperProps.style, ...under.style }
183
+ wrapperProps['data-uw-under-header'] = under['data-uw-under-header']
184
+ }
176
185
  // A body section's element, for the measurement of what is stuck over the body (`../sticky-top.js`);
177
186
  // in dev, every section's, so `uniweb dev`'s sticky check knows a component's outermost element.
178
187
  const bodySection = stickySelf === 'section' || stickySelf === true
@@ -202,6 +211,11 @@ export default function BlockRenderer({ block, as = 'section', stickySelf = fals
202
211
  // and let the engine background show through.
203
212
  block.hasBackground = hasBackground
204
213
 
214
+ // `background: 'self'` is everything behind the content: the theme leaves this wrapper
215
+ // unpainted too, and the component paints its context's surface where it wants it
216
+ // (`@uniweb/theming`'s section rule). Mirrored in `ssr-renderer.js`.
217
+ if (meta?.background === 'self') wrapperProps['data-uw-background'] = 'self'
218
+
205
219
  // Determine wrapper element:
206
220
  // - Explicit as prop (not 'section') → use as prop directly
207
221
  // - Component.as → use component's declared tag (e.g., Header.as = 'header')
@@ -35,6 +35,7 @@ import { useEffect } from 'react'
35
35
  import Blocks from './Blocks.jsx'
36
36
  import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from '../area-wrappers.js'
37
37
  import { layoutStickiness, zoneProps } from '../sticky.js'
38
+ import { headerOverlay, overlayBoxProps } from '../under-header.js'
38
39
  import { warnUnmovableSticky } from '../sticky-check.js'
39
40
  import { watchStickyTop, bodySectionOf } from '../sticky-top.js'
40
41
  // The built-in layout — one copy, which the prerender renders too (`../default-layout.js`).
@@ -95,9 +96,14 @@ export default function Layout({ page, website }) {
95
96
  // it is lifted above the areas that scroll under it.
96
97
  const sticky = layoutStickiness(areas)
97
98
  const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
99
+ // A page that opens with a section under the header has the header lie over it: the header's box
100
+ // takes no room in the flow (`../under-header.js`). In a foundation's layout that box is the area
101
+ // wrapper; in the built-in one, the zone around it.
102
+ const overlay = headerOverlay(page)
98
103
 
99
104
  const wrap = (region, children) => {
100
- const props = areaWrapperProps(region, transitions, layers, RemoteLayout ? sticky[region] : null)
105
+ let props = areaWrapperProps(region, transitions, layers, RemoteLayout ? sticky[region] : null)
106
+ if (overlay && RemoteLayout && region === 'header') props = overlayBoxProps(props)
101
107
  return props ? <div {...props}>{children}</div> : children
102
108
  }
103
109
 
@@ -143,7 +149,7 @@ export default function Layout({ page, website }) {
143
149
  <DefaultLayout
144
150
  body={bodyElement}
145
151
  {...areaElements}
146
- zones={{ header: zone('header'), footer: zone('footer') }}
152
+ zones={{ header: overlay ? overlayBoxProps(zone('header')) : zone('header'), footer: zone('footer') }}
147
153
  />
148
154
  )
149
155
  }
@@ -34,6 +34,7 @@ import {
34
34
  } from './wire-foundation.js'
35
35
  import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from './area-wrappers.js'
36
36
  import { layoutStickiness, sectionStickyProps, sectionScrollMargin, zoneProps } from './sticky.js'
37
+ import { headerOverlay, overlayBoxProps, openingUnderHeader, underHeaderProps } from './under-header.js'
37
38
  import { DefaultLayout } from './default-layout.js'
38
39
  import { renderAppearanceBootScript } from './appearance.js'
39
40
  import { documentTitle } from './document-title.js'
@@ -185,6 +186,14 @@ export function renderBlock(block, { as = 'section', stickySelf = false } = {})
185
186
  // On a page whose layout has a sticky area, a link to this section lands below it.
186
187
  const margin = sectionScrollMargin(block, stickySelf)
187
188
  if (margin) wrapperProps.style = { ...wrapperProps.style, ...margin }
189
+ // The page's opening section, when the header lies over it: the header's height to clear
190
+ // (`under-header.js`). Mirrors `BlockRenderer.jsx`.
191
+ const overlay = stickySelf === 'section' || stickySelf === true ? openingUnderHeader(block) : null
192
+ if (overlay) {
193
+ const under = underHeaderProps(overlay)
194
+ wrapperProps.style = { ...wrapperProps.style, ...under.style }
195
+ wrapperProps['data-uw-under-header'] = under['data-uw-under-header']
196
+ }
188
197
 
189
198
  // Merge Component.className (static classes declared on the component function)
190
199
  const componentClassName = Component.className
@@ -197,6 +206,8 @@ export function renderBlock(block, { as = 'section', stickySelf = false } = {})
197
206
  // Check if component handles its own background
198
207
  const hasBackground = background?.mode && meta?.background !== 'self'
199
208
  block.hasBackground = hasBackground
209
+ // `background: 'self'` leaves the wrapper unpainted by the theme too (`BlockRenderer.jsx`).
210
+ if (meta?.background === 'self') wrapperProps['data-uw-background'] = 'self'
200
211
 
201
212
  // Determine wrapper element:
202
213
  // - Explicit as (not 'section') → use as prop directly
@@ -261,8 +272,11 @@ export function renderLayout(page, website) {
261
272
  // above the areas that scroll under it.
262
273
  const sticky = layoutStickiness(areas)
263
274
  const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
275
+ // Mirror Layout.jsx: the header lies over a page's opening section under it (`under-header.js`).
276
+ const overlay = headerOverlay(page)
264
277
  const wrapArea = (name, element) => {
265
- const props = areaWrapperProps(name, transitions, layers, RemoteLayout ? sticky[name] : null)
278
+ let props = areaWrapperProps(name, transitions, layers, RemoteLayout ? sticky[name] : null)
279
+ if (overlay && RemoteLayout && name === 'header') props = overlayBoxProps(props)
266
280
  return props ? React.createElement('div', props, element) : element
267
281
  }
268
282
 
@@ -288,7 +302,7 @@ export function renderLayout(page, website) {
288
302
  return React.createElement(DefaultLayout, {
289
303
  body: bodyElement,
290
304
  ...areaElements,
291
- zones: { header: zone('header'), footer: zone('footer') },
305
+ zones: { header: overlay ? overlayBoxProps(zone('header')) : zone('header'), footer: zone('footer') },
292
306
  })
293
307
  }
294
308
 
package/src/sticky-top.js CHANGED
@@ -16,8 +16,15 @@
16
16
  * reaches its `top` plus its height; the variable is the furthest any of them reaches. A rule of
17
17
  * position, so it reads no meaning into an area's name (`area-wrappers.js` refuses to for the
18
18
  * same reason).
19
+ *
20
+ * ⭐ A HEADER OVER THE OPENING SECTION (`under-header.js`) takes no room in the flow, so its box is
21
+ * as tall as nothing: what it reaches is what it holds, the runtime's own wrappers inside it. Its
22
+ * height is published too, as `--uw-header-measured`, which the opening section's
23
+ * `--uw-header-height` reads in place of the height the header type declared for the first paint.
19
24
  */
20
25
 
26
+ import { MEASURED_HEADER_HEIGHT } from './under-header.js'
27
+
21
28
  const VARIABLE = '--uw-sticky-top'
22
29
  const FOLLOWING = 4 // Node.DOCUMENT_POSITION_FOLLOWING
23
30
 
@@ -40,6 +47,34 @@ export function bodySectionOf(block) {
40
47
  return (block && bodySections.get(block)) || null
41
48
  }
42
49
 
50
+ /**
51
+ * How tall a box is drawn: its own height — or, for the header's box over the page's opening
52
+ * section (`data-uw-header="over"`, `under-header.js`), which takes no room in the flow, how far what
53
+ * it holds reaches below its top. What it holds is the runtime's own elements (the area's wrapper,
54
+ * the sections' wrappers), never a guess at a foundation's markup.
55
+ *
56
+ * @param {Element} box
57
+ * @returns {number}
58
+ */
59
+ function drawnHeight(box) {
60
+ const own = box.getBoundingClientRect()
61
+ if (box.getAttribute('data-uw-header') !== 'over') return own.height
62
+ let bottom = own.top
63
+ for (const child of box.children) bottom = Math.max(bottom, child.getBoundingClientRect().bottom)
64
+ return bottom - own.top
65
+ }
66
+
67
+ /**
68
+ * How tall the header lying over the page's opening section is, in px — null on a page with none.
69
+ *
70
+ * @param {Document} doc
71
+ * @returns {number|null}
72
+ */
73
+ export function measureHeaderOverlay(doc) {
74
+ const box = doc.querySelector('[data-uw-header="over"]')
75
+ return box ? Math.round(drawnHeight(box) * 100) / 100 : null
76
+ }
77
+
43
78
  /**
44
79
  * How far down the viewport the stuck chrome over the body reaches, in px — 0 when none does.
45
80
  *
@@ -57,7 +92,7 @@ export function measureStickyTop(doc, body) {
57
92
  const own = box.getBoundingClientRect()
58
93
  if (own.right <= over.left || own.left >= over.right) continue // beside the body: a rail
59
94
  const top = parseFloat(doc.defaultView.getComputedStyle(box).top) || 0
60
- reach = Math.max(reach, top + own.height)
95
+ reach = Math.max(reach, top + drawnHeight(box))
61
96
  }
62
97
  return Math.round(reach * 100) / 100
63
98
  }
@@ -78,13 +113,19 @@ export function watchStickyTop(doc, body) {
78
113
  const reach = measureStickyTop(doc, body)
79
114
  if (reach > 0) root.style.setProperty(VARIABLE, `${reach}px`)
80
115
  else root.style.removeProperty(VARIABLE)
116
+ // The header over the opening section: what the section's `--uw-header-height` reads once known.
117
+ const header = measureHeaderOverlay(doc)
118
+ if (header != null) root.style.setProperty(MEASURED_HEADER_HEIGHT, `${header}px`)
119
+ else root.style.removeProperty(MEASURED_HEADER_HEIGHT)
120
+ }
121
+ const forget = () => {
122
+ root.style.removeProperty(VARIABLE)
123
+ root.style.removeProperty(MEASURED_HEADER_HEIGHT)
81
124
  }
82
125
  apply()
83
126
 
84
127
  const View = doc.defaultView
85
- if (!body || typeof View?.ResizeObserver !== 'function') {
86
- return () => root.style.removeProperty(VARIABLE)
87
- }
128
+ if (!body || typeof View?.ResizeObserver !== 'function') return forget
88
129
  let frame = 0
89
130
  const observer = new View.ResizeObserver(() => {
90
131
  View.cancelAnimationFrame(frame)
@@ -92,9 +133,11 @@ export function watchStickyTop(doc, body) {
92
133
  })
93
134
  observer.observe(body)
94
135
  for (const box of doc.querySelectorAll('[data-uw-sticky="area"]')) observer.observe(box)
136
+ // A box over the opening section keeps no height of its own to change: what it holds does.
137
+ for (const box of doc.querySelectorAll('[data-uw-header="over"]')) for (const child of box.children) observer.observe(child)
95
138
  return () => {
96
139
  View.cancelAnimationFrame(frame)
97
140
  observer.disconnect()
98
- root.style.removeProperty(VARIABLE)
141
+ forget()
99
142
  }
100
143
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The header laid over a page's opening section — `underHeader`, a setting of the section.
3
+ *
4
+ * A page that opens with a section that can sit under the header (`underHeader: true`: its type's
5
+ * default in `meta.js`, or its author's) has the header lie over it. The header's box takes no room
6
+ * in the flow, so the section starts at the top of the window and its picture reaches up behind a
7
+ * see-through header; the header keeps its own height inside that box, and stays in view if it
8
+ * sticks. Pure, so both renderers (`components/Layout.jsx`, `ssr-renderer.js`) build the same page,
9
+ * and the browser's measurement (`sticky-top.js`) reads the same boxes.
10
+ *
11
+ * ⭐ THE RUNTIME DOES IT, AS IT DOES `sticky`, because a component cannot move the box the runtime
12
+ * puts it in. Foundations that floated a header from inside its own section took the bar out of
13
+ * the flow (`absolute`, `fixed`), which left the box the runtime measures with no height: no
14
+ * `--uw-sticky-top`, and a link to a section landing under the bar (measured 2026-11-06 on a site's
15
+ * store page, in Chromium and WebKit). Five sites solved it five ways.
16
+ *
17
+ * The opening section keeps its content clear of the header by `--uw-header-height`, which the
18
+ * runtime gives its wrapper: the header type's `height` on the first paint, which no prerender can
19
+ * measure, then what the browser measures. The header reads `getNextBlockInfo().underHeader` to
20
+ * paint itself see-through, and its box says `data-uw-header="over"` for a stylesheet.
21
+ */
22
+
23
+ /** The header's height, on the opening section's wrapper: what its content is padded by. */
24
+ export const HEADER_HEIGHT = '--uw-header-height'
25
+
26
+ /** What the browser measured, on the root, which the opening section's value defers to. */
27
+ export const MEASURED_HEADER_HEIGHT = '--uw-header-measured'
28
+
29
+ /**
30
+ * Whether the page's header lies over its opening section, and how tall the header is before the
31
+ * browser measures it: the `height` its sections' types declare, else `0px` until measured.
32
+ *
33
+ * @param {Object} page - its blocks' components initialized, as both renderers do before rendering
34
+ * @returns {{ height: string }|null}
35
+ */
36
+ export function headerOverlay(page) {
37
+ const first = page?.getBodyBlocks?.()?.[0]
38
+ if (!first?.underHeader) return null
39
+ const header = page.getLayoutAreas?.()?.header
40
+ if (!Array.isArray(header) || header.length === 0) return null
41
+ const heights = header.map((block) => block?.height).filter(Boolean)
42
+ const height = heights.length === 0 ? '0px' : heights.length === 1 ? heights[0] : `calc(${heights.join(' + ')})`
43
+ return { height }
44
+ }
45
+
46
+ /**
47
+ * The props of the header's box — the built-in layout's zone, or a foundation layout's area
48
+ * wrapper — when it lies over the opening section: it takes no room in the flow, and what it holds
49
+ * keeps its height. Whatever else the box has (its layer, its stickiness) stays.
50
+ *
51
+ * @param {Object|null} props - the box's props without the overlay
52
+ * @returns {Object}
53
+ */
54
+ export function overlayBoxProps(props) {
55
+ return { ...(props || {}), style: { ...(props?.style || {}), height: 0, minHeight: 0 }, 'data-uw-header': 'over' }
56
+ }
57
+
58
+ /**
59
+ * What the opening section's wrapper adds when the header lies over it: the header's height, the
60
+ * declared one until the browser has measured it.
61
+ *
62
+ * @param {{ height: string }} overlay - `headerOverlay(page)`
63
+ * @returns {{ style: Object, 'data-uw-under-header': string }}
64
+ */
65
+ export function underHeaderProps(overlay) {
66
+ return {
67
+ style: { [HEADER_HEIGHT]: `var(${MEASURED_HEADER_HEIGHT}, ${overlay.height})` },
68
+ 'data-uw-under-header': '',
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Whether `block` is its page's opening section under the header — what its wrapper asks before it
74
+ * adds `underHeaderProps`.
75
+ *
76
+ * @param {Object} block - a body section's block, its component initialized
77
+ * @returns {{ height: string }|null} the page's overlay, when it opens with this section
78
+ */
79
+ export function openingUnderHeader(block) {
80
+ const page = block?.page
81
+ if (!page || page.getBodyBlocks?.()?.[0] !== block) return null
82
+ return headerOverlay(page)
83
+ }