@uniweb/runtime 1.1.20 → 1.2.1

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.1.20",
3
+ "version": "1.2.1",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=22.12"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "~1.4.4",
39
+ "@uniweb/core": "~1.4.6",
40
40
  "@uniweb/theming": "~1.2.1"
41
41
  },
42
42
  "devDependencies": {
@@ -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.8.5"
47
+ "@uniweb/build": "1.9.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -4,13 +4,13 @@
4
4
  *
5
5
  * ## Why this module exists
6
6
  *
7
- * Backgrounds are an L3 pair by design: `components/Background.jsx` renders
8
- * with hooks and DOM, `ssr-renderer.js` renders with `React.createElement`
9
- * only, and the runtime-layer rule says that
10
- * pair is twinned on purpose. ⛔ **What it also says is that only the RENDERING
11
- * is twinned — "pure data → L1, one home" — and two pure helpers had been
12
- * dragged across the seam with it**: a base-path joiner and a colour-opacity
13
- * converter, byte-identical in both files.
7
+ * Backgrounds were an L3 pair until 2026-10-25: `components/Background.jsx`
8
+ * rendered for the browser and `ssr-renderer.js` with `React.createElement`
9
+ * only. ⛔ **Only the RENDERING was ever meant to be twinned — "pure data →
10
+ * L1, one home" — and two pure helpers had been dragged across the seam with
11
+ * it**: a base-path joiner and a colour-opacity converter, byte-identical in
12
+ * both files. The rendering is one copy now too (`background.js`: it needs no
13
+ * hooks or DOM); these stay apart from it because they are not rendering.
14
14
  *
15
15
  * ⚠️ **And the copies had already drifted from the rule they were copying.**
16
16
  * The base-path joiner is `@uniweb/core`'s `applyBasePath`, whose own docblock
@@ -59,9 +59,15 @@ export function siteUrl(url) {
59
59
  export function withOpacity(color, opacity) {
60
60
  if (typeof color !== 'string' || !color) return color
61
61
  if (color.startsWith('#')) {
62
- const r = parseInt(color.slice(1, 3), 16)
63
- const g = parseInt(color.slice(3, 5), 16)
64
- const b = parseInt(color.slice(5, 7), 16)
62
+ // `#rgb` and `#rgba` are written out first; an alpha pair is replaced by `opacity`.
63
+ // ⛔ Until 2026-10-25 every hex was read as six digits, so `#000` gave `rgba(0, 0, NaN, …)`,
64
+ // and a gradient using it was invalid CSS that drew nothing.
65
+ let hex = color.slice(1)
66
+ if (hex.length === 3 || hex.length === 4) hex = [...hex].map((c) => c + c).join('')
67
+ if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(hex)) return color
68
+ const r = parseInt(hex.slice(0, 2), 16)
69
+ const g = parseInt(hex.slice(2, 4), 16)
70
+ const b = parseInt(hex.slice(4, 6), 16)
65
71
  return `rgba(${r}, ${g}, ${b}, ${opacity})`
66
72
  }
67
73
  if (color.startsWith('rgb')) {
@@ -0,0 +1,182 @@
1
+ /**
2
+ * A section's background — the layer the runtime draws behind a section's content: a color, a
3
+ * gradient, an image or a video, under an optional overlay. It reads `block.background`, which core
4
+ * normalizes once (`Block.normalizeBackground`).
5
+ *
6
+ * ⭐ ONE COPY, FOR BOTH RENDERERS: the browser's (`components/Background.jsx`) and the prerender's
7
+ * (`ssr-renderer.js`, behind `uniweb build` and a host's prerender alike). It needs no hooks, no
8
+ * Router and no DOM, so it is written with `createElement` and both draw it — the L3 twins exist for
9
+ * code that needs those (`default-layout.js` is the same rule).
10
+ * ⛔ Until 2026-10-25 each renderer wrote its own, and they had drifted: the prerender read an
11
+ * overlay's `angle: 0` and `endPosition: 0` as unset (`||`), drew no video background at all, and
12
+ * dropped the `className` kit's `SectionBackground` passes. Each showed as the page changing when
13
+ * the browser rendered it in place of the prerendered one. Pinned by `tests/page-parity.test.js`.
14
+ *
15
+ * ⭐ A VIDEO IS ITS POSTER UNTIL THE BROWSER HAS MOUNTED IT. Both renderers draw the poster
16
+ * (`VideoPoster`); the browser then swaps in the playing `<video>` (`components/Background.jsx`),
17
+ * unless the visitor prefers reduced motion and there is a poster to keep (`videoMoves`). That
18
+ * preference is the one thing here that needs the DOM, so it is read in an effect, never in the first
19
+ * render: read there, it made the browser's first render differ from the prerendered page for exactly
20
+ * those visitors.
21
+ *
22
+ * ⭐ An unset value is `undefined` or `null`, never a falsy one: `0` is an angle, a position and an
23
+ * opacity like any other (`??`, not `||`).
24
+ */
25
+
26
+ import React from 'react'
27
+ import { siteUrl, withOpacity } from './background-shared.js'
28
+
29
+ const h = React.createElement
30
+
31
+ /** The box every layer fills: the section's, behind its content (`BlockRenderer.jsx`). */
32
+ const FILL = { position: 'absolute', inset: 0 }
33
+
34
+ function ColorLayer({ color }) {
35
+ if (!color) return null
36
+ return h('div', { className: 'background-color', style: { ...FILL, backgroundColor: color }, 'aria-hidden': 'true' })
37
+ }
38
+
39
+ function GradientLayer({ gradient }) {
40
+ if (!gradient) return null
41
+
42
+ // A CSS gradient as written ("linear-gradient(to bottom, #000, #333)")
43
+ if (typeof gradient === 'string') {
44
+ return h('div', { className: 'background-gradient', style: { ...FILL, background: gradient }, 'aria-hidden': 'true' })
45
+ }
46
+
47
+ const start = gradient.start ?? 'transparent'
48
+ const end = gradient.end ?? 'transparent'
49
+ const startOpacity = gradient.startOpacity ?? 1
50
+ const endOpacity = gradient.endOpacity ?? 1
51
+ const startColor = startOpacity < 1 ? withOpacity(start, startOpacity) : start
52
+ const endColor = endOpacity < 1 ? withOpacity(end, endOpacity) : end
53
+ const background =
54
+ `linear-gradient(${gradient.angle ?? 0}deg, ` +
55
+ `${startColor} ${gradient.startPosition ?? 0}%, ${endColor} ${gradient.endPosition ?? 100}%)`
56
+
57
+ return h('div', { className: 'background-gradient', style: { ...FILL, background }, 'aria-hidden': 'true' })
58
+ }
59
+
60
+ function ImageLayer({ image }) {
61
+ if (!image?.src) return null
62
+ const style = {
63
+ ...FILL,
64
+ backgroundImage: `url(${siteUrl(image.src)})`,
65
+ backgroundPosition: image.position ?? 'center',
66
+ backgroundSize: image.size ?? 'cover',
67
+ backgroundRepeat: 'no-repeat',
68
+ }
69
+ return h('div', { className: 'background-image', style, 'aria-hidden': 'true' })
70
+ }
71
+
72
+ /**
73
+ * A video background as both renderers first draw it: its poster, as an image layer — or nothing,
74
+ * when it has none. The browser replaces it with `VideoElement` once mounted.
75
+ */
76
+ export function VideoPoster({ video }) {
77
+ if (!video?.src || !video.poster) return null
78
+ return h(ImageLayer, { image: { src: video.poster, size: 'cover', position: 'center' } })
79
+ }
80
+
81
+ /**
82
+ * Whether a mounted video background plays: always, unless the visitor prefers reduced motion and
83
+ * there is a poster to show instead.
84
+ *
85
+ * @param {Object} video - `background.video`
86
+ * @param {boolean} reducedMotion - the visitor's `prefers-reduced-motion`
87
+ * @returns {boolean}
88
+ */
89
+ export function videoMoves(video, reducedMotion) {
90
+ if (!video?.src) return false
91
+ return !(reducedMotion && video.poster)
92
+ }
93
+
94
+ /** The playing video, which only the browser draws, once mounted (`components/Background.jsx`). */
95
+ export function VideoElement({ video }) {
96
+ if (!video?.src) return null
97
+ const sources = (video.sources || inferSources(video.src)).map((s) => ({ ...s, src: siteUrl(s.src) }))
98
+ return h(
99
+ 'video',
100
+ {
101
+ className: 'background-video',
102
+ style: { ...FILL, width: '100%', height: '100%', objectFit: 'cover' },
103
+ autoPlay: true,
104
+ loop: video.loop ?? true,
105
+ muted: video.muted ?? true,
106
+ playsInline: true,
107
+ poster: siteUrl(video.poster),
108
+ 'aria-hidden': 'true',
109
+ },
110
+ sources.map(({ src, type }, index) => h('source', { key: index, src, type })),
111
+ )
112
+ }
113
+
114
+ /**
115
+ * The formats to offer for a single `src`: an `.mp4` is offered as `.webm` first (better
116
+ * compression), and the reverse; the browser plays the first it supports.
117
+ */
118
+ function inferSources(src) {
119
+ const ext = src.split('.').pop()?.toLowerCase()
120
+ const base = src.slice(0, src.lastIndexOf('.'))
121
+ if (ext === 'mp4') return [{ src: `${base}.webm`, type: 'video/webm' }, { src, type: 'video/mp4' }]
122
+ if (ext === 'webm') return [{ src, type: 'video/webm' }, { src: `${base}.mp4`, type: 'video/mp4' }]
123
+ const type = src.endsWith('.ogg') || src.endsWith('.ogv') ? 'video/ogg' : 'video/mp4'
124
+ return [{ src, type }]
125
+ }
126
+
127
+ function Overlay({ overlay }) {
128
+ if (!overlay?.enabled) return null
129
+ const opacity = overlay.opacity ?? 0.5
130
+
131
+ if (overlay.gradient) {
132
+ const g = overlay.gradient
133
+ const background =
134
+ `linear-gradient(${g.angle ?? 180}deg, ${g.start ?? 'rgba(0,0,0,0.7)'} ${g.startPosition ?? 0}%, ` +
135
+ `${g.end ?? 'rgba(0,0,0,0)'} ${g.endPosition ?? 100}%)`
136
+ return h('div', {
137
+ className: 'background-overlay background-overlay--gradient',
138
+ style: { ...FILL, background, opacity, pointerEvents: 'none' },
139
+ 'aria-hidden': 'true',
140
+ })
141
+ }
142
+
143
+ const base = overlay.type === 'light' ? '255, 255, 255' : '0, 0, 0'
144
+ return h('div', {
145
+ className: 'background-overlay background-overlay--solid',
146
+ style: { ...FILL, backgroundColor: `rgba(${base}, ${opacity})`, pointerEvents: 'none' },
147
+ 'aria-hidden': 'true',
148
+ })
149
+ }
150
+
151
+ /**
152
+ * The background layer: `block.background`'s fields, and a `className` for the layer (kit's
153
+ * `SectionBackground` passes one).
154
+ *
155
+ * @param {Object} props
156
+ * @param {string} props.mode - 'color' | 'gradient' | 'image' | 'video'; nothing is drawn without one
157
+ * @param {string} [props.color]
158
+ * @param {string|Object} [props.gradient]
159
+ * @param {Object} [props.image]
160
+ * @param {Object} [props.video]
161
+ * @param {Object} [props.overlay]
162
+ * @param {string} [props.className]
163
+ * @param {Function} [props.Video] - how a video background is drawn: its poster (the default, and the
164
+ * prerender's), or the browser's, which plays it once mounted
165
+ * @returns {React.ReactElement|null}
166
+ */
167
+ export function Background({ mode, color, gradient, image, video, overlay, className = '', Video = VideoPoster }) {
168
+ if (!mode) return null
169
+ return h(
170
+ 'div',
171
+ {
172
+ className: `background background--${mode} ${className}`.trim(),
173
+ style: { ...FILL, overflow: 'hidden', zIndex: 0 },
174
+ 'aria-hidden': 'true',
175
+ },
176
+ mode === 'color' ? h(ColorLayer, { color }) : null,
177
+ mode === 'gradient' ? h(GradientLayer, { gradient }) : null,
178
+ mode === 'image' ? h(ImageLayer, { image }) : null,
179
+ mode === 'video' ? h(Video, { video }) : null,
180
+ h(Overlay, { overlay }),
181
+ )
182
+ }
@@ -48,6 +48,7 @@
48
48
  import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
49
49
  import { resolveRecordsService } from '@uniweb/core/records-service'
50
50
  import { createDefaultFetcher } from './default-fetcher.js'
51
+ import { requireTransport } from './require-transport.js'
51
52
 
52
53
  /**
53
54
  * Ask a site's records service for every record its declared queries return.
@@ -56,7 +57,8 @@ import { createDefaultFetcher } from './default-fetcher.js'
56
57
  * @param {Object} options
57
58
  * @param {string} options.locale - the locale to ask in; it is a route segment
58
59
  * of the service's address, so there is no asking without one
59
- * @param {Function} options.fetch - the transport, `(url, init) => Response`
60
+ * @param {Function} options.fetch - the transport, `(url, init) => Response`. Required: a missing
61
+ * one is refused, never replaced by the ambient `fetch` (`require-transport.js`)
60
62
  * @param {AbortSignal} [options.signal]
61
63
  * @param {string[]} [options.only] - restrict to these query names
62
64
  * @param {boolean} [options.whole=false] - what to ask for. `false` is the brief a
@@ -84,6 +86,10 @@ export async function collectSiteRecords(
84
86
  content,
85
87
  { locale, fetch, signal, only = null, whole = false, maxPages } = {},
86
88
  ) {
89
+ // ⛔ Before the empties below: those are states of a site, while a lost transport is the caller's
90
+ // wiring, and a site with no live records must not hide it.
91
+ requireTransport(fetch, 'collectSiteRecords')
92
+
87
93
  const config = content?.config
88
94
  const services = config?.services ?? null
89
95
  const queries = config?.queries ?? null
@@ -1,311 +1,36 @@
1
1
  /**
2
2
  * Background
3
3
  *
4
- * Renders section backgrounds (color, gradient, image, video) with optional overlay.
5
- * Positioned absolutely behind content with proper z-index stacking.
6
- *
7
- * @module @uniweb/runtime/components/Background
8
- */
9
-
10
- import React from 'react'
11
- import { siteUrl, withOpacity } from '../background-shared.js'
12
-
13
- /**
14
- * Background modes
15
- */
16
- const MODES = {
17
- COLOR: 'color',
18
- GRADIENT: 'gradient',
19
- IMAGE: 'image',
20
- VIDEO: 'video',
21
- }
22
-
23
- /**
24
- * Default overlay colors
25
- */
26
- const OVERLAY_COLORS = {
27
- light: 'rgba(255, 255, 255, 0.5)',
28
- dark: 'rgba(0, 0, 0, 0.5)',
29
- }
30
-
31
- /**
32
- * Render gradient overlay
33
- */
34
- function GradientOverlay({ gradient, opacity = 0.5 }) {
35
- const {
36
- start = 'rgba(0,0,0,0.7)',
37
- end = 'rgba(0,0,0,0)',
38
- angle = 180,
39
- startPosition = 0,
40
- endPosition = 100,
41
- } = gradient
42
-
43
- const style = {
44
- position: 'absolute',
45
- inset: 0,
46
- background: `linear-gradient(${angle}deg, ${start} ${startPosition}%, ${end} ${endPosition}%)`,
47
- opacity,
48
- pointerEvents: 'none',
49
- }
50
-
51
- return <div className="background-overlay background-overlay--gradient" style={style} aria-hidden="true" />
52
- }
53
-
54
- /**
55
- * Render solid overlay
56
- */
57
- function SolidOverlay({ type = 'dark', opacity = 0.5 }) {
58
- const baseColor = type === 'light' ? '255, 255, 255' : '0, 0, 0'
59
-
60
- const style = {
61
- position: 'absolute',
62
- inset: 0,
63
- backgroundColor: `rgba(${baseColor}, ${opacity})`,
64
- pointerEvents: 'none',
65
- }
66
-
67
- return <div className="background-overlay background-overlay--solid" style={style} aria-hidden="true" />
68
- }
69
-
70
- /**
71
- * Render overlay (gradient or solid)
72
- */
73
- function Overlay({ overlay }) {
74
- if (!overlay?.enabled) return null
75
-
76
- if (overlay.gradient) {
77
- return <GradientOverlay gradient={overlay.gradient} opacity={overlay.opacity} />
78
- }
79
-
80
- return <SolidOverlay type={overlay.type} opacity={overlay.opacity} />
81
- }
82
-
83
- /**
84
- * Color background
85
- */
86
- function ColorBackground({ color }) {
87
- if (!color) return null
88
-
89
- const style = {
90
- position: 'absolute',
91
- inset: 0,
92
- backgroundColor: color,
93
- }
94
-
95
- return <div className="background-color" style={style} aria-hidden="true" />
96
- }
97
-
98
- /**
99
- * Gradient background
100
- */
101
- function GradientBackground({ gradient }) {
102
- if (!gradient) return null
103
-
104
- // Raw CSS gradient string (e.g., "linear-gradient(to bottom, #000, #333)")
105
- if (typeof gradient === 'string') {
106
- const style = { position: 'absolute', inset: 0, background: gradient }
107
- return <div className="background-gradient" style={style} aria-hidden="true" />
108
- }
109
-
110
- const {
111
- start = 'transparent',
112
- end = 'transparent',
113
- angle = 0,
114
- startPosition = 0,
115
- endPosition = 100,
116
- startOpacity = 1,
117
- endOpacity = 1,
118
- } = gradient
119
-
120
- // Convert colors to rgba if opacity is specified
121
- const startColor = startOpacity < 1 ? withOpacity(start, startOpacity) : start
122
- const endColor = endOpacity < 1 ? withOpacity(end, endOpacity) : end
123
-
124
- const style = {
125
- position: 'absolute',
126
- inset: 0,
127
- background: `linear-gradient(${angle}deg, ${startColor} ${startPosition}%, ${endColor} ${endPosition}%)`,
128
- }
129
-
130
- return <div className="background-gradient" style={style} aria-hidden="true" />
131
- }
132
-
133
- /**
134
- * Image background
4
+ * A section's background in the browser: the one the prerender draws (`../background.js`, one copy
5
+ * for both renderers), plus the one thing only a browser does — playing a video background once it
6
+ * has mounted.
135
7
  */
136
- function ImageBackground({ image }) {
137
- if (!image?.src) return null
138
-
139
- const {
140
- src,
141
- position = 'center',
142
- size = 'cover',
143
- lazy = true,
144
- } = image
145
-
146
- const style = {
147
- position: 'absolute',
148
- inset: 0,
149
- backgroundImage: `url(${siteUrl(src)})`,
150
- backgroundPosition: position,
151
- backgroundSize: size,
152
- backgroundRepeat: 'no-repeat',
153
- }
154
8
 
155
- // For lazy loading, we could use an img tag with loading="lazy"
156
- // But for backgrounds, CSS is more appropriate
157
- // The lazy prop could be used for future intersection observer optimization
9
+ import React, { useEffect, useState } from 'react'
10
+ import { Background as Layer, VideoPoster, VideoElement, videoMoves } from '../background.js'
158
11
 
159
- return <div className="background-image" style={style} aria-hidden="true" />
160
- }
161
-
162
- /**
163
- * Check if user prefers reduced motion
164
- */
12
+ /** The visitor's `prefers-reduced-motion`; false where it cannot be read (old webviews, no DOM). */
165
13
  function prefersReducedMotion() {
166
- if (typeof window === 'undefined') return false
14
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
167
15
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches
168
16
  }
169
17
 
170
18
  /**
171
- * Video background
172
- *
173
- * Supports multiple source formats with automatic fallback.
174
- * Respects prefers-reduced-motion by showing poster image instead.
175
- */
176
- function VideoBackground({ video }) {
177
- if (!video?.src) return null
178
-
179
- const {
180
- src,
181
- sources, // Array of { src, type } for multiple formats
182
- poster,
183
- loop = true,
184
- muted = true,
185
- } = video
186
-
187
- // Respect reduced motion preference - show poster image instead
188
- if (prefersReducedMotion() && poster) {
189
- return <ImageBackground image={{ src: poster, size: 'cover', position: 'center' }} />
190
- }
191
-
192
- const style = {
193
- position: 'absolute',
194
- inset: 0,
195
- width: '100%',
196
- height: '100%',
197
- objectFit: 'cover',
198
- }
199
-
200
- // Build source list: explicit sources array, or infer from src
201
- const sourceList = (sources || inferSources(src)).map(s => ({
202
- ...s,
203
- src: siteUrl(s.src)
204
- }))
205
-
206
- return (
207
- <video
208
- className="background-video"
209
- style={style}
210
- autoPlay
211
- loop={loop}
212
- muted={muted}
213
- playsInline
214
- poster={siteUrl(poster)}
215
- aria-hidden="true"
216
- >
217
- {sourceList.map(({ src: sourceSrc, type }, index) => (
218
- <source key={index} src={sourceSrc} type={type} />
219
- ))}
220
- </video>
221
- )
222
- }
223
-
224
- /**
225
- * Infer multiple source formats from a single src
226
- *
227
- * If given "video.mp4", also tries "video.webm" (better compression)
228
- * Browser will use first supported format
19
+ * A video background: its poster first — the prerendered page's, so the render that replaces that
20
+ * page draws the same — and the playing video once mounted (`videoMoves`).
229
21
  */
230
- function inferSources(src) {
231
- const sources = []
232
- const ext = src.split('.').pop()?.toLowerCase()
233
- const basePath = src.slice(0, src.lastIndexOf('.'))
234
-
235
- // Prefer webm (better compression), fall back to original
236
- if (ext === 'mp4') {
237
- sources.push({ src: `${basePath}.webm`, type: 'video/webm' })
238
- sources.push({ src, type: 'video/mp4' })
239
- } else if (ext === 'webm') {
240
- sources.push({ src, type: 'video/webm' })
241
- sources.push({ src: `${basePath}.mp4`, type: 'video/mp4' })
242
- } else {
243
- // Single source for other formats
244
- sources.push({ src, type: getVideoMimeType(src) })
245
- }
246
-
247
- return sources
22
+ function MovingVideo({ video }) {
23
+ const [moving, setMoving] = useState(false)
24
+ useEffect(() => {
25
+ setMoving(videoMoves(video, prefersReducedMotion()))
26
+ }, [video])
27
+ return moving ? <VideoElement video={video} /> : <VideoPoster video={video} />
248
28
  }
249
29
 
250
30
  /**
251
- * Get video MIME type from URL
31
+ * @param {Object} props - `block.background`'s fields (`mode`, `color`, `gradient`, `image`, `video`,
32
+ * `overlay`), and a `className` for the layer
252
33
  */
253
- function getVideoMimeType(src) {
254
- if (src.endsWith('.webm')) return 'video/webm'
255
- if (src.endsWith('.ogg') || src.endsWith('.ogv')) return 'video/ogg'
256
- return 'video/mp4'
34
+ export default function Background(props) {
35
+ return <Layer {...props} Video={MovingVideo} />
257
36
  }
258
-
259
- /**
260
- * Background component
261
- *
262
- * @param {Object} props
263
- * @param {string} props.mode - Background mode: 'color', 'gradient', 'image', 'video'
264
- * @param {string} props.color - Color value (for color mode)
265
- * @param {Object} props.gradient - Gradient configuration
266
- * @param {Object} props.image - Image configuration
267
- * @param {Object} props.video - Video configuration
268
- * @param {Object} props.overlay - Overlay configuration
269
- * @param {string} props.className - Additional CSS class
270
- */
271
- export default function Background({
272
- mode,
273
- color,
274
- gradient,
275
- image,
276
- video,
277
- overlay,
278
- className = '',
279
- }) {
280
- // No background to render
281
- if (!mode) return null
282
-
283
- const containerStyle = {
284
- position: 'absolute',
285
- inset: 0,
286
- overflow: 'hidden',
287
- zIndex: 0,
288
- }
289
-
290
- return (
291
- <div
292
- className={`background background--${mode} ${className}`.trim()}
293
- style={containerStyle}
294
- aria-hidden="true"
295
- >
296
- {/* Render background based on mode */}
297
- {mode === MODES.COLOR && <ColorBackground color={color} />}
298
- {mode === MODES.GRADIENT && <GradientBackground gradient={gradient} />}
299
- {mode === MODES.IMAGE && <ImageBackground image={image} />}
300
- {mode === MODES.VIDEO && <VideoBackground video={video} />}
301
-
302
- {/* Overlay on top of background */}
303
- <Overlay overlay={overlay} />
304
- </div>
305
- )
306
- }
307
-
308
- /**
309
- * Export background modes for external use
310
- */
311
- export { MODES as BackgroundModes }
@@ -12,6 +12,7 @@ import { prepareProps, getComponentMeta } from '../prepare-props.js'
12
12
  import Background from './Background.jsx'
13
13
  import { sectionStickyProps, sectionScrollMargin } from '../sticky.js'
14
14
  import { noteBodySection } from '../sticky-top.js'
15
+ import { noteSectionWrapper } from '../sticky-check.js'
15
16
 
16
17
  /**
17
18
  * Valid color contexts
@@ -172,8 +173,15 @@ export default function BlockRenderer({ block, as = 'section', stickySelf = fals
172
173
  // On a page whose layout has a sticky area, a link to this section lands below it.
173
174
  const margin = sectionScrollMargin(block, stickySelf)
174
175
  if (margin) wrapperProps.style = { ...wrapperProps.style, ...margin }
175
- // A body section's element, for the measurement of what is stuck over the body (`../sticky-top.js`).
176
- if (stickySelf === 'section' || stickySelf === true) wrapperProps.ref = (element) => noteBodySection(block, element)
176
+ // A body section's element, for the measurement of what is stuck over the body (`../sticky-top.js`);
177
+ // in dev, every section's, so `uniweb dev`'s sticky check knows a component's outermost element.
178
+ const bodySection = stickySelf === 'section' || stickySelf === true
179
+ if (bodySection || import.meta.env?.DEV) {
180
+ wrapperProps.ref = (element) => {
181
+ if (bodySection) noteBodySection(block, element)
182
+ if (import.meta.env?.DEV) noteSectionWrapper(element)
183
+ }
184
+ }
177
185
 
178
186
  // Merge Component.className (static classes declared on the component function)
179
187
  // Order: context-{theme} + block.state.className + Component.className
@@ -117,9 +117,10 @@ export default function Layout({ page, website }) {
117
117
  useEffect(() => watchStickyTop(document, bodySectionOf(bodyBlocks?.[0])), [page])
118
118
 
119
119
  // `uniweb dev` names a sticky element that cannot move — a component's own `sticky`, an area a
120
- // layout wraps in an element of its own, a box that clips. Dropped from a published page's runtime.
120
+ // layout wraps in an element of its own, a box that clips — measured once the page's data is in.
121
+ // Dropped from a published page's runtime.
121
122
  useEffect(() => {
122
- if (import.meta.env?.DEV) return warnUnmovableSticky()
123
+ if (import.meta.env?.DEV) return warnUnmovableSticky(document, { settled: () => website.dataStore.whenIdle() })
123
124
  }, [page])
124
125
 
125
126
  // Use foundation's custom Layout if provided
@@ -246,8 +246,10 @@ export default function PageRenderer() {
246
246
  )
247
247
  }
248
248
 
249
- // Wait for content before rendering blocks (split page content).
250
- // The prerendered HTML is already visible while we load.
249
+ // Wait for content before rendering blocks (split page content): reached after an in-app
250
+ // navigation to a page not loaded yet, and it renders nothing until the sections arrive. Not at
251
+ // boot — the page a visit starts on carries its own sections (`splitPageSections`) — and never
252
+ // over the prerendered HTML: the browser's first commit replaces it (`createRoot`, index.jsx).
251
253
  if (!contentReady) return null
252
254
 
253
255
  const appearance = website?.themeData?.appearance
@@ -70,7 +70,10 @@ import {
70
70
  * fetches outside a browser (an SSR isolate) decides how a site-relative
71
71
  * address is dispatched — through its own origin or a service binding — and
72
72
  * hands that in. Defaults to the global `fetch`, resolved at call time so a
73
- * test stub installed later is honoured.
73
+ * test stub installed later is honoured. ⛔ The entries a host calls
74
+ * (`loadPageData`, `prefetchAndHydrate`, `collectSiteRecords`) never reach this
75
+ * default: they refuse a missing transport (`require-transport.js`). It serves
76
+ * the browser and a harness that builds its own fetcher.
74
77
  * @returns {{ cacheKey: (req: Object) => string, resolve: (req: Object, ctx: Object) => Promise<{ data, error?, meta? }> }}
75
78
  */
76
79
  export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchImpl = null } = {}) {