mykiwi/mykiwi.blogpublic Fork
⑂ 5924473
Commits
⬇ Clone ▾
git clone https://git.rickub.com/mykiwi/mykiwi.blog.git
git clone ssh://git@rickub.com/mykiwi/mykiwi.blog.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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.
Romain Gautier committed 2026-09-20T12:50:19+02:00 Browse files
5924473 parent: 73f7684
modified blog/assets/css/style.scss +6 -6
@@ -1360,12 +1360,12 @@ blockquote.alert {
13601360 stroke-dasharray: 100;
13611361 stroke-dashoffset: 100;
13621362 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.
13691369 }
13701370 }
13711371
@@ -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 can1364+ // `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 in1365+ // 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"
1919 # numbers. Geometry is preserved; the command sequence is not - and two
2020 # things here read the commands, not just the shape:
2121 #
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.)
2830 # - shortcodes/hello-world.html's plate, whose script parses the word out
2931 # of its own `d` with /M(\d+) (\d+)h(\d+)v(\d+)/. Minified, `M13 0h10v3h-10z`
3032 # 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 two19 # 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 the22+# - partials/masthead.html's signal. The signal-morph script in
23-# curve morphs point-by-point on a boosted navigation, which the browser23+# _default/baseof.html interpolates `d` coordinate by coordinate so the
24-# will only do between paths with an identical command sequence. The24+# 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 out25+# with an identical command sequence. The template emits a fixed M + 31 C
26-# as e.g. `McCcCcCsC` (home) vs `MCcCcCcsCcCSsCcCcCsC` (a post), so26+# 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 out30 # - 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 @@
116116 });
117117 });
118118 </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>
119214 {{ block "head" . }}{{ end }}
120215 </head>
121216 {{- /*
@@ -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 {{- /*