@uniweb/runtime 1.1.13 → 1.1.14

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.13",
3
+ "version": "1.1.14",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,15 +36,15 @@
36
36
  "node": ">=22.12"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/theming": "~1.2.0",
40
- "@uniweb/core": "~1.4.0"
39
+ "@uniweb/core": "~1.4.0",
40
+ "@uniweb/theming": "~1.2.1"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@vitejs/plugin-react": "^4.5.2",
44
44
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0 || ^0.28.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "1.6.2"
47
+ "@uniweb/build": "1.7.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -180,3 +180,20 @@ export function areaWrapperStyle(region, transitions, layers, sticky = null) {
180
180
 
181
181
  return Object.keys(style).length > 0 ? style : null
182
182
  }
183
+
184
+ /**
185
+ * The props for one area's wrapper element — its style, and `data-uw-sticky="area"` when it is
186
+ * the box that sticks (`sticky.js`) — or `null` when the region needs no wrapper. Both renderers
187
+ * build the wrapper from this, so the prerendered page and the browser's agree.
188
+ *
189
+ * @param {string} region - Area name, or `body`.
190
+ * @param {Object|null} transitions - region → view-transition-name.
191
+ * @param {Object} layers - region → z-index.
192
+ * @param {{ top: string }|null} [sticky] - the area's stickiness, when its box is the one that sticks
193
+ * @returns {{ style: Object, 'data-uw-sticky'?: 'area' }|null}
194
+ */
195
+ export function areaWrapperProps(region, transitions, layers, sticky = null) {
196
+ const style = areaWrapperStyle(region, transitions, layers, sticky)
197
+ if (!style) return null
198
+ return sticky ? { style, 'data-uw-sticky': 'area' } : { style }
199
+ }
@@ -10,7 +10,8 @@ import React, { useState, useEffect } from 'react'
10
10
  import { sectionDomId } from '@uniweb/core/section-id'
11
11
  import { prepareProps, getComponentMeta } from '../prepare-props.js'
12
12
  import Background from './Background.jsx'
13
- import { sectionStickyStyle } from '../sticky.js'
13
+ import { sectionStickyProps } from '../sticky.js'
14
+ import { noteBodySection } from '../sticky-top.js'
14
15
 
15
16
  /**
16
17
  * Valid color contexts
@@ -79,7 +80,8 @@ const getWrapperProps = (block) => {
79
80
  * @param {Object} props
80
81
  * @param {Block} props.block - Block instance to render
81
82
  * @param {string|null} props.as - Wrapper element tag for section mode, or null/false for bare mode
82
- * @param {boolean} [props.stickySelf] - whether a sticky section's own wrapper sticks (in the body)
83
+ * @param {'section'|'child'|boolean} [props.stickySelf] - whether a sticky section's own wrapper is
84
+ * the box that sticks: a body section's (`'section'`), or a child section's (`'child'`)
83
85
  */
84
86
  export default function BlockRenderer({ block, as = 'section', stickySelf = false }) {
85
87
  const Component = block.initComponent()
@@ -161,9 +163,14 @@ export default function BlockRenderer({ block, as = 'section', stickySelf = fals
161
163
  // Section mode — full treatment with wrapper, context classes, background.
162
164
  const { background, ...wrapperProps } = getWrapperProps(block)
163
165
 
164
- // In the body, a sticky section's wrapper is the box that stays in view (`../sticky.js`).
165
- const sticky = stickySelf ? sectionStickyStyle(block) : null
166
- if (sticky) wrapperProps.style = { ...wrapperProps.style, ...sticky }
166
+ // A body section's wrapper, or a child section's, is the box that stays in view (`../sticky.js`).
167
+ const sticky = sectionStickyProps(block, stickySelf)
168
+ if (sticky) {
169
+ wrapperProps.style = { ...wrapperProps.style, ...sticky.style }
170
+ wrapperProps['data-uw-sticky'] = sticky['data-uw-sticky']
171
+ }
172
+ // A body section's element, for the measurement of what is stuck over the body (`../sticky-top.js`).
173
+ if (stickySelf === 'section' || stickySelf === true) wrapperProps.ref = (element) => noteBodySection(block, element)
167
174
 
168
175
  // Merge Component.className (static classes declared on the component function)
169
176
  // Order: context-{theme} + block.state.className + Component.className
@@ -19,8 +19,8 @@ import BlockRenderer from './BlockRenderer.jsx'
19
19
  /**
20
20
  * @param {Object} props
21
21
  * @param {Block[]} props.blocks
22
- * @param {boolean} [props.stickySelf] - a sticky section's own wrapper is the box that sticks:
23
- * true in the body; an area's sections leave it to the area's box (`../sticky.js`)
22
+ * @param {'section'|false} [props.stickySelf] - a sticky section's own wrapper is the box that
23
+ * sticks: `'section'` in the body; an area's sections leave it to the area's box (`../sticky.js`)
24
24
  */
25
25
  export default function Blocks({ blocks, stickySelf = false }) {
26
26
  if (!blocks || blocks.length === 0) return null
@@ -33,9 +33,10 @@
33
33
 
34
34
  import { useEffect } from 'react'
35
35
  import Blocks from './Blocks.jsx'
36
- import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperStyle } from '../area-wrappers.js'
37
- import { layoutStickiness, stickyStyle } from '../sticky.js'
36
+ import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from '../area-wrappers.js'
37
+ import { layoutStickiness, zoneProps } from '../sticky.js'
38
38
  import { warnUnmovableSticky } from '../sticky-check.js'
39
+ import { watchStickyTop, bodySectionOf } from '../sticky-top.js'
39
40
  // The built-in layout — one copy, which the prerender renders too (`../default-layout.js`).
40
41
  import { DefaultLayout } from '../default-layout.js'
41
42
 
@@ -98,21 +99,25 @@ export default function Layout({ page, website }) {
98
99
  const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
99
100
 
100
101
  const wrap = (region, children) => {
101
- const style = areaWrapperStyle(region, transitions, layers, RemoteLayout ? sticky[region] : null)
102
- return style ? <div style={style}>{children}</div> : children
102
+ const props = areaWrapperProps(region, transitions, layers, RemoteLayout ? sticky[region] : null)
103
+ return props ? <div {...props}>{children}</div> : children
103
104
  }
104
105
 
105
106
  // A body section's own wrapper is the box that sticks for it; an area's sections leave that to
106
107
  // the area's box.
107
- const bodyElement = bodyBlocks ? wrap('body', <Blocks blocks={bodyBlocks} stickySelf />) : null
108
+ const bodyElement = bodyBlocks ? wrap('body', <Blocks blocks={bodyBlocks} stickySelf="section" />) : null
108
109
 
109
110
  const areaElements = {}
110
111
  for (const [name, blocks] of Object.entries(areas)) {
111
112
  areaElements[name] = wrap(name, <Blocks blocks={blocks} />)
112
113
  }
113
114
 
114
- // `uniweb dev` names a sticky element that cannot move — a component's own `sticky`, or an area
115
- // a layout wraps in an element of its own. Dropped from a published page's runtime.
115
+ // How far down the stuck chrome reaches — `--uw-sticky-top`, where a sticky section with no
116
+ // `top` of its own stays, and where kit's anchors land (`../sticky-top.js`).
117
+ useEffect(() => watchStickyTop(document, bodySectionOf(bodyBlocks?.[0])), [page])
118
+
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.
116
121
  useEffect(() => {
117
122
  if (import.meta.env?.DEV) return warnUnmovableSticky()
118
123
  }, [page])
@@ -134,7 +139,7 @@ export default function Layout({ page, website }) {
134
139
  }
135
140
 
136
141
  // Default layout — a sticky area's zone is the box that sticks.
137
- const zone = (name) => (sticky[name] ? stickyStyle(sticky[name].top, layers?.[name]) : null)
142
+ const zone = (name) => zoneProps(sticky[name], layers?.[name])
138
143
  return (
139
144
  <DefaultLayout
140
145
  body={bodyElement}
@@ -45,7 +45,7 @@ export function ChildBlocks({ blocks, from, wrapAs }) {
45
45
 
46
46
  return blockList.map((childBlock, index) => (
47
47
  <React.Fragment key={childBlock.id || index}>
48
- <BlockRenderer block={childBlock} as={wrapAs || null} />
48
+ <BlockRenderer block={childBlock} as={wrapAs || null} stickySelf={wrapAs ? 'child' : false} />
49
49
  </React.Fragment>
50
50
  ))
51
51
  }
@@ -21,9 +21,9 @@
21
21
  * 2026-07-31: `layers: { header: 0 }` on the default layout changed nothing.
22
22
  *
23
23
  * ⭐ A ZONE IS THE BOX THAT STICKS. When a section of the header (or footer) area is sticky, the
24
- * runtime hands this layout the zone's style (`zones`): its parent is the page column, so it can
25
- * stay in view, where the area wrapper inside it, as tall as itself, cannot (`sticky.js`). The
26
- * style carries the area's layer, which the sticky zone then holds.
24
+ * runtime hands this layout the zone's props (`zones`, from `zoneProps`): its parent is the page
25
+ * column, so it can stay in view, where the area wrapper inside it, as tall as itself, cannot
26
+ * (`sticky.js`). The style carries the area's layer, which the sticky zone then holds.
27
27
  */
28
28
 
29
29
  import React from 'react'
@@ -36,16 +36,15 @@ const GROW = { flex: 1 }
36
36
  * @param {React.ReactNode} [props.header]
37
37
  * @param {React.ReactNode} [props.body]
38
38
  * @param {React.ReactNode} [props.footer]
39
- * @param {{ header?: Object, footer?: Object }} [props.zones] - a zone's style, when it sticks
39
+ * @param {{ header?: Object, footer?: Object }} [props.zones] - a zone's props, when it sticks
40
40
  * @returns {React.ReactElement}
41
41
  */
42
42
  export function DefaultLayout({ header, body, footer, zones }) {
43
- const zone = (name) => (zones?.[name] ? { style: zones[name] } : null)
44
43
  return React.createElement(
45
44
  'div',
46
45
  { style: COLUMN },
47
- header && React.createElement('header', zone('header'), header),
46
+ header && React.createElement('header', zones?.header || null, header),
48
47
  body && React.createElement('main', { style: GROW }, body),
49
- footer && React.createElement('footer', zone('footer'), footer),
48
+ footer && React.createElement('footer', zones?.footer || null, footer),
50
49
  )
51
50
  }
@@ -30,8 +30,8 @@ import {
30
30
  hydrateDataStore,
31
31
  ensureThemeCss,
32
32
  } from './wire-foundation.js'
33
- import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperStyle } from './area-wrappers.js'
34
- import { layoutStickiness, sectionStickyStyle, stickyStyle } from './sticky.js'
33
+ import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from './area-wrappers.js'
34
+ import { layoutStickiness, sectionStickyProps, zoneProps } from './sticky.js'
35
35
  import { DefaultLayout } from './default-layout.js'
36
36
  import { renderAppearanceBootScript } from './appearance.js'
37
37
  import { documentTitle } from './document-title.js'
@@ -224,7 +224,8 @@ export function renderBackground(background) {
224
224
  * @param {Block} block - Block instance to render
225
225
  * @param {Object} [options]
226
226
  * @param {string|null} [options.as='section'] - Wrapper element tag, or null/false for bare mode
227
- * @param {boolean} [options.stickySelf] - whether a sticky section's own wrapper sticks (in the body)
227
+ * @param {'section'|'child'|boolean} [options.stickySelf] - whether a sticky section's own wrapper
228
+ * is the box that sticks: a body section's (`'section'`), or a child section's (`'child'`)
228
229
  * @returns {React.ReactElement}
229
230
  */
230
231
  export function renderBlock(block, { as = 'section', stickySelf = false } = {}) {
@@ -267,9 +268,12 @@ export function renderBlock(block, { as = 'section', stickySelf = false } = {})
267
268
  // Section mode: full treatment with wrapper, context classes, background.
268
269
  const { background, ...wrapperProps } = getWrapperProps(block)
269
270
 
270
- // In the body, a sticky section's wrapper is the box that stays in view (`sticky.js`).
271
- const sticky = stickySelf ? sectionStickyStyle(block) : null
272
- if (sticky) wrapperProps.style = { ...wrapperProps.style, ...sticky }
271
+ // A body section's wrapper, or a child section's, is the box that stays in view (`sticky.js`).
272
+ const sticky = sectionStickyProps(block, stickySelf)
273
+ if (sticky) {
274
+ wrapperProps.style = { ...wrapperProps.style, ...sticky.style }
275
+ wrapperProps['data-uw-sticky'] = sticky['data-uw-sticky']
276
+ }
273
277
 
274
278
  // Merge Component.className (static classes declared on the component function)
275
279
  const componentClassName = Component.className
@@ -347,11 +351,11 @@ export function renderLayout(page, website) {
347
351
  const sticky = layoutStickiness(areas)
348
352
  const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
349
353
  const wrapArea = (name, element) => {
350
- const style = areaWrapperStyle(name, transitions, layers, RemoteLayout ? sticky[name] : null)
351
- return style ? React.createElement('div', { style }, element) : element
354
+ const props = areaWrapperProps(name, transitions, layers, RemoteLayout ? sticky[name] : null)
355
+ return props ? React.createElement('div', props, element) : element
352
356
  }
353
357
 
354
- const bodyElement = bodyBlocks ? wrapArea('body', renderBlocks(bodyBlocks, { stickySelf: true })) : null
358
+ const bodyElement = bodyBlocks ? wrapArea('body', renderBlocks(bodyBlocks, { stickySelf: 'section' })) : null
355
359
  const areaElements = {}
356
360
  for (const [name, blocks] of Object.entries(areas)) {
357
361
  areaElements[name] = wrapArea(name, renderBlocks(blocks))
@@ -369,7 +373,7 @@ export function renderLayout(page, website) {
369
373
  // The built-in layout — the browser's own copy, not a mirror of it (`default-layout.js`).
370
374
  // ⛔ Until 2026-10-07 this wrote its own, with no full-height column, so the prerendered
371
375
  // page differed from the one the browser renders over it.
372
- const zone = (name) => (sticky[name] ? stickyStyle(sticky[name].top, layers?.[name]) : null)
376
+ const zone = (name) => zoneProps(sticky[name], layers?.[name])
373
377
  return React.createElement(DefaultLayout, {
374
378
  body: bodyElement,
375
379
  ...areaElements,
@@ -461,7 +465,7 @@ export function initPrerender(content, foundation, extensionsOrOptions, maybeOpt
461
465
  const blockList = blocks || from?.childBlocks || []
462
466
  return blockList.map((childBlock, index) =>
463
467
  React.createElement(React.Fragment, { key: childBlock.id || index },
464
- renderBlock(childBlock, { as: wrapAs || null })
468
+ renderBlock(childBlock, { as: wrapAs || null, stickySelf: wrapAs ? 'child' : false })
465
469
  )
466
470
  )
467
471
  }
@@ -13,7 +13,14 @@
13
13
  * The layout makes its element sticky when `page.isSticky(area)`.
14
14
  *
15
15
  * Table parts are left out: a sticky table header cell sticks within its table, though its row is
16
- * no taller than it is.
16
+ * no taller than it is. So is a child section the runtime made sticky (`data-uw-sticky="child"`):
17
+ * with no room — stacked at one column, or the tallest in its row — it does not stick, by design.
18
+ *
19
+ * And an element that sits in a box that CLIPS — `overflow: hidden`, or `overflow-x: hidden`
20
+ * alone, which makes the box scroll vertically too — sticks to that box in place of the page,
21
+ * and the box does not scroll, so it never moves. Components clip for rounded corners; the
22
+ * warning names `overflow: clip`, which clips and keeps it sticking. A box that really scrolls
23
+ * (a layout's own scrolling `<main>`) is the element's scroller, and is left alone.
17
24
  */
18
25
 
19
26
  const warned = new WeakSet()
@@ -43,6 +50,28 @@ export function canStick(el) {
43
50
  return (vertical && room.height > own.height + 0.5) || (horizontal && room.width > own.width + 0.5)
44
51
  }
45
52
 
53
+ /**
54
+ * The box a sticky element would stick to in place of the page, because it clips its overflow
55
+ * without scrolling — or null. Looks no further than the nearest box with an overflow of its own:
56
+ * one that scrolls is the element's scroller, which is fine. Stops below `<body>`, whose overflow
57
+ * is the viewport's.
58
+ *
59
+ * @param {Element} el
60
+ * @returns {Element|null}
61
+ */
62
+ export function clippingAncestor(el) {
63
+ const doc = el.ownerDocument
64
+ for (let node = el.parentElement; node && node !== doc?.body && node !== doc?.documentElement; node = node.parentElement) {
65
+ const { overflowX, overflowY } = getComputedStyle(node)
66
+ if (overflowY === 'visible' || overflowY === 'clip') continue
67
+ if (overflowY === 'hidden') return node
68
+ // `auto` / `scroll`: a scroller — unless it is only `overflow-x: hidden`'s side effect.
69
+ const scrolls = node.scrollHeight > node.clientHeight + 1
70
+ return overflowX === 'hidden' && !scrolls ? node : null
71
+ }
72
+ return null
73
+ }
74
+
46
75
  /**
47
76
  * Warn, once per element, about each sticky element on the page that cannot move.
48
77
  *
@@ -53,7 +82,20 @@ export function warnUnmovableSticky(doc = globalThis.document) {
53
82
  if (!doc?.body || typeof requestAnimationFrame !== 'function') return () => {}
54
83
  const frame = requestAnimationFrame(() => {
55
84
  for (const el of doc.body.querySelectorAll('*')) {
56
- if (warned.has(el) || getComputedStyle(el).position !== 'sticky' || canStick(el)) continue
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
57
99
  warned.add(el)
58
100
  console.warn(
59
101
  '[uniweb] This element is sticky but cannot move: its parent is no taller than it is, so it ' +
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `--uw-sticky-top` — how far down the viewport the page's stuck chrome reaches.
3
+ *
4
+ * A sticky section with no `top` of its own stays in view below it (`sticky.js`), and kit's
5
+ * anchors land below it — a heading a contents rail scrolls to, a figure a cross-reference links
6
+ * to. Browser-only, run by `components/Layout.jsx`: only a browser knows a box's height, and the
7
+ * height is all that varies — a header that wraps on a phone, an announcement bar above it.
8
+ *
9
+ * ⭐ A MEASUREMENT THE PRERENDER DOES NOT NEED. The offset matters only once something is stuck,
10
+ * which takes scrolling; by then the browser has rendered and measured. A prerendered page, and
11
+ * one read without scripts, keep the variable's fallback.
12
+ *
13
+ * WHAT COUNTS: each box the runtime made sticky for an area (`data-uw-sticky="area"`) that comes
14
+ * before the body and spans over it — a header — but not one that follows it (a footer that
15
+ * sticks never reaches the top) or sits beside it (a rail stuck at the top covers no content). It
16
+ * reaches its `top` plus its height; the variable is the furthest any of them reaches. A rule of
17
+ * position, so it reads no meaning into an area's name (`area-wrappers.js` refuses to for the
18
+ * same reason).
19
+ */
20
+
21
+ const VARIABLE = '--uw-sticky-top'
22
+ const FOLLOWING = 4 // Node.DOCUMENT_POSITION_FOLLOWING
23
+
24
+ /**
25
+ * The body's sections, by their Block — noted by `components/BlockRenderer.jsx` — so the
26
+ * measurement finds the body by the block it rendered, never by an id: a header's section and a
27
+ * body's can carry the same one.
28
+ */
29
+ const bodySections = new WeakMap()
30
+
31
+ /** Note (or forget, with `null`) the element a body section rendered into. */
32
+ export function noteBodySection(block, element) {
33
+ if (!block) return
34
+ if (element) bodySections.set(block, element)
35
+ else bodySections.delete(block)
36
+ }
37
+
38
+ /** The element a body section rendered into, or null. */
39
+ export function bodySectionOf(block) {
40
+ return (block && bodySections.get(block)) || null
41
+ }
42
+
43
+ /**
44
+ * How far down the viewport the stuck chrome over the body reaches, in px — 0 when none does.
45
+ *
46
+ * @param {Document} doc
47
+ * @param {Element|null} body - the body's first section
48
+ * @returns {number}
49
+ */
50
+ export function measureStickyTop(doc, body) {
51
+ if (!body) return 0
52
+ const over = body.getBoundingClientRect()
53
+ let reach = 0
54
+ for (const box of doc.querySelectorAll('[data-uw-sticky="area"]')) {
55
+ if (box.contains(body)) continue
56
+ if (!(box.compareDocumentPosition(body) & FOLLOWING)) continue // the body comes first: a footer
57
+ const own = box.getBoundingClientRect()
58
+ if (own.right <= over.left || own.left >= over.right) continue // beside the body: a rail
59
+ const top = parseFloat(doc.defaultView.getComputedStyle(box).top) || 0
60
+ reach = Math.max(reach, top + own.height)
61
+ }
62
+ return Math.round(reach * 100) / 100
63
+ }
64
+
65
+ /**
66
+ * Keep `--uw-sticky-top` on the root element as the page's stuck chrome changes size, and remove
67
+ * it when nothing over the body sticks.
68
+ *
69
+ * @param {Document} doc
70
+ * @param {Element|null} body - the body's first section
71
+ * @returns {() => void} stops watching, and removes the variable
72
+ */
73
+ export function watchStickyTop(doc, body) {
74
+ const root = doc?.documentElement
75
+ if (!root) return () => {}
76
+
77
+ const apply = () => {
78
+ const reach = measureStickyTop(doc, body)
79
+ if (reach > 0) root.style.setProperty(VARIABLE, `${reach}px`)
80
+ else root.style.removeProperty(VARIABLE)
81
+ }
82
+ apply()
83
+
84
+ const View = doc.defaultView
85
+ if (!body || typeof View?.ResizeObserver !== 'function') {
86
+ return () => root.style.removeProperty(VARIABLE)
87
+ }
88
+ let frame = 0
89
+ const observer = new View.ResizeObserver(() => {
90
+ View.cancelAnimationFrame(frame)
91
+ frame = View.requestAnimationFrame(apply)
92
+ })
93
+ observer.observe(body)
94
+ for (const box of doc.querySelectorAll('[data-uw-sticky="area"]')) observer.observe(box)
95
+ return () => {
96
+ View.cancelAnimationFrame(frame)
97
+ observer.disconnect()
98
+ root.style.removeProperty(VARIABLE)
99
+ }
100
+ }
package/src/sticky.js CHANGED
@@ -10,22 +10,44 @@
10
10
  * defaulted by its type's `meta.js` — and the runtime makes sticky the box that can:
11
11
  *
12
12
  * - in the body, the section's own wrapper, whose parent, the body, spans every section;
13
+ * - a child section rendered as a section (`<ChildBlocks wrapAs>`, kit's `ChildGrid`), its own
14
+ * wrapper too, which then stays in view within the box its parent places it in — in
15
+ * `ChildGrid`, its cell, as tall as the row — and no further. Top-aligned, so a flex or grid
16
+ * item has room. At one column (a phone) a child is its own row and does not stick, which is
17
+ * what a side panel stacked under the content should do;
13
18
  * - in any other area, the area's box when any of its sections is sticky: the zone, in the
14
19
  * built-in layout; the area wrapper, in a foundation's layout. That box takes the area's
15
20
  * layer, and a sticky area's layer is above every area that scrolls under it
16
21
  * (`resolveLayoutLayers`).
17
22
  *
23
+ * ⭐ A section stays in view BELOW what is stuck at the top. A sticky area sticks at `0px`; a
24
+ * sticky section with no `top` of its own sticks at `--uw-sticky-top`, how far down the page's
25
+ * stuck chrome reaches, which the browser measures (`sticky-top.js`) — so a side panel or a
26
+ * sub-navigation clears a stuck header whatever its height. Unmeasured — a prerendered page
27
+ * before the browser runs, a page with nothing stuck — it is `0px`.
28
+ *
29
+ * Every box this module makes sticky says so, `data-uw-sticky` = `area`, `section` or `child`,
30
+ * for what reads them in the browser: the measurement, and `uniweb dev`'s check
31
+ * (`sticky-check.js`).
32
+ *
18
33
  * ⛔ A layout that wraps an area in an element of its own confines the area wrapper, as the zone
19
34
  * does in the built-in layout. That element is the layout's, so the layout makes it sticky, when
20
35
  * `page.isSticky(area)`.
21
36
  *
22
37
  * ⛔ Until 2026-10-19 there was no setting, and a header's own `sticky top-0` — the starter
23
- * templates' — scrolled away with the page.
38
+ * templates' — scrolled away with the page. Until 2026-10-20 a child section's setting did
39
+ * nothing, and a sticky section stuck at `0px`, under a stuck header.
24
40
  */
25
41
 
26
- /** The very top of the viewport — a sticky box with no `top` of its own. */
42
+ /** The very top of the viewport — a sticky area with no `top` of its own. */
27
43
  export const STICKY_TOP = '0px'
28
44
 
45
+ /**
46
+ * Where a sticky section with no `top` of its own stays: below what is stuck at the top
47
+ * (`sticky-top.js` measures it), else the very top.
48
+ */
49
+ export const STICKY_SECTION_TOP = 'var(--uw-sticky-top, 0px)'
50
+
29
51
  /**
30
52
  * The style that keeps a box in view.
31
53
  *
@@ -65,12 +87,38 @@ export function layoutStickiness(areas) {
65
87
  }
66
88
 
67
89
  /**
68
- * The style a sticky section's own wrapper takes, in the body — or null. Layer 1, so it paints
69
- * over the sections after it, which are positioned only when they have a background.
90
+ * The props a built-in layout zone takes when its area sticks — or null.
91
+ *
92
+ * @param {{ top: string }|null|undefined} stickiness - the area's (`areaStickiness`)
93
+ * @param {number|null} [layer] - the area's layer (`resolveLayoutLayers`)
94
+ * @returns {{ style: Object, 'data-uw-sticky': 'area' }|null}
95
+ */
96
+ export function zoneProps(stickiness, layer) {
97
+ return stickiness ? { style: stickyStyle(stickiness.top, layer), 'data-uw-sticky': 'area' } : null
98
+ }
99
+
100
+ /**
101
+ * The style a sticky section's own wrapper takes — a body section's, or a child section's — or
102
+ * null. Below what is stuck at the top unless its type says how far down; layer 1, so it paints
103
+ * over the sections after it, which are positioned only when they have a background; and
104
+ * top-aligned, so that as a flex or grid item it is not stretched to the row, which would leave
105
+ * it no room to move (in normal flow `align-self` does nothing).
70
106
  *
71
107
  * @param {Object} block - its component initialized
72
108
  * @returns {Object|null}
73
109
  */
74
110
  export function sectionStickyStyle(block) {
75
- return block?.sticky ? stickyStyle(block.stickyTop, 1) : null
111
+ return block?.sticky ? { ...stickyStyle(block.stickyTop || STICKY_SECTION_TOP, 1), alignSelf: 'start' } : null
112
+ }
113
+
114
+ /**
115
+ * What a section's wrapper adds when it sticks: its style, and which kind of box it is.
116
+ *
117
+ * @param {Object} block - its component initialized
118
+ * @param {'section'|'child'|boolean} kind - a body section (`true` reads as one), or a child section
119
+ * @returns {{ style: Object, 'data-uw-sticky': string }|null}
120
+ */
121
+ export function sectionStickyProps(block, kind) {
122
+ const style = kind ? sectionStickyStyle(block) : null
123
+ return style ? { style, 'data-uw-sticky': kind === 'child' ? 'child' : 'section' } : null
76
124
  }