Morph the masthead signal in JS instead of transition: d
Safari does not implement the CSS d property at all - it parses transition: d and then ignores it - so the signal snapped from one page's hash curve to the next on iPadOS while morphing smoothly on Chrome and Firefox. Feature detection is no help, since Safari does parse the property and answers CSS.supports yes, so drive the morph from JS on every browser and drop the CSS rule entirely: one code path, one result everywhere. Every signal is the same M + 31 C over the same 188 numbers, so two of them can be paired up positionally and lerped exactly rather than approximated. The guards leave the value htmx wrote alone whenever that does not hold, which is also why hugo.toml still disables SVG minification. The trigger is a MutationObserver on d rather than an htmx event. hx-boost wraps the swap in a View Transition, and htmx:after:swap only fires once that has finished - about 260ms after innerMorph rewrote the attribute - which showed up as two effects per navigation: the line snapping to the new curve with the swap, then jumping back to the old one to morph. The observer callback is a microtask, so it rewinds before the snap can paint, restoring the timing the CSS transition had.
5924473 parent: 73f7684 modified
blog/assets/css/style.scss +6 -6 | @@ -1360,12 +1360,12 @@ blockquote.alert { | ||
| 1360 | 1360 | stroke-dasharray: 100; |
| 1361 | 1361 | stroke-dashoffset: 100; |
| 1362 | 1362 | animation: signal-sweep 1.1s cubic-bezier(0.32, 0.5, 0.3, 1) 0.18s forwards; |
| 1363 | - // Every page's signal is the same number of curve commands (32 bytes -> | |
| 1364 | - // fixed M + 30 C), just different coordinates - so the browser can | |
| 1365 | - // interpolate straight from one `d` to the next when htmx morphs it in | |
| 1366 | - // on navigation, instead of snapping. Only fires on a same-node update; | |
| 1367 | - // a fresh page load has no prior `d` to transition from. | |
| 1368 | - transition: d 0.7s cubic-bezier(0.32, 0.5, 0.3, 1); | |
| 1363 | + // The page-to-page morph from one hash curve to the next used to be a | |
| 1364 | + // `transition: d` here, but Safari parses the CSS `d` property and then | |
| 1365 | + // ignores it, so the line snapped on iPadOS while flowing on Chrome. | |
| 1366 | + // It is interpolated in JS instead, on every browser alike - see the | |
| 1367 | + // signal-morph script in layouts/_default/baseof.html. Nothing in CSS | |
| 1368 | + // touches `d` any more; this rule is the first-load sweep only. | |
| 1369 | 1369 | } |
| 1370 | 1370 | } |
| 1371 | 1371 | |
| @@ -1360,12 +1360,12 @@ blockquote.alert { | |||
| 1360 | stroke-dasharray: 100; | 1360 | stroke-dasharray: 100; |
| 1361 | stroke-dashoffset: 100; | 1361 | stroke-dashoffset: 100; |
| 1362 | animation: signal-sweep 1.1s cubic-bezier(0.32, 0.5, 0.3, 1) 0.18s forwards; | 1362 | animation: signal-sweep 1.1s cubic-bezier(0.32, 0.5, 0.3, 1) 0.18s forwards; |
| 1363 | - // Every page's signal is the same number of curve commands (32 bytes -> | 1363 | + // The page-to-page morph from one hash curve to the next used to be a |
| 1364 | - // fixed M + 30 C), just different coordinates - so the browser can | 1364 | + // `transition: d` here, but Safari parses the CSS `d` property and then |
| 1365 | - // interpolate straight from one `d` to the next when htmx morphs it in | 1365 | + // ignores it, so the line snapped on iPadOS while flowing on Chrome. |
| 1366 | - // on navigation, instead of snapping. Only fires on a same-node update; | 1366 | + // It is interpolated in JS instead, on every browser alike - see the |
| 1367 | - // a fresh page load has no prior `d` to transition from. | 1367 | + // signal-morph script in layouts/_default/baseof.html. Nothing in CSS |
| 1368 | - transition: d 0.7s cubic-bezier(0.32, 0.5, 0.3, 1); | 1368 | + // touches `d` any more; this rule is the first-load sweep only. |
| 1369 | } | 1369 | } |
| 1370 | } | 1370 | } |
| 1371 | 1371 | ||
modified
blog/hugo.toml +8 -6 | @@ -19,12 +19,14 @@ defaultContentLanguage = "en" | ||
| 19 | 19 | # numbers. Geometry is preserved; the command sequence is not - and two |
| 20 | 20 | # things here read the commands, not just the shape: |
| 21 | 21 | # |
| 22 | -# - partials/masthead.html's signal. style.scss transitions `d` so the | |
| 23 | -# curve morphs point-by-point on a boosted navigation, which the browser | |
| 24 | -# will only do between paths with an identical command sequence. The | |
| 25 | -# template emits a fixed M + 30 C per page, but minified they came out | |
| 26 | -# as e.g. `McCcCcCsC` (home) vs `MCcCcCcsCcCSsCcCcCsC` (a post), so | |
| 27 | -# Chrome could not interpolate and the line snapped instead. | |
| 22 | +# - partials/masthead.html's signal. The signal-morph script in | |
| 23 | +# _default/baseof.html interpolates `d` coordinate by coordinate so the | |
| 24 | +# curve morphs on a boosted navigation, which only works between paths | |
| 25 | +# with an identical command sequence. The template emits a fixed M + 31 C | |
| 26 | +# per page, but minified they came out as e.g. `McCcCcCsC` (home) vs | |
| 27 | +# `MCcCcCcsCcCSsCcCcCsC` (a post) - nothing to pair up, so the line | |
| 28 | +# snapped instead. (This started as a CSS `transition: d`, which Safari | |
| 29 | +# silently ignores; the same constraint applies either way.) | |
| 28 | 30 | # - shortcodes/hello-world.html's plate, whose script parses the word out |
| 29 | 31 | # of its own `d` with /M(\d+) (\d+)h(\d+)v(\d+)/. Minified, `M13 0h10v3h-10z` |
| 30 | 32 | # becomes `m5 0h10v3H13z` and only 22 of the 61 rectangles still matched, |
| @@ -19,12 +19,14 @@ defaultContentLanguage = "en" | |||
| 19 | # numbers. Geometry is preserved; the command sequence is not - and two | 19 | # numbers. Geometry is preserved; the command sequence is not - and two |
| 20 | # things here read the commands, not just the shape: | 20 | # things here read the commands, not just the shape: |
| 21 | # | 21 | # |
| 22 | -# - partials/masthead.html's signal. style.scss transitions `d` so the | 22 | +# - partials/masthead.html's signal. The signal-morph script in |
| 23 | -# curve morphs point-by-point on a boosted navigation, which the browser | 23 | +# _default/baseof.html interpolates `d` coordinate by coordinate so the |
| 24 | -# will only do between paths with an identical command sequence. The | 24 | +# curve morphs on a boosted navigation, which only works between paths |
| 25 | -# template emits a fixed M + 30 C per page, but minified they came out | 25 | +# with an identical command sequence. The template emits a fixed M + 31 C |
| 26 | -# as e.g. `McCcCcCsC` (home) vs `MCcCcCcsCcCSsCcCcCsC` (a post), so | 26 | +# per page, but minified they came out as e.g. `McCcCcCsC` (home) vs |
| 27 | -# Chrome could not interpolate and the line snapped instead. | 27 | +# `MCcCcCcsCcCSsCcCcCsC` (a post) - nothing to pair up, so the line |
| 28 | +# snapped instead. (This started as a CSS `transition: d`, which Safari | ||
| 29 | +# silently ignores; the same constraint applies either way.) | ||
| 28 | # - shortcodes/hello-world.html's plate, whose script parses the word out | 30 | # - shortcodes/hello-world.html's plate, whose script parses the word out |
| 29 | # of its own `d` with /M(\d+) (\d+)h(\d+)v(\d+)/. Minified, `M13 0h10v3h-10z` | 31 | # of its own `d` with /M(\d+) (\d+)h(\d+)v(\d+)/. Minified, `M13 0h10v3h-10z` |
| 30 | # becomes `m5 0h10v3H13z` and only 22 of the 61 rectangles still matched, | 32 | # becomes `m5 0h10v3H13z` and only 22 of the 61 rectangles still matched, |
modified
blog/layouts/_default/baseof.html +95 -0 | @@ -116,6 +116,101 @@ | ||
| 116 | 116 | }); |
| 117 | 117 | }); |
| 118 | 118 | </script> |
| 119 | + {{- /* | |
| 120 | + Safari does not implement the CSS `d` property at all - it parses | |
| 121 | + `transition: d` and then ignores it (MDN's compat note: "The property | |
| 122 | + parses, but has no effect"), on iPadOS and macOS alike. So the masthead | |
| 123 | + signal, which should flow from one page's SHA-256 curve into the next's | |
| 124 | + when innerMorph rewrites the path's `d`, snapped instantly on an iPad | |
| 125 | + while morphing smoothly on Chrome and Firefox. | |
| 126 | + | |
| 127 | + Feature detection is no help precisely because Safari *does* parse the | |
| 128 | + property, so CSS.supports("d", ...) answers yes. Rather than keep two | |
| 129 | + behaviours, the morph is driven from here on every browser and | |
| 130 | + style.scss no longer declares `transition: d` at all - one code path, | |
| 131 | + one result everywhere. | |
| 132 | + | |
| 133 | + The trigger is the attribute write itself, watched with a | |
| 134 | + MutationObserver, and not an htmx event. That is not a stylistic | |
| 135 | + preference: hx-boost wraps the swap in a View Transition, and | |
| 136 | + `htmx:after:swap` only fires once that transition has finished - about | |
| 137 | + 260ms after innerMorph actually rewrote `d`. Starting there produced two | |
| 138 | + visible effects per navigation, the line snapping to the new curve with | |
| 139 | + the swap and then jumping back to the old one to morph. The CSS | |
| 140 | + transition this replaces had no such gap because it keyed off the | |
| 141 | + attribute, so the observer restores the original timing. It also covers | |
| 142 | + back/forward, which reaches the path through a different code path | |
| 143 | + entirely, without either needing to know about the other. | |
| 144 | + | |
| 145 | + Interpolating is a plain lerp of the numbers in `d`: every signal is the | |
| 146 | + same M + 31 C (32 hash bytes plotted, one curve per gap) and so the same | |
| 147 | + 188 numbers in the same order, which makes pairing them up positionally | |
| 148 | + exact rather than an approximation. The guards below leave the value | |
| 149 | + htmx wrote alone whenever that does not hold - which is also why | |
| 150 | + hugo.toml turns off SVG minification, since the minifier rewrites the | |
| 151 | + command sequence. Should a swap ever replace the <path> outright rather | |
| 152 | + than patch it, there is no attribute mutation to see and the curve | |
| 153 | + simply snaps, the same graceful nothing Safari used to get. | |
| 154 | + | |
| 155 | + No DOMContentLoaded wrapper, unlike the script above: this one never | |
| 156 | + touches `htmx` itself, so the observer is safe to attach during parsing. | |
| 157 | + */}} | |
| 158 | + <script> | |
| 159 | + (() => { | |
| 160 | + const SIGNAL = ".masthead__signal path", DUR = 700; | |
| 161 | + const NUM = /-?\d*\.?\d+/g; | |
| 162 | + const still = matchMedia("(prefers-reduced-motion: reduce)"); | |
| 163 | + // cubic-bezier(0.32, 0.5, 0.3, 1), the curve signal-sweep and .rise | |
| 164 | + // already use: bisect x(t) to invert it, then read y at that t. | |
| 165 | + const ease = (p) => { | |
| 166 | + const at = (c1, c2, t) => 3 * (1 - t) ** 2 * t * c1 + 3 * (1 - t) * t * t * c2 + t ** 3; | |
| 167 | + let lo = 0, hi = 1, t = p; | |
| 168 | + for (let i = 0; i < 18; i++) { t = (lo + hi) / 2; at(0.32, 0.3, t) < p ? (lo = t) : (hi = t); } | |
| 169 | + return at(0.5, 1, t); | |
| 170 | + }; | |
| 171 | + | |
| 172 | + let raf = 0, mine = null; | |
| 173 | + const morph = (path, from, to) => { | |
| 174 | + const a = from.match(NUM), b = to.match(NUM); | |
| 175 | + if (!a || !b || a.length !== b.length || from.replace(NUM, "") !== to.replace(NUM, "")) return; | |
| 176 | + // tpl is the commands and separators with the numbers cut out - one | |
| 177 | + // more piece than there are numbers, so tpl[0] + n[0] + tpl[1] + ... | |
| 178 | + // reassembles the path around a fresh set of coordinates. | |
| 179 | + const tpl = to.split(NUM), n0 = a.map(Number), n1 = b.map(Number); | |
| 180 | + const build = (k) => { | |
| 181 | + let d = ""; | |
| 182 | + for (let i = 0; i < tpl.length; i++) d += tpl[i] + (i < n0.length ? (n0[i] + (n1[i] - n0[i]) * k).toFixed(2) : ""); | |
| 183 | + return d; | |
| 184 | + }; | |
| 185 | + // Every write is remembered so the observer below can tell our own | |
| 186 | + // 40-odd frames from the one write that started them. | |
| 187 | + const set = (v) => { mine = v; path.setAttribute("d", v); }; | |
| 188 | + const t0 = performance.now(); | |
| 189 | + const step = (now) => { | |
| 190 | + const k = Math.min(1, (now - t0) / DUR); | |
| 191 | + // Land on the server's own string, never a rounded rebuild of it. | |
| 192 | + set(k < 1 ? build(ease(k)) : to); | |
| 193 | + raf = k < 1 ? requestAnimationFrame(step) : 0; | |
| 194 | + }; | |
| 195 | + cancelAnimationFrame(raf); | |
| 196 | + set(build(0)); // htmx already wrote `to`; rewind before anything paints | |
| 197 | + raf = requestAnimationFrame(step); | |
| 198 | + }; | |
| 199 | + | |
| 200 | + new MutationObserver((records) => { | |
| 201 | + if (still.matches) return; | |
| 202 | + for (const r of records) { | |
| 203 | + const path = r.target, to = path.getAttribute?.("d"); | |
| 204 | + if (to == null || r.oldValue == null || to === mine || to === r.oldValue) continue; | |
| 205 | + if (!path.matches?.(SIGNAL)) continue; | |
| 206 | + morph(path, r.oldValue, to); | |
| 207 | + return; | |
| 208 | + } | |
| 209 | + }).observe(document.documentElement, { | |
| 210 | + subtree: true, attributes: true, attributeFilter: ["d"], attributeOldValue: true, | |
| 211 | + }); | |
| 212 | + })(); | |
| 213 | + </script> | |
| 119 | 214 | {{ block "head" . }}{{ end }} |
| 120 | 215 | </head> |
| 121 | 216 | {{- /* |
| @@ -116,6 +116,101 @@ | |||
| 116 | }); | 116 | }); |
| 117 | }); | 117 | }); |
| 118 | </script> | 118 | </script> |
| 119 | + {{- /* | ||
| 120 | + Safari does not implement the CSS `d` property at all - it parses | ||
| 121 | + `transition: d` and then ignores it (MDN's compat note: "The property | ||
| 122 | + parses, but has no effect"), on iPadOS and macOS alike. So the masthead | ||
| 123 | + signal, which should flow from one page's SHA-256 curve into the next's | ||
| 124 | + when innerMorph rewrites the path's `d`, snapped instantly on an iPad | ||
| 125 | + while morphing smoothly on Chrome and Firefox. | ||
| 126 | + | ||
| 127 | + Feature detection is no help precisely because Safari *does* parse the | ||
| 128 | + property, so CSS.supports("d", ...) answers yes. Rather than keep two | ||
| 129 | + behaviours, the morph is driven from here on every browser and | ||
| 130 | + style.scss no longer declares `transition: d` at all - one code path, | ||
| 131 | + one result everywhere. | ||
| 132 | + | ||
| 133 | + The trigger is the attribute write itself, watched with a | ||
| 134 | + MutationObserver, and not an htmx event. That is not a stylistic | ||
| 135 | + preference: hx-boost wraps the swap in a View Transition, and | ||
| 136 | + `htmx:after:swap` only fires once that transition has finished - about | ||
| 137 | + 260ms after innerMorph actually rewrote `d`. Starting there produced two | ||
| 138 | + visible effects per navigation, the line snapping to the new curve with | ||
| 139 | + the swap and then jumping back to the old one to morph. The CSS | ||
| 140 | + transition this replaces had no such gap because it keyed off the | ||
| 141 | + attribute, so the observer restores the original timing. It also covers | ||
| 142 | + back/forward, which reaches the path through a different code path | ||
| 143 | + entirely, without either needing to know about the other. | ||
| 144 | + | ||
| 145 | + Interpolating is a plain lerp of the numbers in `d`: every signal is the | ||
| 146 | + same M + 31 C (32 hash bytes plotted, one curve per gap) and so the same | ||
| 147 | + 188 numbers in the same order, which makes pairing them up positionally | ||
| 148 | + exact rather than an approximation. The guards below leave the value | ||
| 149 | + htmx wrote alone whenever that does not hold - which is also why | ||
| 150 | + hugo.toml turns off SVG minification, since the minifier rewrites the | ||
| 151 | + command sequence. Should a swap ever replace the <path> outright rather | ||
| 152 | + than patch it, there is no attribute mutation to see and the curve | ||
| 153 | + simply snaps, the same graceful nothing Safari used to get. | ||
| 154 | + | ||
| 155 | + No DOMContentLoaded wrapper, unlike the script above: this one never | ||
| 156 | + touches `htmx` itself, so the observer is safe to attach during parsing. | ||
| 157 | + */}} | ||
| 158 | + <script> | ||
| 159 | + (() => { | ||
| 160 | + const SIGNAL = ".masthead__signal path", DUR = 700; | ||
| 161 | + const NUM = /-?\d*\.?\d+/g; | ||
| 162 | + const still = matchMedia("(prefers-reduced-motion: reduce)"); | ||
| 163 | + // cubic-bezier(0.32, 0.5, 0.3, 1), the curve signal-sweep and .rise | ||
| 164 | + // already use: bisect x(t) to invert it, then read y at that t. | ||
| 165 | + const ease = (p) => { | ||
| 166 | + const at = (c1, c2, t) => 3 * (1 - t) ** 2 * t * c1 + 3 * (1 - t) * t * t * c2 + t ** 3; | ||
| 167 | + let lo = 0, hi = 1, t = p; | ||
| 168 | + for (let i = 0; i < 18; i++) { t = (lo + hi) / 2; at(0.32, 0.3, t) < p ? (lo = t) : (hi = t); } | ||
| 169 | + return at(0.5, 1, t); | ||
| 170 | + }; | ||
| 171 | + | ||
| 172 | + let raf = 0, mine = null; | ||
| 173 | + const morph = (path, from, to) => { | ||
| 174 | + const a = from.match(NUM), b = to.match(NUM); | ||
| 175 | + if (!a || !b || a.length !== b.length || from.replace(NUM, "") !== to.replace(NUM, "")) return; | ||
| 176 | + // tpl is the commands and separators with the numbers cut out - one | ||
| 177 | + // more piece than there are numbers, so tpl[0] + n[0] + tpl[1] + ... | ||
| 178 | + // reassembles the path around a fresh set of coordinates. | ||
| 179 | + const tpl = to.split(NUM), n0 = a.map(Number), n1 = b.map(Number); | ||
| 180 | + const build = (k) => { | ||
| 181 | + let d = ""; | ||
| 182 | + for (let i = 0; i < tpl.length; i++) d += tpl[i] + (i < n0.length ? (n0[i] + (n1[i] - n0[i]) * k).toFixed(2) : ""); | ||
| 183 | + return d; | ||
| 184 | + }; | ||
| 185 | + // Every write is remembered so the observer below can tell our own | ||
| 186 | + // 40-odd frames from the one write that started them. | ||
| 187 | + const set = (v) => { mine = v; path.setAttribute("d", v); }; | ||
| 188 | + const t0 = performance.now(); | ||
| 189 | + const step = (now) => { | ||
| 190 | + const k = Math.min(1, (now - t0) / DUR); | ||
| 191 | + // Land on the server's own string, never a rounded rebuild of it. | ||
| 192 | + set(k < 1 ? build(ease(k)) : to); | ||
| 193 | + raf = k < 1 ? requestAnimationFrame(step) : 0; | ||
| 194 | + }; | ||
| 195 | + cancelAnimationFrame(raf); | ||
| 196 | + set(build(0)); // htmx already wrote `to`; rewind before anything paints | ||
| 197 | + raf = requestAnimationFrame(step); | ||
| 198 | + }; | ||
| 199 | + | ||
| 200 | + new MutationObserver((records) => { | ||
| 201 | + if (still.matches) return; | ||
| 202 | + for (const r of records) { | ||
| 203 | + const path = r.target, to = path.getAttribute?.("d"); | ||
| 204 | + if (to == null || r.oldValue == null || to === mine || to === r.oldValue) continue; | ||
| 205 | + if (!path.matches?.(SIGNAL)) continue; | ||
| 206 | + morph(path, r.oldValue, to); | ||
| 207 | + return; | ||
| 208 | + } | ||
| 209 | + }).observe(document.documentElement, { | ||
| 210 | + subtree: true, attributes: true, attributeFilter: ["d"], attributeOldValue: true, | ||
| 211 | + }); | ||
| 212 | + })(); | ||
| 213 | + </script> | ||
| 119 | {{ block "head" . }}{{ end }} | 214 | {{ block "head" . }}{{ end }} |
| 120 | </head> | 215 | </head> |
| 121 | {{- /* | 216 | {{- /* |