@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.
@@ -2,11 +2,13 @@
2
2
  * SSR Renderer
3
3
  *
4
4
  * Hook-free rendering pipeline for SSG (build) and server-side SSR.
5
- * Mirrors BlockRenderer.jsx + Background.jsx using React.createElement
6
- * directly — no hooks, no JSX, no browser APIs.
5
+ * Mirrors BlockRenderer.jsx using React.createElement directly — no hooks,
6
+ * no JSX, no browser APIs. A section's background and the built-in layout are
7
+ * not mirrored: both renderers draw the one copy (`background.js`,
8
+ * `default-layout.js`).
7
9
  *
8
10
  * This is the single source of truth for how blocks render during prerender.
9
- * When modifying BlockRenderer.jsx or Background.jsx, update this file to match.
11
+ * When modifying BlockRenderer.jsx, update `renderBlock` here to match.
10
12
  *
11
13
  * Exports three layers:
12
14
  * 1. Rendering functions (renderBlock, renderBlocks, renderLayout, renderBackground)
@@ -18,7 +20,7 @@ import React from 'react'
18
20
  import { renderToString } from 'react-dom/server'
19
21
  import { createUniweb, resolveDefaultLocale } from '@uniweb/core'
20
22
  import { sectionDomId } from '@uniweb/core/section-id'
21
- import { siteUrl, withOpacity } from './background-shared.js'
23
+ import { Background } from './background.js'
22
24
  import { routePatternToRegex, isDynamicRoute } from '@uniweb/core/route-match'
23
25
  import { DEFAULT_ICON_BASE, iconUrl } from '@uniweb/core/icon-corpus'
24
26
  import { buildSectionOverrides, FONT_LINKS_MARKER } from '@uniweb/theming'
@@ -95,123 +97,17 @@ export function getWrapperProps(block) {
95
97
 
96
98
 
97
99
  /**
98
- * Render a background element for SSR.
99
- * Mirrors Background.jsx (color, gradient, image — not video).
100
- * Video backgrounds require JS for autoplay and are skipped during SSR.
100
+ * Render a section's background for SSR: the one implementation both renderers draw
101
+ * (`background.js`). A video background is drawn as its poster — what the browser draws too,
102
+ * until it has mounted and starts the video.
103
+ * ⛔ Until 2026-10-25 this was a copy of `Background.jsx`, and drew no video at all.
104
+ *
105
+ * @param {Object} background - `block.background`, normalized by core
106
+ * @returns {React.ReactElement|null}
101
107
  */
102
108
  export function renderBackground(background) {
103
109
  if (!background?.mode) return null
104
-
105
- const containerStyle = {
106
- position: 'absolute',
107
- inset: '0',
108
- overflow: 'hidden',
109
- zIndex: 0,
110
- }
111
-
112
- const children = []
113
-
114
- // Color background
115
- if (background.mode === 'color' && background.color) {
116
- children.push(
117
- React.createElement('div', {
118
- key: 'bg-color',
119
- className: 'background-color',
120
- style: { position: 'absolute', inset: '0', backgroundColor: background.color },
121
- 'aria-hidden': 'true',
122
- })
123
- )
124
- }
125
-
126
- // Gradient background (supports string or object with opacity)
127
- if (background.mode === 'gradient' && background.gradient) {
128
- const g = background.gradient
129
-
130
- let bgValue
131
- if (typeof g === 'string') {
132
- bgValue = g
133
- } else {
134
- const {
135
- start = 'transparent',
136
- end = 'transparent',
137
- angle = 0,
138
- startPosition = 0,
139
- endPosition = 100,
140
- startOpacity = 1,
141
- endOpacity = 1,
142
- } = g
143
- const startColor = startOpacity < 1 ? withOpacity(start, startOpacity) : start
144
- const endColor = endOpacity < 1 ? withOpacity(end, endOpacity) : end
145
- bgValue = `linear-gradient(${angle}deg, ${startColor} ${startPosition}%, ${endColor} ${endPosition}%)`
146
- }
147
-
148
- children.push(
149
- React.createElement('div', {
150
- key: 'bg-gradient',
151
- className: 'background-gradient',
152
- style: { position: 'absolute', inset: '0', background: bgValue },
153
- 'aria-hidden': 'true',
154
- })
155
- )
156
- }
157
-
158
- // Image background
159
- if (background.mode === 'image' && background.image?.src) {
160
- const img = background.image
161
- children.push(
162
- React.createElement('div', {
163
- key: 'bg-image',
164
- className: 'background-image',
165
- style: {
166
- position: 'absolute',
167
- inset: '0',
168
- backgroundImage: `url(${siteUrl(img.src)})`,
169
- backgroundPosition: img.position || 'center',
170
- backgroundSize: img.size || 'cover',
171
- backgroundRepeat: 'no-repeat',
172
- },
173
- 'aria-hidden': 'true',
174
- })
175
- )
176
- }
177
-
178
- // Overlay (gradient or solid)
179
- if (background.overlay?.enabled) {
180
- const ov = background.overlay
181
- let overlayStyle
182
-
183
- if (ov.gradient) {
184
- const g = ov.gradient
185
- overlayStyle = {
186
- position: 'absolute', inset: '0', pointerEvents: 'none',
187
- background: `linear-gradient(${g.angle || 180}deg, ${g.start || 'rgba(0,0,0,0.7)'} ${g.startPosition || 0}%, ${g.end || 'rgba(0,0,0,0)'} ${g.endPosition || 100}%)`,
188
- opacity: ov.opacity ?? 0.5,
189
- }
190
- } else {
191
- const baseColor = ov.type === 'light' ? '255, 255, 255' : '0, 0, 0'
192
- overlayStyle = {
193
- position: 'absolute', inset: '0', pointerEvents: 'none',
194
- backgroundColor: `rgba(${baseColor}, ${ov.opacity ?? 0.5})`,
195
- }
196
- }
197
-
198
- children.push(
199
- React.createElement('div', {
200
- key: 'bg-overlay',
201
- className: ov.gradient ? 'background-overlay background-overlay--gradient' : 'background-overlay background-overlay--solid',
202
- style: overlayStyle,
203
- 'aria-hidden': 'true',
204
- })
205
- )
206
- }
207
-
208
- if (children.length === 0) return null
209
-
210
- return React.createElement('div', {
211
- className: `background background--${background.mode}`,
212
- style: containerStyle,
213
- 'aria-hidden': 'true',
214
- }, ...children)
110
+ return React.createElement(Background, background)
215
111
  }
216
112
 
217
113
  /**
@@ -244,11 +140,20 @@ export function renderBlock(block, { as = 'section', stickySelf = false } = {})
244
140
  const meta = getComponentMeta(block.type)
245
141
  const entityStore = block.website?.entityStore
246
142
  let entityData = null
143
+ let pending = false
247
144
  if (entityStore) {
248
145
  const resolved = entityStore.resolve(block, meta)
249
146
  if (resolved.status === 'ready') entityData = resolved.data
147
+ pending = resolved.status === 'pending'
250
148
  }
251
149
 
150
+ // ⭐ A block whose data is still out is LOADING here, as in the browser, which sets the same flag
151
+ // before `prepareProps` (`BlockRenderer.jsx`) and fetches the data once it boots. So the page
152
+ // paints the component's loading state, the one the browser renders in its place.
153
+ // ⛔ Until 2026-10-25 this was never set, and such a section prerendered its EMPTY state —
154
+ // "nothing found", or an error — then switched to loading when the browser took over.
155
+ block.dataLoading = pending
156
+
252
157
  // Build content and params with runtime guarantees.
253
158
  // prepareProps handles the full pipeline: entity data merge,
254
159
  // foundation content handler invocation, guaranteed content
@@ -342,8 +247,8 @@ export function renderLayout(page, website) {
342
247
  }
343
248
 
344
249
  // Mirror Layout.jsx: wrap body + each area in a thin div carrying its
345
- // view-transition-name, so the prerendered HTML matches what the SPA hydrates
346
- // and the browser can animate regions independently on client navigation.
250
+ // view-transition-name, so the prerendered HTML matches the page the SPA renders
251
+ // in its place and the browser can animate regions independently on client navigation.
347
252
  const areaNames = Object.keys(areas)
348
253
  const transitions = website.viewTransitions
349
254
  ? resolveLayoutTransitions(areaNames, layoutMeta?.transitions)
@@ -475,10 +380,9 @@ export function initPrerender(content, foundation, extensionsOrOptions, maybeOpt
475
380
  }
476
381
 
477
382
  // The same background a section gets, for kit's `SectionBackground` — a component that
478
- // paints its own (`background: 'self'`). Mirrors the SPA's `Background` (setup.js).
479
- uniweb.backgroundRenderer = function InlineBackground(background) {
480
- return renderBackground(background)
481
- }
383
+ // paints its own (`background: 'self'`). The one implementation, which the browser draws
384
+ // too (setup.js); its `className` is the layer's. ⛔ Until 2026-10-25 this dropped it.
385
+ uniweb.backgroundRenderer = Background
482
386
 
483
387
  // L2 (singleton wiring): defaultInsets, xref.build(), and any future
484
388
  // framework-level capability bridge — shared with setup.js so both
@@ -674,7 +578,7 @@ export { escapeHtml }
674
578
  * Common operations shared by both build and cloud:
675
579
  * - Replace #root div with rendered HTML
676
580
  * - Update page title
677
- * - Add/update meta description
581
+ * - Write the page's head tags — description, canonical, hreflang, Open Graph (`writeHeadMeta`)
678
582
  * - Inject section override CSS
679
583
  * - Inject the pre-paint appearance script
680
584
  *
@@ -701,7 +605,7 @@ export function injectPageContent(html, renderedContent, page, options = {}) {
701
605
 
702
606
  // Pre-paint appearance script. Prerendered HTML carries real content styled
703
607
  // from :root (light) tokens, so a dark visitor would see a flash of light
704
- // before the bundle hydrates and applies the class. Read off the page's
608
+ // before the bundle boots and applies the class. Read off the page's
705
609
  // website back-ref rather than a parameter: every lane builds the same graph,
706
610
  // so this needs no plumbing and no per-lane opt-in. Idempotent, because
707
611
  // @uniweb/build's injectBuildData may run over this HTML afterwards.
@@ -766,45 +670,66 @@ export function injectPageContent(html, renderedContent, page, options = {}) {
766
670
  )
767
671
  }
768
672
 
769
- // Add/update meta description
770
- if (page.description) {
771
- const metaDesc = `<meta name="description" content="${escapeHtml(page.description)}">`
772
- if (result.includes('<meta name="description"')) {
773
- result = result.replace(/<meta name="description"[^>]*>/, metaDesc)
774
- } else {
775
- result = result.replace('</head>', `${metaDesc}\n</head>`)
776
- }
777
- }
778
-
779
- // Social / SEO meta from the page's effective head metadata (page seo
780
- // cascading over site-level seo — see Page.getHeadMeta). The SPA emits these
781
- // client-side via useHeadMeta; this is the SSR twin, so crawlers and social
782
- // unfurlers (which don't run JS) get them in the static HTML too.
783
- const headMeta = page.getHeadMeta?.()
784
- if (headMeta) {
785
- const og = headMeta.og || {}
786
- const keywords = Array.isArray(headMeta.keywords)
787
- ? headMeta.keywords.join(', ')
788
- : headMeta.keywords
789
- const tags = []
790
- if (keywords) tags.push(`<meta name="keywords" content="${escapeHtml(keywords)}">`)
791
- if (headMeta.robots) tags.push(`<meta name="robots" content="${escapeHtml(headMeta.robots)}">`)
792
- if (og.title) tags.push(`<meta property="og:title" content="${escapeHtml(og.title)}">`)
793
- if (og.description) tags.push(`<meta property="og:description" content="${escapeHtml(og.description)}">`)
794
- if (og.image) tags.push(`<meta property="og:image" content="${escapeHtml(og.image)}">`)
795
- if (og.url) tags.push(`<meta property="og:url" content="${escapeHtml(og.url)}">`)
796
- tags.push('<meta property="og:type" content="website">')
797
- tags.push(`<meta name="twitter:card" content="${og.image ? 'summary_large_image' : 'summary'}">`)
798
- if (og.title) tags.push(`<meta name="twitter:title" content="${escapeHtml(og.title)}">`)
799
- if (og.description) tags.push(`<meta name="twitter:description" content="${escapeHtml(og.description)}">`)
800
- if (og.image) tags.push(`<meta name="twitter:image" content="${escapeHtml(og.image)}">`)
801
- if (headMeta.canonical) tags.push(`<link rel="canonical" href="${escapeHtml(headMeta.canonical)}">`)
802
- if (tags.length) result = result.replace('</head>', `${tags.join('\n')}\n</head>`)
803
- }
673
+ // ⭐ THE PAGE'S HEAD TAGS — description, canonical, hreflang, Open Graph, Twitter — from the
674
+ // page's own head metadata (`Page#getHeadMeta`, which the browser writes the same tags from in
675
+ // `useHeadMeta`), so crawlers and social unfurlers, which run no script, read the page's own.
676
+ // ⛔ Until 2026-10-25 these were APPENDED, after the static build had written the HOME page's
677
+ // into the one shell every page is rendered into: each page named the home page its canonical
678
+ // and carried two og:title and two og:url, the home page's first.
679
+ result = writeHeadMeta(result, page)
804
680
 
805
681
  return result
806
682
  }
807
683
 
684
+ /**
685
+ * Write a page's head tags into `html`'s head. These tags are the framework's: each is set to the
686
+ * page's value, replacing any of its name the shell carries, and removed when the page has none —
687
+ * as the browser does on every navigation (`useHeadMeta`), so hydrating the page changes nothing a
688
+ * crawler read.
689
+ *
690
+ * @param {string} html
691
+ * @param {Object} page
692
+ * @returns {string}
693
+ */
694
+ function writeHeadMeta(html, page) {
695
+ // A Page states its own (`getHeadMeta`); plain page data, which callers may hand in, its fields.
696
+ const meta = typeof page?.getHeadMeta === 'function' ? page.getHeadMeta() : { description: page?.description || null }
697
+ const end = html.indexOf('</head>')
698
+ if (!meta || end === -1) return html
699
+ const og = meta.og || {}
700
+ const keywords = Array.isArray(meta.keywords) ? meta.keywords.join(', ') : meta.keywords
701
+ const metaTags = [
702
+ ['name', 'description', meta.description],
703
+ ['name', 'keywords', keywords],
704
+ ['name', 'robots', meta.robots],
705
+ ['property', 'og:type', 'website'],
706
+ ['property', 'og:site_name', og.siteName],
707
+ ['property', 'og:title', og.title],
708
+ ['property', 'og:description', og.description],
709
+ ['property', 'og:image', og.image],
710
+ ['property', 'og:url', og.url],
711
+ ['name', 'twitter:card', og.image ? 'summary_large_image' : 'summary'],
712
+ ['name', 'twitter:site', meta.twitter?.site],
713
+ ['name', 'twitter:title', og.title],
714
+ ['name', 'twitter:description', og.description],
715
+ ['name', 'twitter:image', og.image],
716
+ ]
717
+ // The head alone: the page's content is in the body, and none of it is edited here.
718
+ let head = html.slice(0, end)
719
+ const written = []
720
+ for (const [attr, key, value] of metaTags) {
721
+ head = head.replace(new RegExp(`<meta\\b[^>]*\\b(?:name|property)=["']${key}["'][^>]*>\\s*`, 'gi'), '')
722
+ if (value) written.push(`<meta ${attr}="${key}" content="${escapeHtml(value)}">`)
723
+ }
724
+ head = head.replace(/<link\b[^>]*\brel=["']canonical["'][^>]*>\s*/gi, '')
725
+ if (meta.canonical) written.push(`<link rel="canonical" href="${escapeHtml(meta.canonical)}">`)
726
+ head = head.replace(/<link\b[^>]*\bhreflang=["'][^"']*["'][^>]*>\s*/gi, '')
727
+ for (const { hreflang, url } of meta.alternates || []) {
728
+ written.push(`<link rel="alternate" hreflang="${escapeHtml(hreflang)}" href="${escapeHtml(url)}">`)
729
+ }
730
+ return `${head}${written.join('\n')}\n${html.slice(end)}`
731
+ }
732
+
808
733
  // ============================================================================
809
734
  // 404 fallback generation
810
735
  // ============================================================================
@@ -4,13 +4,23 @@
4
4
  *
5
5
  * `position: sticky` keeps an element inside its parent's box. When the parent is no taller than
6
6
  * the element (no wider, for one that sticks sideways), the element has no room and scrolls away
7
- * with the page, and nothing says so. The two ways it happens:
7
+ * with the page, and nothing says so. Whose box the parent is says what to do (`unmovableAdvice`):
8
8
  *
9
- * - a component's own `sticky top-0`: a section's markup sits in boxes as tall as itself, so it
10
- * never moves. The section's `sticky: true` asks the runtime to make sticky the box that can
11
- * (`sticky.js`).
12
- * - a layout that wraps a sticky area in an element of its own, which confines the area's box.
13
- * The layout makes its element sticky when `page.isSticky(area)`.
9
+ * - a component's outermost element — a header's own `sticky top-0` — sits in the section's
10
+ * wrapper, as tall as itself, so it never moves. The section's `sticky: true` asks the runtime
11
+ * to make sticky the box that can (`sticky.js`).
12
+ * - a box the runtime made sticky (`data-uw-sticky`) in an element a layout wraps it in, which
13
+ * confines it. The layout makes its element sticky when `page.isSticky(area)`.
14
+ * - an element inside a component sits in a box the component draws. A side panel beside a list
15
+ * sticks within the row the two share, and cannot when its box is only as tall as the panel:
16
+ * the fix is the component's, to give that box room or make it the sticky one.
17
+ *
18
+ * ⭐ MEASURED ON THE SETTLED PAGE. A box's height can wait on the page's data — a list fills its
19
+ * column once its records arrive — so the check runs once nothing is in flight (the layout passes
20
+ * `website.dataStore.whenIdle` as `settled`), after the page's fonts, in the frames after that:
21
+ * React has committed what arrived, and the browser has laid it out. ⛔ Until 2026-10-25 it
22
+ * measured in the first frame after the page mounted, while the page's fetches were still out,
23
+ * and named a side panel whose column was waiting for its records as one that could never move.
14
24
  *
15
25
  * Table parts are left out: a sticky table header cell sticks within its table, though its row is
16
26
  * no taller than it is. So is a child section the runtime made sticky (`data-uw-sticky="child"`):
@@ -25,6 +35,22 @@
25
35
 
26
36
  const warned = new WeakSet()
27
37
 
38
+ /** Every section wrapper the browser's renderer drew, noted in dev (`BlockRenderer.jsx`). */
39
+ const sectionWrappers = new WeakSet()
40
+
41
+ /** How long the check waits on the page's fetches before it measures anyway. */
42
+ const SETTLE_CAP_MS = 10_000
43
+
44
+ /**
45
+ * Note a section's wrapper, so the check can tell a component's outermost element — the one in
46
+ * it — from an element inside a component. A ref callback's argument: null on unmount.
47
+ *
48
+ * @param {Element|null} element
49
+ */
50
+ export function noteSectionWrapper(element) {
51
+ if (element) sectionWrappers.add(element)
52
+ }
53
+
28
54
  /** The nearest ancestor that draws a box — `display: contents` draws none. */
29
55
  function boxParent(el) {
30
56
  let parent = el.parentElement
@@ -73,38 +99,85 @@ export function clippingAncestor(el) {
73
99
  }
74
100
 
75
101
  /**
76
- * Warn, once per element, about each sticky element on the page that cannot move.
102
+ * What to do about a sticky element that cannot move, by whose box holds it (see above).
77
103
  *
78
- * @param {Document} [doc]
79
- * @returns {() => void} cancels a check not yet run
104
+ * @param {Element} el
105
+ * @returns {string}
80
106
  */
81
- export function warnUnmovableSticky(doc = globalThis.document) {
82
- if (!doc?.body || typeof requestAnimationFrame !== 'function') return () => {}
83
- const frame = requestAnimationFrame(() => {
84
- for (const el of doc.body.querySelectorAll('*')) {
85
- if (warned.has(el) || getComputedStyle(el).position !== 'sticky') continue
86
- const clip = clippingAncestor(el)
87
- if (clip) {
88
- warned.add(el)
89
- console.warn(
90
- '[uniweb] This element is sticky but sits in a box that clips its overflow (`overflow: hidden`, ' +
91
- 'or `overflow-x: hidden`), so it sticks to that box, which does not scroll, and never moves. ' +
92
- 'Clip with `overflow: clip` instead: it clips the same and keeps the element sticking.',
93
- el,
94
- clip,
95
- )
96
- continue
97
- }
98
- if (el.getAttribute('data-uw-sticky') === 'child' || canStick(el)) continue
107
+ export function unmovableAdvice(el) {
108
+ if (el.getAttribute('data-uw-sticky')) {
109
+ return (
110
+ 'It is the box the runtime makes sticky for a section, and an element of the layout around it ' +
111
+ 'confines it: a layout that wraps an area in an element of its own makes that element sticky ' +
112
+ 'when `page.isSticky(area)`.'
113
+ )
114
+ }
115
+ if (sectionWrappers.has(boxParent(el))) {
116
+ return (
117
+ "It is a section's outermost element, and the section's wrapper is as tall as it is. To keep the " +
118
+ 'section in view, give it `sticky: true` (its type may default it in meta.js), and the runtime ' +
119
+ 'makes sticky the box that can.'
120
+ )
121
+ }
122
+ return (
123
+ 'It sits in a box the component draws. Give that box room — let it stretch with its row rather ' +
124
+ 'than fit its content (`items-start` or `self-start` shrinks it) — or make that box the sticky one.'
125
+ )
126
+ }
127
+
128
+ /** Warn, once per element, about each sticky element on the page that cannot move. */
129
+ function checkPage(doc) {
130
+ for (const el of doc.body.querySelectorAll('*')) {
131
+ if (warned.has(el) || getComputedStyle(el).position !== 'sticky') continue
132
+ const clip = clippingAncestor(el)
133
+ if (clip) {
99
134
  warned.add(el)
100
135
  console.warn(
101
- '[uniweb] This element is sticky but cannot move: its parent is no taller than it is, so it ' +
102
- 'scrolls away with the page. To keep a section in view, give the section `sticky: true` ' +
103
- '(its type may default it in meta.js) — no box a component draws can stick. A layout that ' +
104
- 'wraps an area in an element of its own makes that element sticky when `page.isSticky(area)`.',
136
+ '[uniweb] This element is sticky but sits in a box that clips its overflow (`overflow: hidden`, ' +
137
+ 'or `overflow-x: hidden`), so it sticks to that box, which does not scroll, and never moves. ' +
138
+ 'Clip with `overflow: clip` instead: it clips the same and keeps the element sticking.',
105
139
  el,
140
+ clip,
106
141
  )
142
+ continue
107
143
  }
108
- })
109
- return () => cancelAnimationFrame(frame)
144
+ if (el.getAttribute('data-uw-sticky') === 'child' || canStick(el)) continue
145
+ warned.add(el)
146
+ console.warn(
147
+ '[uniweb] This element is sticky but cannot move: its parent is no taller than it is, so it ' +
148
+ `scrolls away with the page. ${unmovableAdvice(el)}`,
149
+ el,
150
+ )
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Check the page once it has settled: when `settled()` resolves (or after `SETTLE_CAP_MS`, so a
156
+ * fetch that never answers does not silence the check), its fonts have loaded, and two frames have
157
+ * passed — the first for React to commit what arrived, the second to lay it out.
158
+ *
159
+ * @param {Document} [doc]
160
+ * @param {{ settled?: () => Promise<unknown> }} [options] - what the page waits on: its data
161
+ * @returns {() => void} cancels a check not yet run
162
+ */
163
+ export function warnUnmovableSticky(doc = globalThis.document, { settled } = {}) {
164
+ if (!doc?.body || typeof requestAnimationFrame !== 'function') return () => {}
165
+ let cancelled = false
166
+ let frame = 0
167
+ let cap = 0
168
+ const nextFrame = () => new Promise((resolve) => { frame = requestAnimationFrame(resolve) })
169
+ const capped = new Promise((resolve) => { cap = setTimeout(resolve, SETTLE_CAP_MS) })
170
+ ;(async () => {
171
+ await Promise.race([Promise.resolve(settled?.()).catch(() => {}), capped])
172
+ clearTimeout(cap)
173
+ await doc.fonts?.ready
174
+ if (!cancelled) await nextFrame()
175
+ if (!cancelled) await nextFrame()
176
+ if (!cancelled) checkPage(doc)
177
+ })()
178
+ return () => {
179
+ cancelled = true
180
+ clearTimeout(cap)
181
+ cancelAnimationFrame(frame)
182
+ }
110
183
  }
package/src/sticky.js CHANGED
@@ -3,9 +3,11 @@
3
3
  * (`components/Layout.jsx`, `BlockRenderer.jsx`) and the prerender's (`ssr-renderer.js`) make the
4
4
  * same boxes sticky, and a prerendered page does not change when the browser renders over it.
5
5
  *
6
- * ⭐ NO BOX A COMPONENT DRAWS CAN STICK. `position: sticky` moves an element only within its
7
- * parent's box, and a component's markup sits in boxes as tall as itself: the section's wrapper,
8
- * the layout area's wrapper (`area-wrappers.js`), the built-in layout's zone (`default-layout.js`).
6
+ * ⭐ A COMPONENT'S OUTERMOST ELEMENT CANNOT STICK. `position: sticky` moves an element only within
7
+ * its parent's box, and a component sits in boxes as tall as itself: the section's wrapper, the
8
+ * layout area's wrapper (`area-wrappers.js`), the built-in layout's zone (`default-layout.js`). (An
9
+ * element inside a component can stick within a taller box the component draws — a side panel in
10
+ * the row beside a list — which is the component's own layout and asks nothing of this module.)
9
11
  * So a section asks — `sticky:`, one of a section's own settings (`@uniweb/schemas/section`),
10
12
  * defaulted by its type's `meta.js` — and the runtime makes sticky the box that can:
11
13
  *