@uniweb/runtime 1.2.0 → 1.2.2
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/dist/ssr.js +86 -106
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/background-shared.js +16 -10
- package/src/background.js +182 -0
- package/src/components/Background.jsx +19 -294
- package/src/components/PageRenderer.jsx +4 -2
- package/src/load-page-data.js +13 -2
- package/src/setup.js +2 -1
- package/src/ssr-renderer.js +26 -122
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
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.
|
|
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.
|
|
47
|
+
"@uniweb/build": "1.9.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/background-shared.js
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
*
|
|
5
5
|
* ## Why this module exists
|
|
6
6
|
*
|
|
7
|
-
* Backgrounds
|
|
8
|
-
*
|
|
9
|
-
* only
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
|
@@ -1,311 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Background
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
*
|
|
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
|
|
254
|
-
|
|
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 }
|
|
@@ -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
|
-
//
|
|
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
|
package/src/load-page-data.js
CHANGED
|
@@ -68,7 +68,8 @@ function blocksOf(page) {
|
|
|
68
68
|
* page asks for the site's and its parent's data and reading each address once is the difference
|
|
69
69
|
* between N reads and N×M. ⛔ Not for a host serving requests: there a page's data is asked
|
|
70
70
|
* fresh, and the default (a cache per call) is what does that. A shared cache changes nothing
|
|
71
|
-
* about what is ASKED or returned — every request is still recorded, hit or miss
|
|
71
|
+
* about what is ASKED or returned — every request is still recorded, hit or miss, and whether a
|
|
72
|
+
* record is held whole already is answered from this call's own requests, never the cache's.
|
|
72
73
|
* @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: *, meta?: Object, error?: string }>>}
|
|
73
74
|
* one entry per request the render will make, keyed downstream by `deriveCacheKey(config)`
|
|
74
75
|
*/
|
|
@@ -117,9 +118,16 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
|
|
|
117
118
|
if (!entries.has(key)) entries.set(key, entry)
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
// ⭐ WHAT THIS CALL'S OWN REQUESTS ANSWERED — the record index the plan asks "held whole
|
|
122
|
+
// already?" before it asks for a record (R1). ⛔ Not the cache's: a shared one holds the records
|
|
123
|
+
// other pages fetched, and a record found there was never asked for, so never recorded, and the
|
|
124
|
+
// page carried nothing for it (2026-10-25). With a cache of this call's own, the two hold the
|
|
125
|
+
// same records.
|
|
126
|
+
const answered = new DataStore()
|
|
127
|
+
|
|
120
128
|
const recording = {
|
|
121
129
|
peek: (request, ctx) => dispatcher.peek(request, ctx),
|
|
122
|
-
peekRecord: (id) =>
|
|
130
|
+
peekRecord: (id) => answered.getRecord(id),
|
|
123
131
|
dispatch: async (request, ctx) => {
|
|
124
132
|
if (prerender === 'author' && request?.prerender === false) {
|
|
125
133
|
// The author deferred this one to the browser and the caller honours that. Present, so a
|
|
@@ -128,6 +136,9 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
|
|
|
128
136
|
return { data: null }
|
|
129
137
|
}
|
|
130
138
|
const result = await dispatcher.dispatch(request, ctx)
|
|
139
|
+
if (!result?.error && result?.data != null) {
|
|
140
|
+
answered.set(deriveCacheKey(request), result.meta ? { data: result.data, meta: result.meta } : { data: result.data })
|
|
141
|
+
}
|
|
131
142
|
record(request, result?.error
|
|
132
143
|
? { config: request, outcome: 'failed', data: null, error: result.error }
|
|
133
144
|
: { config: request, outcome: 'fetched', data: result?.data ?? null, ...(result?.meta ? { meta: result.meta } : {}) })
|
package/src/setup.js
CHANGED
|
@@ -291,7 +291,8 @@ export function initUniweb({ content, foundation, extensions = [], routingCompon
|
|
|
291
291
|
|
|
292
292
|
uniweb.childBlockRenderer = ChildBlocks
|
|
293
293
|
// The same background a section gets, for kit's `SectionBackground` — a component that
|
|
294
|
-
// paints its own (`background: 'self'`). The
|
|
294
|
+
// paints its own (`background: 'self'`). The prerender sets the same one (`ssr-renderer.js`):
|
|
295
|
+
// one implementation (`background.js`), of which this is the browser's, which plays a video.
|
|
295
296
|
uniweb.backgroundRenderer = Background
|
|
296
297
|
|
|
297
298
|
// L2 (singleton wiring): defaultInsets, xref.build(), and any future
|