Skip to content

Commit 226bcad

Browse files
committed
fix(npf): threshold-triggered SiteHeader morph with docked glass, drop shadow, wider pill
Rework the scroll-morph header from a continuous scroll-linked interpolation to a threshold-triggered two-state morph (cocoindex.io model): - Glass fill + backdrop blur are now present in the docked state (were tied to opacity: var(--p), so gone at the top of the page). - Docked -> floating morph toggles a single .grc-siteheader--floating class once a scroll threshold (88px, hysteresis enter 88 / exit 64) is crossed; every property animates via CSS transition (--dur-slow / --ease-out) so the chrome morphs fully in one shot instead of seaming step-by-step with scroll. - Floating pill gains a --shadow-xl drop shadow. - data-contained-width 1080 -> 1180 so the pill is noticeably wider than the page's --container-lg (1080px) glass boxes. Auto-collapse/measure and menu-panel logic unchanged.
1 parent 6aa5f01 commit 226bcad

3 files changed

Lines changed: 93 additions & 36 deletions

File tree

docs/projects/nthpartyfinder/ds/PROVENANCE.txt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,27 @@ float`. Factored a shared `PRECISION_HEADER` const (matching the canvas)
5050
and applied it to SPHERE_FS/PULSE_FS/LINE_FS uniformly — closes the one
5151
real drift found; the frustum-fill/AA tuning superiority documented above
5252
is unchanged and was NOT touched.
53+
54+
SiteHeader morph reworked 2026-07-15 (owner feedback from live iPhone
55+
screenshots): the first port drove the docked→floating morph off a
56+
continuous `--p = scrollY/scrollDistance` variable, which caused three
57+
defects — (1) the glass fill/blur was tied to `opacity: var(--p)` so it was
58+
GONE when docked at the top; (2) the shape interpolated per-scroll-pixel, so
59+
the transition looked seamy and stepped in sync with the wheel instead of
60+
snapping; (3) no drop shadow under the pill; (4) the pill matched the page's
61+
1080px content width so it didn't read as wider. Rebuilt to the cocoindex.io
62+
model: a single `.grc-siteheader--floating` class toggles once a scroll
63+
THRESHOLD (data-scroll-distance, 88px) is crossed — with a hysteresis band
64+
(enter 88 / exit 64) to prevent boundary flicker — and every visual property
65+
(max-width, border-radius, box-shadow, gap padding, ring/rule opacity)
66+
carries a CSS `transition` (--dur-slow 320ms / --ease-out) so the whole chrome
67+
morphs FULLY in one shot. Docked = full-bleed frosted bar (glass ALWAYS
68+
present, square corners, bottom hairline rule). Floating = contained glass
69+
pill (full corners, gradient hairline ring, --shadow-xl drop shadow).
70+
data-contained-width raised 1080 → 1180 so the pill is noticeably wider than
71+
the page's --container-lg (1080px) glass boxes. Auto-collapse/measure and
72+
menu-panel logic unchanged. Verified: node --check clean; threshold state
73+
machine unit-checked (binary snap + hysteresis, not continuous); CSS
74+
invariants asserted. Rendered-pixel confirmation deferred to the live site —
75+
both browser-automation paths (Interceptor, claude-in-chrome) were
76+
unreachable in this background session.

docs/projects/nthpartyfinder/ds/site-header.js

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
11
/* ============================================================
22
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):
106
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
1422
controller (same shape as ds/node-graph.js). The component's auto-collapse
1523
behavior (measures the real layout against the floating pill width, and
1624
switches to a menu button whenever it wouldn't fit) is preserved so any
1725
future nav-link edit stays correct without touching a hardcoded breakpoint.
1826
1927
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">
2129
<div class="grc-siteheader__bar">
2230
<div class="grc-siteheader__rule"></div>
2331
<div class="grc-siteheader__inner">
@@ -29,10 +37,12 @@
2937
</div>
3038
</header>
3139
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).
3646
============================================================ */
3747
(function () {
3848
"use strict";
@@ -42,28 +52,42 @@
4252
".grc-siteheader {\n" +
4353
" position: fixed; top: 0; left: 0; right: 0; z-index: 50;\n" +
4454
" 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" +
4956
" pointer-events: none;\n" +
5057
" 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" +
5164
"}\n" +
5265
".grc-siteheader__bar {\n" +
5366
" position: relative; pointer-events: auto; box-sizing: border-box;\n" +
5467
" width: 100%;\n" +
68+
" max-width: var(--grc-sh-maxw-docked, 6000px);\n" +
5569
" height: var(--grc-sh-h, 64px);\n" +
5670
" display: flex; justify-content: center;\n" +
57-
" border-radius: calc(var(--p) * var(--grc-sh-radius, 999px));\n" +
71+
" border-radius: 0;\n" +
5872
" 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" +
5983
"}\n" +
6084
".grc-siteheader__bar::before {\n" +
6185
" content: \"\"; position: absolute; inset: 0; border-radius: inherit;\n" +
6286
" background: var(--glass-fill-strong);\n" +
6387
" -webkit-backdrop-filter: blur(var(--grc-sh-blur, 14px)) saturate(1.3);\n" +
6488
" 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" +
6791
"}\n" +
6892
"@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {\n" +
6993
" .grc-siteheader__bar::before { background: var(--surface-card); }\n" +
@@ -73,16 +97,20 @@
7397
" padding: 1px; background: var(--glass-border);\n" +
7498
" -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);\n" +
7599
" -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" +
77102
"}\n" +
103+
".grc-siteheader--floating .grc-siteheader__bar::after { opacity: 1; }\n" +
78104
".grc-siteheader__rule {\n" +
79105
" 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" +
81107
" 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" +
82109
"}\n" +
110+
".grc-siteheader--floating .grc-siteheader__rule { opacity: 0; }\n" +
83111
".grc-siteheader__inner {\n" +
84112
" 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" +
86114
" display: flex; align-items: center; gap: var(--space-5);\n" +
87115
" padding: 0 var(--space-7);\n" +
88116
"}\n" +
@@ -127,7 +155,9 @@
127155
".grc-siteheader[data-measuring=\"1\"] .grc-siteheader__nav { display: flex !important; flex: none !important; }\n" +
128156
".grc-siteheader[data-measuring=\"1\"] .grc-siteheader__inner > .grc-siteheader__actions { display: flex !important; }\n" +
129157
".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" +
130159
"@media (prefers-reduced-motion: reduce) {\n" +
160+
" .grc-siteheader, .grc-siteheader__bar, .grc-siteheader__bar::after, .grc-siteheader__rule { transition: none; }\n" +
131161
" .grc-siteheader__panel { animation: none; }\n" +
132162
"}\n";
133163
if (!document.getElementById("grc-siteheader-css")) {
@@ -145,8 +175,6 @@
145175
return isFinite(n) ? n : fallback;
146176
}
147177

148-
function smooth(t) { return t * t * (3 - 2 * t); }
149-
150178
function mountOne(wrap) {
151179
if (wrap.__grcSiteHeader) return;
152180
var bar = wrap.querySelector(".grc-siteheader__bar");
@@ -157,7 +185,7 @@
157185
if (!bar || !inner) return;
158186

159187
var opts = {
160-
containedWidth: attrNum(wrap, "data-contained-width", 1120),
188+
containedWidth: attrNum(wrap, "data-contained-width", 1180),
161189
scrollDistance: attrNum(wrap, "data-scroll-distance", 88),
162190
gap: attrNum(wrap, "data-gap", 14),
163191
side: attrNum(wrap, "data-side", 22),
@@ -172,19 +200,24 @@
172200
wrap.style.setProperty("--grc-sh-h", opts.height + "px");
173201
wrap.style.setProperty("--grc-sh-blur", opts.blur + "px");
174202
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))");
175205

176206
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));
178212

179213
function applyScroll() {
180214
raf = 0;
181215
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+
}
188221
}
189222
function onScroll() { if (!raf) raf = requestAnimationFrame(applyScroll); }
190223

docs/projects/nthpartyfinder/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@
389389
<body>
390390

391391
<!-- NAV — GRC Engineering SiteHeader (scroll-morph: flush bar → floating glass pill on scroll) -->
392-
<header class="grc-siteheader" data-grc-siteheader data-scroll-distance="88" data-contained-width="1080">
392+
<header class="grc-siteheader" data-grc-siteheader data-scroll-distance="88" data-contained-width="1180">
393393
<div class="grc-siteheader__bar">
394394
<div class="grc-siteheader__rule"></div>
395395
<div class="grc-siteheader__inner">

0 commit comments

Comments
 (0)