@uniweb/runtime 1.4.6 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "1.4.6",
3
+ "version": "1.5.1",
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/core": "~1.6.5",
40
- "@uniweb/theming": "~1.2.1"
39
+ "@uniweb/core": "~1.6.7",
40
+ "@uniweb/theming": "~1.3.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.17.1"
47
+ "@uniweb/build": "1.18.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -13,6 +13,7 @@ import Background from './Background.jsx'
13
13
  import { sectionStickyProps, sectionScrollMargin } from '../sticky.js'
14
14
  import { noteBodySection } from '../sticky-top.js'
15
15
  import { noteSectionWrapper } from '../sticky-check.js'
16
+ import { openingUnderHeader, underHeaderProps } from '../under-header.js'
16
17
 
17
18
  /**
18
19
  * Valid color contexts
@@ -173,6 +174,14 @@ export default function BlockRenderer({ block, as = 'section', stickySelf = fals
173
174
  // On a page whose layout has a sticky area, a link to this section lands below it.
174
175
  const margin = sectionScrollMargin(block, stickySelf)
175
176
  if (margin) wrapperProps.style = { ...wrapperProps.style, ...margin }
177
+ // The page's opening section, when the header lies over it: the header's height to clear, the
178
+ // declared one until the browser measures it (`../under-header.js`). Mirrored in `ssr-renderer.js`.
179
+ const overlay = stickySelf === 'section' || stickySelf === true ? openingUnderHeader(block) : null
180
+ if (overlay) {
181
+ const under = underHeaderProps(overlay)
182
+ wrapperProps.style = { ...wrapperProps.style, ...under.style }
183
+ wrapperProps['data-uw-under-header'] = under['data-uw-under-header']
184
+ }
176
185
  // A body section's element, for the measurement of what is stuck over the body (`../sticky-top.js`);
177
186
  // in dev, every section's, so `uniweb dev`'s sticky check knows a component's outermost element.
178
187
  const bodySection = stickySelf === 'section' || stickySelf === true
@@ -202,6 +211,11 @@ export default function BlockRenderer({ block, as = 'section', stickySelf = fals
202
211
  // and let the engine background show through.
203
212
  block.hasBackground = hasBackground
204
213
 
214
+ // `background: 'self'` is everything behind the content: the theme leaves this wrapper
215
+ // unpainted too, and the component paints its context's surface where it wants it
216
+ // (`@uniweb/theming`'s section rule). Mirrored in `ssr-renderer.js`.
217
+ if (meta?.background === 'self') wrapperProps['data-uw-background'] = 'self'
218
+
205
219
  // Determine wrapper element:
206
220
  // - Explicit as prop (not 'section') → use as prop directly
207
221
  // - Component.as → use component's declared tag (e.g., Header.as = 'header')
@@ -35,6 +35,7 @@ import { useEffect } from 'react'
35
35
  import Blocks from './Blocks.jsx'
36
36
  import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from '../area-wrappers.js'
37
37
  import { layoutStickiness, zoneProps } from '../sticky.js'
38
+ import { headerOverlay, overlayBoxProps } from '../under-header.js'
38
39
  import { warnUnmovableSticky } from '../sticky-check.js'
39
40
  import { watchStickyTop, bodySectionOf } from '../sticky-top.js'
40
41
  // The built-in layout — one copy, which the prerender renders too (`../default-layout.js`).
@@ -95,9 +96,14 @@ export default function Layout({ page, website }) {
95
96
  // it is lifted above the areas that scroll under it.
96
97
  const sticky = layoutStickiness(areas)
97
98
  const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
99
+ // A page that opens with a section under the header has the header lie over it: the header's box
100
+ // takes no room in the flow (`../under-header.js`). In a foundation's layout that box is the area
101
+ // wrapper; in the built-in one, the zone around it.
102
+ const overlay = headerOverlay(page)
98
103
 
99
104
  const wrap = (region, children) => {
100
- const props = areaWrapperProps(region, transitions, layers, RemoteLayout ? sticky[region] : null)
105
+ let props = areaWrapperProps(region, transitions, layers, RemoteLayout ? sticky[region] : null)
106
+ if (overlay && RemoteLayout && region === 'header') props = overlayBoxProps(props)
101
107
  return props ? <div {...props}>{children}</div> : children
102
108
  }
103
109
 
@@ -143,7 +149,7 @@ export default function Layout({ page, website }) {
143
149
  <DefaultLayout
144
150
  body={bodyElement}
145
151
  {...areaElements}
146
- zones={{ header: zone('header'), footer: zone('footer') }}
152
+ zones={{ header: overlay ? overlayBoxProps(zone('header')) : zone('header'), footer: zone('footer') }}
147
153
  />
148
154
  )
149
155
  }
@@ -126,13 +126,12 @@ export default function PageRenderer() {
126
126
 
127
127
  // ⭐ WHAT THE URL NAMES — `Website#resolveRoute`, the one rule every lane calls
128
128
  // (`@uniweb/core/resolve-route`): a page, a redirect, a page served from elsewhere, or
129
- // nothing. Resolved synchronously (not in an effect) so child components like Header see the
130
- // correct activePage during the same render cycle.
129
+ // nothing. Resolved synchronously (not in an effect), and the page drawn made active below,
130
+ // so child components like Header see it during the same render cycle.
131
131
  const navigate = useNavigate()
132
132
 
133
133
  const resolution = website?.resolveRoute?.(location.pathname) ?? null
134
134
  let page = resolution && resolution.kind !== 'notFound' ? resolution.page : null
135
- if (page && website) website.setActivePage(location.pathname)
136
135
 
137
136
  // ─── Content loading (split page content) ───
138
137
  // On first load, the current page's sections are pre-embedded → contentReady = true.
@@ -182,6 +181,13 @@ export default function PageRenderer() {
182
181
  page = website?.getNotFoundPage?.() || null
183
182
  }
184
183
 
184
+ // ⭐ THE PAGE DRAWN IS THE ACTIVE ONE, by the Page itself, as the prerender makes it
185
+ // (`renderPage`): a header reading its next block, the language switcher, page state and a
186
+ // contents rail all read the page on screen, the not-found page included. ⛔ Until 2026-11-06 it
187
+ // was made active by the URL, so the not-found page drew with another page active, and a
188
+ // parametric page whose records were not held yet was looked up anew, another Page than this one.
189
+ if (page && website) website.setActivePage(page)
190
+
185
191
  // Head metadata — compute for all cases (redirect pages get a fallback)
186
192
  const headMeta = page?.getHeadMeta?.() || {
187
193
  title: isNotFound ? 'Page Not Found' : (page?.title || 'Website'),
@@ -38,7 +38,6 @@ import { resolveRequestedPage } from './resolve-page.js'
38
38
  import { hydrateDataStore } from './wire-foundation.js'
39
39
  import { loadPageData } from './load-page-data.js'
40
40
  import { requireTransport } from './require-transport.js'
41
- import { restoreStartPage } from './start-page.js'
42
41
  import { whileReadingIcons } from './page-icons.js'
43
42
 
44
43
  /**
@@ -152,15 +151,11 @@ export function createPageRenderer({ website, shell }) {
152
151
  // host that answered by the outcome sent 200 for a URL naming no record.
153
152
  const missing = !named || page?.notFound === true
154
153
 
155
- // ⭐ EVERY RENDER STARTS FROM THE ACTIVE PAGE THE WEBSITE STARTED WITH (`start-page.js`),
156
- // then makes active the page the route names, as the browser's boot does (`index.jsx`).
157
- // `renderPage` then makes the page it renders active by its route, and the not-found page
158
- // has none: it renders with the page a route served from elsewhere names, as in the browser,
159
- // or with the start page. ⛔ Until 2026-10-26 it rendered with whatever page this Website
160
- // rendered last, so on a Website reused across requests its header could show the previous
161
- // request's page.
162
- restoreStartPage(website)
163
- if (typeof target === 'string') website.setActivePage(target)
154
+ // ⭐ `renderPage` makes the page it renders active, by the Page, the not-found page included,
155
+ // so no render sees the page an earlier request rendered. ⛔ Until 2026-10-26 the not-found
156
+ // page rendered with whatever page this Website rendered last, so on a Website reused across
157
+ // requests its header could show the previous request's page; until 2026-11-06 it rendered with
158
+ // the page the Website started with (or a page served from elsewhere that the route named).
164
159
  // ⭐ The page carries the icons it drew, so the browser's first render draws them too.
165
160
  const icons = new Map()
166
161
  let result
@@ -34,10 +34,10 @@ import {
34
34
  } from './wire-foundation.js'
35
35
  import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from './area-wrappers.js'
36
36
  import { layoutStickiness, sectionStickyProps, sectionScrollMargin, zoneProps } from './sticky.js'
37
+ import { headerOverlay, overlayBoxProps, openingUnderHeader, underHeaderProps } from './under-header.js'
37
38
  import { DefaultLayout } from './default-layout.js'
38
39
  import { renderAppearanceBootScript } from './appearance.js'
39
40
  import { documentTitle } from './document-title.js'
40
- import { recordStartPage } from './start-page.js'
41
41
  import { escapeHtml } from '@uniweb/core/page-shell'
42
42
  import { jsonForScript } from '@uniweb/core/data-script'
43
43
 
@@ -185,6 +185,14 @@ export function renderBlock(block, { as = 'section', stickySelf = false } = {})
185
185
  // On a page whose layout has a sticky area, a link to this section lands below it.
186
186
  const margin = sectionScrollMargin(block, stickySelf)
187
187
  if (margin) wrapperProps.style = { ...wrapperProps.style, ...margin }
188
+ // The page's opening section, when the header lies over it: the header's height to clear
189
+ // (`under-header.js`). Mirrors `BlockRenderer.jsx`.
190
+ const overlay = stickySelf === 'section' || stickySelf === true ? openingUnderHeader(block) : null
191
+ if (overlay) {
192
+ const under = underHeaderProps(overlay)
193
+ wrapperProps.style = { ...wrapperProps.style, ...under.style }
194
+ wrapperProps['data-uw-under-header'] = under['data-uw-under-header']
195
+ }
188
196
 
189
197
  // Merge Component.className (static classes declared on the component function)
190
198
  const componentClassName = Component.className
@@ -197,6 +205,8 @@ export function renderBlock(block, { as = 'section', stickySelf = false } = {})
197
205
  // Check if component handles its own background
198
206
  const hasBackground = background?.mode && meta?.background !== 'self'
199
207
  block.hasBackground = hasBackground
208
+ // `background: 'self'` leaves the wrapper unpainted by the theme too (`BlockRenderer.jsx`).
209
+ if (meta?.background === 'self') wrapperProps['data-uw-background'] = 'self'
200
210
 
201
211
  // Determine wrapper element:
202
212
  // - Explicit as (not 'section') → use as prop directly
@@ -261,8 +271,11 @@ export function renderLayout(page, website) {
261
271
  // above the areas that scroll under it.
262
272
  const sticky = layoutStickiness(areas)
263
273
  const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
274
+ // Mirror Layout.jsx: the header lies over a page's opening section under it (`under-header.js`).
275
+ const overlay = headerOverlay(page)
264
276
  const wrapArea = (name, element) => {
265
- const props = areaWrapperProps(name, transitions, layers, RemoteLayout ? sticky[name] : null)
277
+ let props = areaWrapperProps(name, transitions, layers, RemoteLayout ? sticky[name] : null)
278
+ if (overlay && RemoteLayout && name === 'header') props = overlayBoxProps(props)
266
279
  return props ? React.createElement('div', props, element) : element
267
280
  }
268
281
 
@@ -288,7 +301,7 @@ export function renderLayout(page, website) {
288
301
  return React.createElement(DefaultLayout, {
289
302
  body: bodyElement,
290
303
  ...areaElements,
291
- zones: { header: zone('header'), footer: zone('footer') },
304
+ zones: { header: overlay ? overlayBoxProps(zone('header')) : zone('header'), footer: zone('footer') },
292
305
  })
293
306
  }
294
307
 
@@ -362,8 +375,6 @@ export function initPrerender(content, foundation, extensionsOrOptions, maybeOpt
362
375
  // Uniweb constructor wires foundation, capabilities, layoutMeta, handlers,
363
376
  // and extensions at construction time — see `@uniweb/core`'s src/uniweb.js.
364
377
  const uniweb = createUniweb(content, foundation, extensions)
365
- // Before anything renders: the active page every render starts from (`start-page.js`).
366
- recordStartPage(uniweb.activeWebsite)
367
378
 
368
379
  // Set base path from site config for subdirectory deployments
369
380
  if (content.config?.base && uniweb.activeWebsite?.setBasePath) {
@@ -401,12 +412,8 @@ export function initPrerender(content, foundation, extensionsOrOptions, maybeOpt
401
412
  ensureThemeCss(uniweb, foundation)
402
413
 
403
414
  // Register SSR-safe routing so useRouting()/useActiveRoute() work during prerender.
404
- // renderPage() calls website.setActivePage() before rendering each page,
405
- // so activePage.route reflects the page being rendered — except the site's
406
- // not-found page, which no route names. Through `createPageRenderer` it renders
407
- // with the page a route served from elsewhere names, else the page the Website
408
- // starts with (`start-page.js`); through `generate404Html`, with the page
409
- // rendered last.
415
+ // renderPage() makes the page it renders active before rendering it, so
416
+ // activePage.route is the page being rendered, the not-found page included.
410
417
  const website = uniweb.activeWebsite
411
418
  uniweb.routingComponents = {
412
419
  useLocation: () => {
@@ -534,11 +541,13 @@ export function classifyRenderError(err) {
534
541
  * @returns {{ renderedContent: string, sectionOverrideCSS: string } | { error: { type: string, message: string } }}
535
542
  */
536
543
  export function renderPage(page, website) {
537
- // ⛔ The site's not-found page is never made active by its route. No route names it, and on a
538
- // site whose parametric page matches `/404` (a root `/:slug`) that route names THAT page, which
539
- // then rendered as the active one. It renders with the active page it finds: the Website's
540
- // first, through `createPageRenderer` (`start-page.js`), as in the browser.
541
- if (page !== website.getNotFoundPage()) website.setActivePage(page.route)
544
+ // ⭐ The page it renders is the active one, by the Page itself, as in the browser
545
+ // (`PageRenderer.jsx`): whatever adapts to the page — a header reading its next block, the
546
+ // language switcher, page state, a contents rail — reads the page on screen. ⛔ Until 2026-11-06
547
+ // it was made active by its route. The site's not-found page has none (on a site whose
548
+ // parametric page matches `/404`, that route names THAT page), so it rendered with another page
549
+ // active, and a parametric page whose records were not held yet was made anew by the lookup.
550
+ website.setActivePage(page)
542
551
 
543
552
  // A page that claims content but yields no blocks has not been loaded — it is
544
553
  // not an empty page. `Page#bodyBlocks` returns [] when its sections are absent
@@ -25,6 +25,13 @@
25
25
  * Table parts are left out: a sticky table header cell sticks within its table, though its row is
26
26
  * no taller than it is. So is a child section the runtime made sticky (`data-uw-sticky="child"`):
27
27
  * with no room — stacked at one column, or the tallest in its row — it does not stick, by design.
28
+ * And so is an element whose box is sticky itself: the box moves, and the element with it — the
29
+ * runtime's area wrapper inside a layout's own element that `page.isSticky(area)` made sticky, as
30
+ * the docs say to. ⛔ Until 2026-11-06 that layout was told to do what it had done.
31
+ *
32
+ * ⭐ A header over the page's opening section is checked too (`under-header.js`): when what it
33
+ * holds takes no room, a bar made `fixed` or `absolute`, the runtime measures 0px, so the section
34
+ * is not cleared and a link to a section lands under the bar — and nothing else says why.
28
35
  *
29
36
  * And an element that sits in a box that CLIPS — `overflow: hidden`, or `overflow-x: hidden`
30
37
  * alone, which makes the box scroll vertically too — sticks to that box in place of the page,
@@ -33,6 +40,8 @@
33
40
  * (a layout's own scrolling `<main>`) is the element's scroller, and is left alone.
34
41
  */
35
42
 
43
+ import { measureHeaderOverlay } from './sticky-top.js'
44
+
36
45
  const warned = new WeakSet()
37
46
 
38
47
  /** Every section wrapper the browser's renderer drew, noted in dev (`BlockRenderer.jsx`). */
@@ -125,10 +134,37 @@ export function unmovableAdvice(el) {
125
134
  )
126
135
  }
127
136
 
137
+ /**
138
+ * The box of a header over the opening section, when what it holds takes no room though some of
139
+ * it is drawn: a bar out of the flow — or null.
140
+ *
141
+ * @param {Document} doc
142
+ * @returns {Element|null}
143
+ */
144
+ export function headerOutOfFlow(doc) {
145
+ const box = doc.querySelector?.('[data-uw-header="over"]')
146
+ if (!box || measureHeaderOverlay(doc) !== 0) return null
147
+ const drawn = [...box.querySelectorAll('*')].some((el) => el.getBoundingClientRect().height > 0)
148
+ return drawn ? box : null
149
+ }
150
+
128
151
  /** Warn, once per element, about each sticky element on the page that cannot move. */
129
152
  function checkPage(doc) {
153
+ const header = headerOutOfFlow(doc)
154
+ if (header && !warned.has(header)) {
155
+ warned.add(header)
156
+ console.warn(
157
+ '[uniweb] The header lies over the page\'s opening section, but what it holds takes no room: a bar ' +
158
+ 'made `fixed` or `absolute` leaves the runtime nothing to measure, so the section is cleared by ' +
159
+ '0px and a link to a section lands under the bar. Keep the bar in the flow; the runtime lays ' +
160
+ 'the header over the section and keeps it stuck.',
161
+ header,
162
+ )
163
+ }
130
164
  for (const el of doc.body.querySelectorAll('*')) {
131
165
  if (warned.has(el) || getComputedStyle(el).position !== 'sticky') continue
166
+ const parent = boxParent(el)
167
+ if (parent && getComputedStyle(parent).position === 'sticky') continue
132
168
  const clip = clippingAncestor(el)
133
169
  if (clip) {
134
170
  warned.add(el)
package/src/sticky-top.js CHANGED
@@ -16,8 +16,15 @@
16
16
  * reaches its `top` plus its height; the variable is the furthest any of them reaches. A rule of
17
17
  * position, so it reads no meaning into an area's name (`area-wrappers.js` refuses to for the
18
18
  * same reason).
19
+ *
20
+ * ⭐ A HEADER OVER THE OPENING SECTION (`under-header.js`) takes no room in the flow, so its box is
21
+ * as tall as nothing: what it reaches is what it holds, the runtime's own wrappers inside it. Its
22
+ * height is published too, as `--uw-header-measured`, which the opening section's
23
+ * `--uw-header-height` reads in place of the height the header type declared for the first paint.
19
24
  */
20
25
 
26
+ import { MEASURED_HEADER_HEIGHT } from './under-header.js'
27
+
21
28
  const VARIABLE = '--uw-sticky-top'
22
29
  const FOLLOWING = 4 // Node.DOCUMENT_POSITION_FOLLOWING
23
30
 
@@ -40,6 +47,34 @@ export function bodySectionOf(block) {
40
47
  return (block && bodySections.get(block)) || null
41
48
  }
42
49
 
50
+ /**
51
+ * How tall a box is drawn: its own height — or, for the header's box over the page's opening
52
+ * section (`data-uw-header="over"`, `under-header.js`), which takes no room in the flow, how far what
53
+ * it holds reaches below its top. What it holds is the runtime's own elements (the area's wrapper,
54
+ * the sections' wrappers), never a guess at a foundation's markup.
55
+ *
56
+ * @param {Element} box
57
+ * @returns {number}
58
+ */
59
+ function drawnHeight(box) {
60
+ const own = box.getBoundingClientRect()
61
+ if (box.getAttribute('data-uw-header') !== 'over') return own.height
62
+ let bottom = own.top
63
+ for (const child of box.children) bottom = Math.max(bottom, child.getBoundingClientRect().bottom)
64
+ return bottom - own.top
65
+ }
66
+
67
+ /**
68
+ * How tall the header lying over the page's opening section is, in px — null on a page with none.
69
+ *
70
+ * @param {Document} doc
71
+ * @returns {number|null}
72
+ */
73
+ export function measureHeaderOverlay(doc) {
74
+ const box = doc.querySelector('[data-uw-header="over"]')
75
+ return box ? Math.round(drawnHeight(box) * 100) / 100 : null
76
+ }
77
+
43
78
  /**
44
79
  * How far down the viewport the stuck chrome over the body reaches, in px — 0 when none does.
45
80
  *
@@ -57,7 +92,7 @@ export function measureStickyTop(doc, body) {
57
92
  const own = box.getBoundingClientRect()
58
93
  if (own.right <= over.left || own.left >= over.right) continue // beside the body: a rail
59
94
  const top = parseFloat(doc.defaultView.getComputedStyle(box).top) || 0
60
- reach = Math.max(reach, top + own.height)
95
+ reach = Math.max(reach, top + drawnHeight(box))
61
96
  }
62
97
  return Math.round(reach * 100) / 100
63
98
  }
@@ -78,13 +113,19 @@ export function watchStickyTop(doc, body) {
78
113
  const reach = measureStickyTop(doc, body)
79
114
  if (reach > 0) root.style.setProperty(VARIABLE, `${reach}px`)
80
115
  else root.style.removeProperty(VARIABLE)
116
+ // The header over the opening section: what the section's `--uw-header-height` reads once known.
117
+ const header = measureHeaderOverlay(doc)
118
+ if (header != null) root.style.setProperty(MEASURED_HEADER_HEIGHT, `${header}px`)
119
+ else root.style.removeProperty(MEASURED_HEADER_HEIGHT)
120
+ }
121
+ const forget = () => {
122
+ root.style.removeProperty(VARIABLE)
123
+ root.style.removeProperty(MEASURED_HEADER_HEIGHT)
81
124
  }
82
125
  apply()
83
126
 
84
127
  const View = doc.defaultView
85
- if (!body || typeof View?.ResizeObserver !== 'function') {
86
- return () => root.style.removeProperty(VARIABLE)
87
- }
128
+ if (!body || typeof View?.ResizeObserver !== 'function') return forget
88
129
  let frame = 0
89
130
  const observer = new View.ResizeObserver(() => {
90
131
  View.cancelAnimationFrame(frame)
@@ -92,9 +133,11 @@ export function watchStickyTop(doc, body) {
92
133
  })
93
134
  observer.observe(body)
94
135
  for (const box of doc.querySelectorAll('[data-uw-sticky="area"]')) observer.observe(box)
136
+ // A box over the opening section keeps no height of its own to change: what it holds does.
137
+ for (const box of doc.querySelectorAll('[data-uw-header="over"]')) for (const child of box.children) observer.observe(child)
95
138
  return () => {
96
139
  View.cancelAnimationFrame(frame)
97
140
  observer.disconnect()
98
- root.style.removeProperty(VARIABLE)
141
+ forget()
99
142
  }
100
143
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The header laid over a page's opening section — `underHeader`, a setting of the section.
3
+ *
4
+ * A page that opens with a section that can sit under the header (`underHeader: true`: its type's
5
+ * default in `meta.js`, or its author's) has the header lie over it. The header's box takes no room
6
+ * in the flow, so the section starts at the top of the window and its picture reaches up behind a
7
+ * see-through header; the header keeps its own height inside that box, and stays in view if it
8
+ * sticks. Pure, so both renderers (`components/Layout.jsx`, `ssr-renderer.js`) build the same page,
9
+ * and the browser's measurement (`sticky-top.js`) reads the same boxes.
10
+ *
11
+ * ⭐ THE RUNTIME DOES IT, AS IT DOES `sticky`, because a component cannot move the box the runtime
12
+ * puts it in. Foundations that floated a header from inside its own section took the bar out of
13
+ * the flow (`absolute`, `fixed`), which left the box the runtime measures with no height: no
14
+ * `--uw-sticky-top`, and a link to a section landing under the bar (measured 2026-11-06 on a site's
15
+ * store page, in Chromium and WebKit). Five sites solved it five ways.
16
+ *
17
+ * The opening section keeps its content clear of the header by `--uw-header-height`, which the
18
+ * runtime gives its wrapper: the header type's `height` on the first paint, which no prerender can
19
+ * measure, then what the browser measures. The header reads `getNextBlockInfo().underHeader` to
20
+ * paint itself see-through, and its box says `data-uw-header="over"` for a stylesheet. Its type
21
+ * says `background: 'self'` and paints its own bar: otherwise the theme paints the header's section
22
+ * with its surface, a solid band over the opening section (measured 2026-11-06).
23
+ */
24
+
25
+ /** The header's height, on the opening section's wrapper: what its content is padded by. */
26
+ export const HEADER_HEIGHT = '--uw-header-height'
27
+
28
+ /** What the browser measured, on the root, which the opening section's value defers to. */
29
+ export const MEASURED_HEADER_HEIGHT = '--uw-header-measured'
30
+
31
+ /**
32
+ * Whether the page's header lies over its opening section, and how tall the header is before the
33
+ * browser measures it: the `height` its sections' types declare, else `0px` until measured.
34
+ *
35
+ * @param {Object} page - its blocks' components initialized, as both renderers do before rendering
36
+ * @returns {{ height: string }|null}
37
+ */
38
+ export function headerOverlay(page) {
39
+ const first = page?.getBodyBlocks?.()?.[0]
40
+ if (!first?.underHeader) return null
41
+ const header = page.getLayoutAreas?.()?.header
42
+ if (!Array.isArray(header) || header.length === 0) return null
43
+ const heights = header.map((block) => block?.height).filter(Boolean)
44
+ const height = heights.length === 0 ? '0px' : heights.length === 1 ? heights[0] : `calc(${heights.join(' + ')})`
45
+ return { height }
46
+ }
47
+
48
+ /**
49
+ * The props of the header's box — the built-in layout's zone, or a foundation layout's area
50
+ * wrapper — when it lies over the opening section: it takes no room in the flow, and what it holds
51
+ * keeps its height. Whatever else the box has (its layer, its stickiness) stays.
52
+ *
53
+ * @param {Object|null} props - the box's props without the overlay
54
+ * @returns {Object}
55
+ */
56
+ export function overlayBoxProps(props) {
57
+ return { ...(props || {}), style: { ...(props?.style || {}), height: 0, minHeight: 0 }, 'data-uw-header': 'over' }
58
+ }
59
+
60
+ /**
61
+ * What the opening section's wrapper adds when the header lies over it: the header's height, the
62
+ * declared one until the browser has measured it.
63
+ *
64
+ * @param {{ height: string }} overlay - `headerOverlay(page)`
65
+ * @returns {{ style: Object, 'data-uw-under-header': string }}
66
+ */
67
+ export function underHeaderProps(overlay) {
68
+ return {
69
+ style: { [HEADER_HEIGHT]: `var(${MEASURED_HEADER_HEIGHT}, ${overlay.height})` },
70
+ 'data-uw-under-header': '',
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Whether `block` is its page's opening section under the header — what its wrapper asks before it
76
+ * adds `underHeaderProps`.
77
+ *
78
+ * @param {Object} block - a body section's block, its component initialized
79
+ * @returns {{ height: string }|null} the page's overlay, when it opens with this section
80
+ */
81
+ export function openingUnderHeader(block) {
82
+ const page = block?.page
83
+ if (!page || page.getBodyBlocks?.()?.[0] !== block) return null
84
+ return headerOverlay(page)
85
+ }
package/src/start-page.js DELETED
@@ -1,26 +0,0 @@
1
- /**
2
- * The page a Website starts with as active — recorded when the SSR init builds the graph, and put
3
- * back before each render (`createPageRenderer`).
4
- *
5
- * ⭐ It is what the browser has on a URL that names no page. Core makes the site's index page (or
6
- * its first page) active when it builds the Website, and neither the boot nor `PageRenderer` makes
7
- * the not-found page active, since no route names it.
8
- *
9
- * ⛔ `renderPage` makes the page it renders active by its route, so for the not-found page it
10
- * changes nothing, and that page rendered with whatever page the Website rendered last: on a
11
- * Website reused across requests, the previous request's. Anything that adapts to the active page
12
- * — a header, a language switcher, a docs sidebar — showed that page.
13
- *
14
- * A WeakMap rather than a slot on core's sealed Website: it is SSR state, and core is in every site.
15
- */
16
- const startPages = new WeakMap()
17
-
18
- /** Remember `website`'s active page as the one it starts with. Call it before anything renders. */
19
- export function recordStartPage(website) {
20
- if (website && !startPages.has(website)) startPages.set(website, website.activePage)
21
- }
22
-
23
- /** Make `website`'s start page active again. A Website the SSR init did not build is left alone. */
24
- export function restoreStartPage(website) {
25
- if (startPages.has(website)) website.activePage = startPages.get(website)
26
- }