@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/dist/ssr.js +138 -148
- 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/collect-records.js +7 -1
- package/src/components/Background.jsx +19 -294
- package/src/components/BlockRenderer.jsx +10 -2
- package/src/components/Layout.jsx +3 -2
- package/src/components/PageRenderer.jsx +4 -2
- package/src/default-fetcher.js +4 -1
- package/src/hooks/useHeadMeta.js +42 -7
- package/src/load-page-data.js +6 -1
- package/src/page-renderer.js +21 -40
- package/src/prepare-props.js +31 -19
- package/src/require-transport.js +35 -0
- package/src/setup.js +2 -1
- package/src/ssr-renderer.js +86 -161
- package/src/sticky-check.js +106 -33
- package/src/sticky.js +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.1
|
|
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.
|
|
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
|
+
}
|
package/src/collect-records.js
CHANGED
|
@@ -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
|
-
*
|
|
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 }
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
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/default-fetcher.js
CHANGED
|
@@ -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 } = {}) {
|