|
1 | 1 | /* ============================================================ |
2 | 2 | GRC Engineering — SiteHeader (vanilla port, 2026-07-15) |
3 | | - Scroll-aware header: a flush, full-bleed bar at the top of the page that |
4 | | - morphs — continuously, driven by scroll position — into a centered, |
5 | | - floating glass pill. The chrome shrinks from edge-to-edge to a contained |
6 | | - width, corners round to a full pill, it detaches from the top with a |
7 | | - small gap, and a translucent frosted fill + backdrop blur + drop shadow + |
8 | | - the brand's orange→blue gradient hairline fade in. The content row stays |
9 | | - at a constant container width so the logo/links never jump. |
| 3 | + Scroll-aware header with TWO discrete states, morphing between them in a |
| 4 | + single seamless CSS transition once a scroll threshold is crossed |
| 5 | + (cocoindex.io model — NOT a continuous scroll-linked interpolation): |
10 | 6 |
|
11 | | - Ported verbatim from the DS React component |
12 | | - (components/navigation/SiteHeader.jsx, project 73c32b48) — CSS and scroll |
13 | | - math unchanged; only the React shell is replaced with a vanilla auto-mount |
| 7 | + • Docked (at the top of the page): a flush, full-bleed frosted bar. The |
| 8 | + translucent glass fill + backdrop blur are PRESENT here, with a bottom |
| 9 | + hairline rule and square corners. |
| 10 | + • Floating (scrolled past the threshold): a centered, contained glass |
| 11 | + pill — full pill corners, a detached gap from the top, a gradient |
| 12 | + hairline ring, and a soft drop shadow beneath it. |
| 13 | +
|
| 14 | + Crossing the threshold toggles a single `.grc-siteheader--floating` class; |
| 15 | + every visual property (width, corner radius, gap, shadow, ring/rule |
| 16 | + opacity) carries a CSS `transition`, so the whole chrome animates fully and |
| 17 | + at once — no per-pixel seams, no step-by-step sync with the scroll wheel. A |
| 18 | + small hysteresis band keeps the toggle from flickering at the boundary. |
| 19 | +
|
| 20 | + Ported from the DS React component (components/navigation/SiteHeader.jsx, |
| 21 | + project 73c32b48) — the React shell is replaced with a vanilla auto-mount |
14 | 22 | controller (same shape as ds/node-graph.js). The component's auto-collapse |
15 | 23 | behavior (measures the real layout against the floating pill width, and |
16 | 24 | switches to a menu button whenever it wouldn't fit) is preserved so any |
17 | 25 | future nav-link edit stays correct without touching a hardcoded breakpoint. |
18 | 26 |
|
19 | 27 | Usage: |
20 | | - <header class="grc-siteheader" data-grc-siteheader data-scroll-distance="88"> |
| 28 | + <header class="grc-siteheader" data-grc-siteheader data-scroll-distance="88" data-contained-width="1180"> |
21 | 29 | <div class="grc-siteheader__bar"> |
22 | 30 | <div class="grc-siteheader__rule"></div> |
23 | 31 | <div class="grc-siteheader__inner"> |
|
29 | 37 | </div> |
30 | 38 | </header> |
31 | 39 | Auto-mounts every [data-grc-siteheader] on DOMContentLoaded. Options via |
32 | | - data-* attributes: data-contained-width (1120), data-scroll-distance (88), |
33 | | - data-gap (14), data-side (22), data-height (64), data-blur (14), |
34 | | - data-radius (999), data-collapse-at (px, forces a fixed breakpoint instead |
35 | | - of auto-measuring). |
| 40 | + data-* attributes: data-contained-width (1180 — the floating pill width; |
| 41 | + set noticeably wider than the page's content container so the pill reads as |
| 42 | + wider than the glass boxes below it), data-scroll-distance (88 — the |
| 43 | + threshold in px at which the header snaps to floating), data-gap (14), |
| 44 | + data-side (22), data-height (64), data-blur (14), data-radius (999), |
| 45 | + data-collapse-at (px, forces a fixed breakpoint instead of auto-measuring). |
36 | 46 | ============================================================ */ |
37 | 47 | (function () { |
38 | 48 | "use strict"; |
|
42 | 52 | ".grc-siteheader {\n" + |
43 | 53 | " position: fixed; top: 0; left: 0; right: 0; z-index: 50;\n" + |
44 | 54 | " display: flex; justify-content: center;\n" + |
45 | | -" --p: 0;\n" + |
46 | | -" padding-top: calc(var(--p) * var(--grc-sh-gap, 14px));\n" + |
47 | | -" padding-left: calc(var(--p) * var(--grc-sh-side, 22px));\n" + |
48 | | -" padding-right: calc(var(--p) * var(--grc-sh-side, 22px));\n" + |
| 55 | +" padding: 0;\n" + |
49 | 56 | " pointer-events: none;\n" + |
50 | 57 | " font-family: var(--ui-family);\n" + |
| 58 | +" transition: padding var(--grc-sh-dur, 320ms) var(--grc-sh-ease, cubic-bezier(0.2,0.8,0.2,1));\n" + |
| 59 | +"}\n" + |
| 60 | +".grc-siteheader--floating {\n" + |
| 61 | +" padding-top: var(--grc-sh-gap, 14px);\n" + |
| 62 | +" padding-left: var(--grc-sh-side, 22px);\n" + |
| 63 | +" padding-right: var(--grc-sh-side, 22px);\n" + |
51 | 64 | "}\n" + |
52 | 65 | ".grc-siteheader__bar {\n" + |
53 | 66 | " position: relative; pointer-events: auto; box-sizing: border-box;\n" + |
54 | 67 | " width: 100%;\n" + |
| 68 | +" max-width: var(--grc-sh-maxw-docked, 6000px);\n" + |
55 | 69 | " height: var(--grc-sh-h, 64px);\n" + |
56 | 70 | " display: flex; justify-content: center;\n" + |
57 | | -" border-radius: calc(var(--p) * var(--grc-sh-radius, 999px));\n" + |
| 71 | +" border-radius: 0;\n" + |
58 | 72 | " isolation: isolate;\n" + |
| 73 | +" box-shadow: 0 0 0 0 rgba(0,0,0,0);\n" + |
| 74 | +" transition:\n" + |
| 75 | +" max-width var(--grc-sh-dur, 320ms) var(--grc-sh-ease, cubic-bezier(0.2,0.8,0.2,1)),\n" + |
| 76 | +" border-radius var(--grc-sh-dur, 320ms) var(--grc-sh-ease, cubic-bezier(0.2,0.8,0.2,1)),\n" + |
| 77 | +" box-shadow var(--grc-sh-dur, 320ms) var(--grc-sh-ease, cubic-bezier(0.2,0.8,0.2,1));\n" + |
| 78 | +"}\n" + |
| 79 | +".grc-siteheader--floating .grc-siteheader__bar {\n" + |
| 80 | +" max-width: var(--grc-sh-contained, 1180px);\n" + |
| 81 | +" border-radius: var(--grc-sh-radius, 999px);\n" + |
| 82 | +" box-shadow: var(--shadow-xl);\n" + |
59 | 83 | "}\n" + |
60 | 84 | ".grc-siteheader__bar::before {\n" + |
61 | 85 | " content: \"\"; position: absolute; inset: 0; border-radius: inherit;\n" + |
62 | 86 | " background: var(--glass-fill-strong);\n" + |
63 | 87 | " -webkit-backdrop-filter: blur(var(--grc-sh-blur, 14px)) saturate(1.3);\n" + |
64 | 88 | " backdrop-filter: blur(var(--grc-sh-blur, 14px)) saturate(1.3);\n" + |
65 | | -" box-shadow: var(--glass-highlight), var(--shadow-lg);\n" + |
66 | | -" opacity: var(--p); pointer-events: none; z-index: 0;\n" + |
| 89 | +" box-shadow: var(--glass-highlight);\n" + |
| 90 | +" pointer-events: none; z-index: 0;\n" + |
67 | 91 | "}\n" + |
68 | 92 | "@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {\n" + |
69 | 93 | " .grc-siteheader__bar::before { background: var(--surface-card); }\n" + |
|
73 | 97 | " padding: 1px; background: var(--glass-border);\n" + |
74 | 98 | " -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);\n" + |
75 | 99 | " -webkit-mask-composite: xor; mask-composite: exclude;\n" + |
76 | | -" opacity: var(--p); pointer-events: none; z-index: 1;\n" + |
| 100 | +" opacity: 0; pointer-events: none; z-index: 1;\n" + |
| 101 | +" transition: opacity var(--grc-sh-dur, 320ms) var(--grc-sh-ease, cubic-bezier(0.2,0.8,0.2,1));\n" + |
77 | 102 | "}\n" + |
| 103 | +".grc-siteheader--floating .grc-siteheader__bar::after { opacity: 1; }\n" + |
78 | 104 | ".grc-siteheader__rule {\n" + |
79 | 105 | " position: absolute; left: 0; right: 0; bottom: 0; height: 1px;\n" + |
80 | | -" background: var(--border-default); opacity: calc(1 - var(--p));\n" + |
| 106 | +" background: var(--border-default); opacity: 1;\n" + |
81 | 107 | " pointer-events: none; z-index: 1;\n" + |
| 108 | +" transition: opacity var(--grc-sh-dur, 320ms) var(--grc-sh-ease, cubic-bezier(0.2,0.8,0.2,1));\n" + |
82 | 109 | "}\n" + |
| 110 | +".grc-siteheader--floating .grc-siteheader__rule { opacity: 0; }\n" + |
83 | 111 | ".grc-siteheader__inner {\n" + |
84 | 112 | " position: relative; z-index: 2; width: 100%;\n" + |
85 | | -" max-width: var(--grc-sh-contained, 1120px); height: 100%;\n" + |
| 113 | +" max-width: var(--grc-sh-contained, 1180px); height: 100%;\n" + |
86 | 114 | " display: flex; align-items: center; gap: var(--space-5);\n" + |
87 | 115 | " padding: 0 var(--space-7);\n" + |
88 | 116 | "}\n" + |
|
127 | 155 | ".grc-siteheader[data-measuring=\"1\"] .grc-siteheader__nav { display: flex !important; flex: none !important; }\n" + |
128 | 156 | ".grc-siteheader[data-measuring=\"1\"] .grc-siteheader__inner > .grc-siteheader__actions { display: flex !important; }\n" + |
129 | 157 | ".grc-siteheader[data-measuring=\"1\"] .grc-siteheader__menu-btn { display: none !important; }\n" + |
| 158 | +".grc-siteheader[data-measuring=\"1\"] .grc-siteheader__bar { transition: none !important; }\n" + |
130 | 159 | "@media (prefers-reduced-motion: reduce) {\n" + |
| 160 | +" .grc-siteheader, .grc-siteheader__bar, .grc-siteheader__bar::after, .grc-siteheader__rule { transition: none; }\n" + |
131 | 161 | " .grc-siteheader__panel { animation: none; }\n" + |
132 | 162 | "}\n"; |
133 | 163 | if (!document.getElementById("grc-siteheader-css")) { |
|
145 | 175 | return isFinite(n) ? n : fallback; |
146 | 176 | } |
147 | 177 |
|
148 | | - function smooth(t) { return t * t * (3 - 2 * t); } |
149 | | - |
150 | 178 | function mountOne(wrap) { |
151 | 179 | if (wrap.__grcSiteHeader) return; |
152 | 180 | var bar = wrap.querySelector(".grc-siteheader__bar"); |
|
157 | 185 | if (!bar || !inner) return; |
158 | 186 |
|
159 | 187 | var opts = { |
160 | | - containedWidth: attrNum(wrap, "data-contained-width", 1120), |
| 188 | + containedWidth: attrNum(wrap, "data-contained-width", 1180), |
161 | 189 | scrollDistance: attrNum(wrap, "data-scroll-distance", 88), |
162 | 190 | gap: attrNum(wrap, "data-gap", 14), |
163 | 191 | side: attrNum(wrap, "data-side", 22), |
|
172 | 200 | wrap.style.setProperty("--grc-sh-h", opts.height + "px"); |
173 | 201 | wrap.style.setProperty("--grc-sh-blur", opts.blur + "px"); |
174 | 202 | wrap.style.setProperty("--grc-sh-radius", opts.radius + "px"); |
| 203 | + wrap.style.setProperty("--grc-sh-dur", "var(--dur-slow, 320ms)"); |
| 204 | + wrap.style.setProperty("--grc-sh-ease", "var(--ease-out, cubic-bezier(0.2,0.8,0.2,1))"); |
175 | 205 |
|
176 | 206 | var compact = false, open = false, panel = null; |
177 | | - var lastP = -1, lastW = -1, raf = 0; |
| 207 | + // Threshold-triggered floating state with a small hysteresis band so the |
| 208 | + // snap doesn't flicker when the user parks the scroll right at the edge. |
| 209 | + var floating = false, raf = 0; |
| 210 | + var enterAt = Math.max(1, opts.scrollDistance); |
| 211 | + var exitAt = Math.max(0, opts.scrollDistance - Math.min(24, opts.scrollDistance * 0.35)); |
178 | 212 |
|
179 | 213 | function applyScroll() { |
180 | 214 | raf = 0; |
181 | 215 | var y = window.scrollY || window.pageYOffset || 0; |
182 | | - var p = opts.scrollDistance > 0 ? Math.min(Math.max(y / opts.scrollDistance, 0), 1) : (y > 0 ? 1 : 0); |
183 | | - p = smooth(p); |
184 | | - var vw = document.documentElement.clientWidth || window.innerWidth; |
185 | | - var maxw = Math.round(vw + (opts.containedWidth - vw) * p); |
186 | | - if (Math.abs(p - lastP) > 0.001) { wrap.style.setProperty("--p", p.toFixed(4)); lastP = p; } |
187 | | - if (maxw !== lastW) { bar.style.maxWidth = maxw + "px"; lastW = maxw; } |
| 216 | + var next = floating ? (y > exitAt) : (y >= enterAt); |
| 217 | + if (next !== floating) { |
| 218 | + floating = next; |
| 219 | + wrap.classList.toggle("grc-siteheader--floating", floating); |
| 220 | + } |
188 | 221 | } |
189 | 222 | function onScroll() { if (!raf) raf = requestAnimationFrame(applyScroll); } |
190 | 223 |
|
|
0 commit comments