animate-text-weight 0.1.0 → 1.1.0

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.
@@ -0,0 +1,12 @@
1
+ export interface WaveNavOptions {
2
+ /** Toggled on the current link, as well as aria-current. */
3
+ linkClass?: string;
4
+ /** Toggled on the current link's section, which aria-current cannot mark. */
5
+ sectionClass?: string;
6
+ }
7
+ /**
8
+ * Mark the link of the section across the middle of the screen with
9
+ * aria-current="true". Each link's section is the element its hash names;
10
+ * a link without one is left out. Returns a function that stops following.
11
+ */
12
+ export declare function waveNav(links: Iterable<HTMLAnchorElement>, options?: WaveNavOptions): () => void;
@@ -0,0 +1,96 @@
1
+ /*
2
+ * waveNav: a nav whose links follow the page, one section at a time.
3
+ *
4
+ * The current section is the one across the middle of the screen, worked out
5
+ * afresh from where things are on every scroll, never from what changed: a
6
+ * jump by Home or End passes every section in a few frames, and a switch that
7
+ * trusts enter and exit events is left on by the one that goes missing.
8
+ *
9
+ * A jump does not skip what it passes. The switch walks there one section at
10
+ * a time, each link on and then off again, and takes the next step once the
11
+ * wave has crossed the link it just switched on: its delay once per unit. So
12
+ * the links pick the wave up one from the other, and a jump sends one wave
13
+ * through the whole nav rather than a small one through each link.
14
+ *
15
+ * Optional, and apart from the library: nothing in index.ts imports it.
16
+ */
17
+ import { UNIT } from "./index.js";
18
+ /** At least a frame per step, so no link goes on and off before it is drawn. */
19
+ const MIN_STEP = 17;
20
+ /**
21
+ * Mark the link of the section across the middle of the screen with
22
+ * aria-current="true". Each link's section is the element its hash names;
23
+ * a link without one is left out. Returns a function that stops following.
24
+ */
25
+ export function waveNav(links, options = {}) {
26
+ const pairs = [...links].flatMap((link) => {
27
+ const section = document.getElementById(link.hash.slice(1));
28
+ return section ? [{ link, section }] : [];
29
+ });
30
+ function setCurrent(index, on) {
31
+ const pair = pairs[index];
32
+ if (!pair)
33
+ return;
34
+ if (on)
35
+ pair.link.setAttribute("aria-current", "true");
36
+ else
37
+ pair.link.removeAttribute("aria-current");
38
+ if (options.linkClass)
39
+ pair.link.classList.toggle(options.linkClass, on);
40
+ if (options.sectionClass)
41
+ pair.section.classList.toggle(options.sectionClass, on);
42
+ }
43
+ /** The section across the middle, by index: -1 above the first, the length past the last. */
44
+ function getIndex() {
45
+ const middle = innerHeight / 2;
46
+ const below = pairs.findIndex(({ section }) => section.getBoundingClientRect().bottom > middle);
47
+ if (below === -1)
48
+ return pairs.length;
49
+ return pairs[below].section.getBoundingClientRect().top <= middle
50
+ ? below
51
+ : below - 1;
52
+ }
53
+ /** How long the wave takes to cross a link, in ms: its delay once per unit. */
54
+ function getCrossing(link) {
55
+ const delay = getComputedStyle(link)
56
+ .getPropertyValue("--animate-weight-delay")
57
+ .trim();
58
+ const milliseconds = parseFloat(delay) * (delay.endsWith("ms") ? 1 : 1000);
59
+ return link.querySelectorAll(`.${UNIT}`).length * milliseconds || 0;
60
+ }
61
+ let current = getIndex();
62
+ let target = current;
63
+ // The next step, while the wave is still crossing the last link.
64
+ let timer = 0;
65
+ setCurrent(current, true);
66
+ /** One section nearer the target, and the next once the wave is across. */
67
+ function update() {
68
+ timer = 0;
69
+ if (current === target)
70
+ return;
71
+ const next = current + Math.sign(target - current);
72
+ setCurrent(current, false);
73
+ setCurrent(next, true);
74
+ current = next;
75
+ const link = pairs[next]?.link;
76
+ timer = setTimeout(update, Math.max(MIN_STEP, link ? getCrossing(link) : 0));
77
+ }
78
+ function handleMove() {
79
+ target = getIndex();
80
+ if (!timer)
81
+ update();
82
+ }
83
+ // Scroll events already come once a frame. A section that changes height
84
+ // moves the ones below it without a scroll, and a resize moves the middle.
85
+ addEventListener("scroll", handleMove, { passive: true });
86
+ addEventListener("resize", handleMove);
87
+ const observer = new ResizeObserver(handleMove);
88
+ for (const { section } of pairs)
89
+ observer.observe(section);
90
+ return () => {
91
+ removeEventListener("scroll", handleMove);
92
+ removeEventListener("resize", handleMove);
93
+ observer.disconnect();
94
+ clearTimeout(timer);
95
+ };
96
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Where a pointer element's two waves stand, and where a crossing sends them.
3
+ *
4
+ * The leading wave brings the weight up and the trailing one takes it away, so
5
+ * the text is at full weight between the two and a band of weight crosses it.
6
+ * Which way round the pair is travelling is the whole of the state: a visit
7
+ * runs them both to one end, the next visit runs them home again, and nothing
8
+ * is ever put back.
9
+ *
10
+ * Kept apart from the element it is written on, and out of the package's
11
+ * exports, because what it has to get right is arithmetic and not animation.
12
+ * A crossing that lands mid-band cannot be ordered against the frame the waves
13
+ * are on, so the rest it comes to must be right whatever they were doing: an
14
+ * entry always leaves the pair a whole crossing apart, which reads as full
15
+ * weight, and a leaving always brings them together, which reads as none. The
16
+ * waves decide how the text gets there and never whether.
17
+ */
18
+ /** An end of the text, the way the stylesheet counts: 0 the start, 1 the far one. */
19
+ export type End = "0" | "1";
20
+ /** Which way round the pair is running: up from the start, or back down. */
21
+ export type Way = "1" | "-1";
22
+ /** Where each wave is headed, and which way round the two of them are going. */
23
+ export interface Waves {
24
+ in: End;
25
+ out: End;
26
+ way: Way;
27
+ }
28
+ /** The other one of the two. */
29
+ export declare const other: (end: End) => End;
30
+ /**
31
+ * The end both waves are headed for, which is the one they are not resting at.
32
+ * Every visit runs them from the one to the other.
33
+ */
34
+ export declare const target: (way: Way) => End;
35
+ /**
36
+ * A pointer arriving: leading wave to the far end, trailing wave home.
37
+ *
38
+ * Both, every time, although one of the two is always already there. On a
39
+ * fresh visit the trailing wave is home and only the leading one moves, which
40
+ * opens the band; mid-band the leading one has arrived and only the trailing
41
+ * one moves, which sends the weight back in behind the pointer. Saying only
42
+ * the half that is needed means guessing which half that is, and a guess that
43
+ * misses writes a value already in place, which does nothing at all.
44
+ */
45
+ export declare const entered: (waves: Waves) => Waves;
46
+ /** A pointer leaving: the trailing wave after the leading one, to the same end. */
47
+ export declare const left: (waves: Waves) => Waves;
48
+ /**
49
+ * The pair turned round, ready for the next visit.
50
+ *
51
+ * Only where the two were sent to the same end, which is what a band that has
52
+ * come off leaves behind and the only state this may read. A pointer sitting
53
+ * on the text holds them at opposite ends, and turning the pair round under
54
+ * that would let go of all of it at once.
55
+ */
56
+ export declare const settled: (waves: Waves) => Waves;
57
+ /** What the text weighs once both waves have arrived: 1 all of it, 0 none. */
58
+ export declare const weight: (waves: Waves) => number;
package/dist/waves.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Where a pointer element's two waves stand, and where a crossing sends them.
3
+ *
4
+ * The leading wave brings the weight up and the trailing one takes it away, so
5
+ * the text is at full weight between the two and a band of weight crosses it.
6
+ * Which way round the pair is travelling is the whole of the state: a visit
7
+ * runs them both to one end, the next visit runs them home again, and nothing
8
+ * is ever put back.
9
+ *
10
+ * Kept apart from the element it is written on, and out of the package's
11
+ * exports, because what it has to get right is arithmetic and not animation.
12
+ * A crossing that lands mid-band cannot be ordered against the frame the waves
13
+ * are on, so the rest it comes to must be right whatever they were doing: an
14
+ * entry always leaves the pair a whole crossing apart, which reads as full
15
+ * weight, and a leaving always brings them together, which reads as none. The
16
+ * waves decide how the text gets there and never whether.
17
+ */
18
+ /** The other one of the two. */
19
+ export const other = (end) => (end === "1" ? "0" : "1");
20
+ /**
21
+ * The end both waves are headed for, which is the one they are not resting at.
22
+ * Every visit runs them from the one to the other.
23
+ */
24
+ export const target = (way) => (way === "-1" ? "0" : "1");
25
+ /**
26
+ * A pointer arriving: leading wave to the far end, trailing wave home.
27
+ *
28
+ * Both, every time, although one of the two is always already there. On a
29
+ * fresh visit the trailing wave is home and only the leading one moves, which
30
+ * opens the band; mid-band the leading one has arrived and only the trailing
31
+ * one moves, which sends the weight back in behind the pointer. Saying only
32
+ * the half that is needed means guessing which half that is, and a guess that
33
+ * misses writes a value already in place, which does nothing at all.
34
+ */
35
+ export const entered = (waves) => ({
36
+ ...waves,
37
+ in: target(waves.way),
38
+ out: other(target(waves.way)),
39
+ });
40
+ /** A pointer leaving: the trailing wave after the leading one, to the same end. */
41
+ export const left = (waves) => ({ ...waves, out: waves.in });
42
+ /**
43
+ * The pair turned round, ready for the next visit.
44
+ *
45
+ * Only where the two were sent to the same end, which is what a band that has
46
+ * come off leaves behind and the only state this may read. A pointer sitting
47
+ * on the text holds them at opposite ends, and turning the pair round under
48
+ * that would let go of all of it at once.
49
+ */
50
+ export const settled = (waves) => waves.in === waves.out ? { ...waves, way: waves.in === "1" ? "-1" : "1" } : waves;
51
+ /** What the text weighs once both waves have arrived: 1 all of it, 0 none. */
52
+ export const weight = (waves) => (Number(waves.in) - Number(waves.out)) * Number(waves.way);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "animate-text-weight",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "description": "Animate short text between two weights of a variable font without moving a single line.",
5
5
  "license": "MIT",
6
6
  "author": "Tim Schoch",
@@ -14,6 +14,10 @@
14
14
  "types": "./dist/index.d.ts",
15
15
  "default": "./dist/index.js"
16
16
  },
17
+ "./wave-nav": {
18
+ "types": "./dist/wave-nav.d.ts",
19
+ "default": "./dist/wave-nav.js"
20
+ },
17
21
  "./style.css": "./src/animateTextWeight.css"
18
22
  },
19
23
  "files": [
@@ -22,13 +26,17 @@
22
26
  ],
23
27
  "scripts": {
24
28
  "build": "tsc",
29
+ "test": "tsc && node --test test/band.test.mjs test/waves.test.mjs",
30
+ "test:browser": "tsc && node --test test/*.browser.test.mjs",
25
31
  "prepare": "husky && tsc",
26
- "demo": "tsc && npx -y serve -l 3000 ."
32
+ "demo": "tsc && npx -y serve -l 3000 .",
33
+ "compare": "tsc && node scripts/compare.mjs"
27
34
  },
28
35
  "devDependencies": {
29
36
  "husky": "^9.1.7",
30
37
  "lint-staged": "^17.5.0",
31
38
  "prettier": "^3.9.6",
39
+ "puppeteer-core": "^25.12.0",
32
40
  "typescript": "^6.0.3"
33
41
  }
34
42
  }
@@ -7,9 +7,21 @@
7
7
  * --animate-weight-to: 700; the weight it animates to
8
8
  * --animate-weight: true; on any state or ancestor: switch it on
9
9
  *
10
- * And two more if you want the text to arrive in a wave:
10
+ * And three more if you want the text to arrive in a wave:
11
11
  * --animate-weight-by: character; one unit per character, not per line
12
12
  * --animate-weight-delay: 40ms; one unit waits this long after the one before it
13
+ * --animate-weight-direction: end; the wave runs the other way. Or
14
+ * pointer, or scroll, or both: the
15
+ * script points it where the pointer or
16
+ * the page came from, every time it
17
+ * moves, and the last to move has the
18
+ * say.
19
+ *
20
+ * And two if you want the color to travel with the weight:
21
+ * --animate-weight-color-from: #1c1c1c; the color it animates from,
22
+ * else the text color
23
+ * --animate-weight-color-to: #c8102e; the color it animates to,
24
+ * else the text color
13
25
  *
14
26
  * The script (index.ts) splits the element into one span per rendered
15
27
  * line, class .animate-text-weight-line, and in character mode splits each
@@ -61,12 +73,122 @@
61
73
  inherits: true;
62
74
  initial-value: 0s;
63
75
  }
76
+ /*
77
+ * --animate-weight-direction is which end of the line the wave starts at: the
78
+ * one the text starts at, or the one it ends at. Logical, so right-to-left
79
+ * text waves right-to-left; in line mode the ends are the first and the last
80
+ * line instead.
81
+ *
82
+ * pointer and scroll hand the choice to the script. Name both and the script
83
+ * watches both, and whichever moved last has the say.
84
+ *
85
+ * It is deliberately not registered with @property. A registration takes one
86
+ * syntax, and the value is either one of four words or two of them at once;
87
+ * the loose "<custom-ident>+" that admits the pair is not what the style query
88
+ * further down compares against. Left unregistered, the query compares the
89
+ * words themselves, which is all this ever asked for.
90
+ *
91
+ * --atw-flip-in and --atw-flip-out are what the waits actually read: 0 counts
92
+ * from the start of the text, 1 counts from the far end. Numbers rather than
93
+ * words, and inherited rather than queried, because the script writes them
94
+ * while the pointer is already arriving. An inherited value reaches the units
95
+ * in the same pass that turns :hover on; a container query reaches them one
96
+ * pass later, by which time the visit runs the old way round.
97
+ */
98
+ @property --atw-flip-in {
99
+ syntax: "<number>";
100
+ inherits: true;
101
+ initial-value: 0;
102
+ }
103
+ @property --atw-flip-out {
104
+ syntax: "<number>";
105
+ inherits: true;
106
+ initial-value: 0;
107
+ }
108
+ /*
109
+ * The two waves. Each is 0 before it has started and 1 once it has passed
110
+ * everything, and a unit is at full weight between the one and the other:
111
+ * --atw-wave-in brings the weight up, --atw-wave-out takes it away again.
112
+ *
113
+ * Two, so that the two fronts can be on the text at the same time. One wave
114
+ * has to arrive everywhere before it can turn round, which leaves the text
115
+ * sitting at full weight for a whole crossing while the far end waits its
116
+ * turn. Two waves let the second follow the first at a fixed distance, and
117
+ * what crosses the text is a band of weight rather than a tide that comes in
118
+ * and then goes out.
119
+ *
120
+ * One transition per line carries each, and every unit works its own share out
121
+ * of them. That is the whole reason they exist. A delay per unit would mean a
122
+ * unit that has not started yet has nothing running to turn round — switch a
123
+ * wave off while it is still waiting and the browser drops it where it stands,
124
+ * and that letter never moves at all. Brush past a link, or scroll a heading
125
+ * out of view mid-wave, and the wave breaks apart. Nothing here ever waits:
126
+ * every unit is reading numbers that are already moving, so a wave can be
127
+ * turned round at any moment and comes apart at none.
128
+ */
129
+ @property --atw-wave-in {
130
+ syntax: "<number>";
131
+ inherits: true;
132
+ initial-value: 0;
133
+ }
134
+ @property --atw-wave-out {
135
+ syntax: "<number>";
136
+ inherits: true;
137
+ initial-value: 0;
138
+ }
139
+ /* A unit's share of each wave, which the weight below is the gap between. */
140
+ @property --atw-rise {
141
+ syntax: "<number>";
142
+ inherits: false;
143
+ initial-value: 0;
144
+ }
145
+ @property --atw-fall {
146
+ syntax: "<number>";
147
+ inherits: false;
148
+ initial-value: 0;
149
+ }
150
+ /*
151
+ * Which way round the two waves are running: 1 up, -1 back down.
152
+ *
153
+ * A visit leaves both waves parked at the far end from the one it started at,
154
+ * and the next one runs them home again. So nothing is ever put back: the
155
+ * weight is the gap between the two waves whichever way they travel, and this
156
+ * is only the sign that keeps the gap positive. Putting them back instead
157
+ * would mean a second crossing of nothing to look at between every two visits.
158
+ */
159
+ @property --atw-way {
160
+ syntax: "<number>";
161
+ inherits: true;
162
+ initial-value: 1;
163
+ }
164
+ /*
165
+ * The page's switch again, as a number that can be transitioned, so that the
166
+ * script hears it change. A pointer-following element's waves are the script's
167
+ * to send, and a hover is not the only thing that switches it: a class or the
168
+ * page can hold it on while the pointer comes and goes. The transition is a
169
+ * doorbell and nothing reads the value.
170
+ */
171
+ @property --atw-on {
172
+ syntax: "<number>";
173
+ inherits: false;
174
+ initial-value: 0;
175
+ }
64
176
  /* How many delays this unit waits: the count of the one before it, plus one. */
65
177
  @property --atw-delay-count {
66
178
  syntax: "<number>";
67
179
  inherits: false;
68
180
  initial-value: 0;
69
181
  }
182
+ /*
183
+ * The count of the last unit of the wave, which is how long the wave is. Set
184
+ * on whatever holds the units — the line in character mode, the element in
185
+ * line mode — and read by each of them to count from the far end.
186
+ */
187
+ @property --atw-delay-total {
188
+ syntax: "<number>";
189
+ inherits: true;
190
+ initial-value: 0;
191
+ }
70
192
  /* The width a line is held at. */
71
193
  @property --atw-width {
72
194
  syntax: "<length>";
@@ -76,10 +198,47 @@
76
198
 
77
199
  :root {
78
200
  --animate-weight-duration: 300ms;
79
- --animate-weight-easing: ease;
201
+ /*
202
+ * Flat, because this shapes the wave's travel and not one unit's own change.
203
+ * A curve that is slow at both its ends crawls into the top and crawls back
204
+ * out of it, and the two together read as a pause at full weight.
205
+ */
206
+ --animate-weight-easing: linear;
207
+ /*
208
+ * The least the trailing wave ever follows the leading one by. Without a
209
+ * floor, a link brushed past in a moment gets a band a letter wide and shows
210
+ * nothing; with one, the pointer can be long gone and the band still crosses.
211
+ */
212
+ --atw-wave-min-delay: 400ms;
80
213
  }
81
214
 
82
215
  .animate-text-weight-line {
216
+ /*
217
+ * How long the whole wave takes: the last unit's wait, plus the time that
218
+ * unit then spends moving. Never zero, or the wave below would have nothing
219
+ * to cross — reduced motion takes both of these to 0s.
220
+ */
221
+ --atw-span: max(
222
+ 1ms,
223
+ calc(var(--atw-delay-total) * var(--animate-weight-delay) + var(--animate-weight-duration))
224
+ );
225
+ /*
226
+ * The script's own switches, in their own properties rather than in
227
+ * --animate-weight, because the page writes that one and the two would take
228
+ * it off each other. Unset, the leading wave is the page's to switch and the
229
+ * trailing one stays put; set, they say outright where each wave is headed.
230
+ */
231
+ --atw-wave-in: var(--atw-in, 0);
232
+ --atw-wave-out: var(--atw-out, 0);
233
+ /*
234
+ * The two moving parts. The trailing wave's delay is how far it follows the
235
+ * leading one by, which the script works out per visit and writes; a page
236
+ * that never leaves it alone never sets it, and the two run together.
237
+ */
238
+ transition:
239
+ --atw-wave-in var(--atw-span) var(--animate-weight-easing),
240
+ --atw-wave-out var(--atw-span) var(--animate-weight-easing) var(--atw-out-wait, 0s),
241
+ --atw-on 1ms;
83
242
  /* A line can get wider or narrower, never reflow. */
84
243
  white-space: nowrap;
85
244
  /* Fractional glyph advances, so Firefox does not snap letters mid-tween. */
@@ -166,14 +325,103 @@
166
325
  /*
167
326
  * Clamped, because an easing may overshoot: the spacing below stops at the
168
327
  * last stop, so an unclamped weight would keep going and break the width.
328
+ *
329
+ * Rounded to a whole weight, because Chrome (153, macOS, 2x screen) now and
330
+ * then draws a letter whose weight is a fresh fraction every frame with the
331
+ * wrong one for a single frame: mid-tween it flashes bold or light and is
332
+ * back the next. Plain spans tweening font-weight do it too, so it is not
333
+ * the waves. Whole weights are a few hundred values, the same ones every
334
+ * visit, and one weight is far below what the eye or the width can tell.
335
+ */
336
+ font-weight: round(
337
+ calc(
338
+ var(--atw-from) + (var(--atw-to) - var(--atw-from)) *
339
+ clamp(0, var(--animate-weight-progress), 1)
340
+ ),
341
+ 1
342
+ );
343
+ /*
344
+ * This unit's own share of each wave, worked out afresh every frame rather
345
+ * than transitioned: how far that wave has come, less what this unit waits
346
+ * for it, over the time this unit then takes.
347
+ *
348
+ * A flip of 0 leaves the count where it is; a flip of 1 turns it into
349
+ * (total - count), which is the same wave counted from the far end. Read
350
+ * here, in the open, so a wave already running turns round when its flip
351
+ * does — nothing is latched at the moment a tween starts, because no tween
352
+ * starts here.
353
+ */
354
+ --atw-wait-in: calc(
355
+ (
356
+ var(--atw-delay-count) + var(--atw-flip-in) *
357
+ (var(--atw-delay-total) - 2 * var(--atw-delay-count))
358
+ ) * var(--animate-weight-delay)
359
+ );
360
+ --atw-wait-out: calc(
361
+ (
362
+ var(--atw-delay-count) + var(--atw-flip-out) *
363
+ (var(--atw-delay-total) - 2 * var(--atw-delay-count))
364
+ ) * var(--animate-weight-delay)
365
+ );
366
+ /*
367
+ * tan(atan2(a, b)) is a / b. A time over a time is typed arithmetic, which
368
+ * Firefox does not ship yet: it drops the division silently and nothing
369
+ * moves. atan2 takes two times and tan hands back a plain number.
169
370
  */
170
- font-weight: calc(
171
- var(--atw-from) + (var(--atw-to) - var(--atw-from)) *
172
- clamp(0, var(--animate-weight-progress), 1)
371
+ --atw-rise: clamp(
372
+ 0,
373
+ tan(
374
+ atan2(
375
+ var(--atw-wave-in) * var(--atw-span) - var(--atw-wait-in),
376
+ max(1ms, var(--animate-weight-duration))
377
+ )
378
+ ),
379
+ 1
380
+ );
381
+ --atw-fall: clamp(
382
+ 0,
383
+ tan(
384
+ atan2(
385
+ var(--atw-wave-out) * var(--atw-span) - var(--atw-wait-out),
386
+ max(1ms, var(--animate-weight-duration))
387
+ )
388
+ ),
389
+ 1
390
+ );
391
+ /*
392
+ * Full weight between the one wave and the other, and --atw-way to keep that
393
+ * gap the right way up when the pair is running back down.
394
+ *
395
+ * Clamped low as well as high, because the two waves need not run the same
396
+ * way round, and a unit the trailing wave reaches before the leading one
397
+ * would otherwise go past light and out the other side.
398
+ */
399
+ --animate-weight-progress: clamp(
400
+ 0,
401
+ calc((var(--atw-rise) - var(--atw-fall)) * var(--atw-way)),
402
+ 1
403
+ );
404
+ }
405
+
406
+ /*
407
+ * The color, if the element asks for one. An end left unset is the text color.
408
+ *
409
+ * It is read off the unit's own progress rather than transitioned itself, so
410
+ * it takes the curve, the duration and the delay the weight already has, and
411
+ * in character mode it arrives as the same wave. oklab because that is the
412
+ * space a plain color transition interpolates in.
413
+ *
414
+ * Not registered with @property, on purpose: a registration needs an
415
+ * initial-value, and that would hand every unit two colors no page asked for.
416
+ * The fallback is currentColor, which inside color is the inherited color, so
417
+ * an element that sets neither mixes its own color with itself and keeps it.
418
+ */
419
+ .animate-text-weight-unit {
420
+ color: color-mix(
421
+ in oklab,
422
+ var(--animate-weight-color-to, currentColor) calc(clamp(0, var(--animate-weight-progress), 1) * 100%),
423
+ var(--animate-weight-color-from, currentColor)
173
424
  );
174
- transition: --animate-weight-progress var(--animate-weight-duration) var(--animate-weight-easing);
175
- /* After the shorthand, which would reset it. */
176
- transition-delay: calc(var(--atw-delay-count) * var(--animate-weight-delay));
177
425
  }
178
426
 
179
427
  /*
@@ -198,10 +446,40 @@
198
446
  );
199
447
  }
200
448
 
201
- /* The switch. The element is the unit's style container. */
449
+ /*
450
+ * The page's switch, which comes after the line's own rule so that it wins
451
+ * while the page holds it on. The element is the line's style container.
452
+ *
453
+ * --atw-in still ahead of it, so that a script following the pointer keeps the
454
+ * last word either way: it hears the switch through --atw-on and sends the
455
+ * waves itself, so a band coming off is never cut short by the switch.
456
+ */
202
457
  @container style(--animate-weight: true) {
458
+ .animate-text-weight-line {
459
+ --atw-wave-in: var(--atw-in, 1);
460
+ --atw-on: 1;
461
+ }
462
+ }
463
+
464
+ /*
465
+ * The wave the other way round: a unit waits for the ones behind it instead of
466
+ * the ones in front of it, so the last one moves first. The counts are not
467
+ * rewritten for this — they are read backwards from the far end, which is what
468
+ * --atw-delay-total holds. So direction can change at any time, on a state or
469
+ * a media query, and nothing has to be measured again.
470
+ *
471
+ * This is the stylesheet's own end, and only that. A separate property,
472
+ * because the script must not overwrite the word the stylesheet asked for —
473
+ * pointer and scroll write the flips on the element instead, where they reach
474
+ * the units by inheritance and this rule never matches.
475
+ *
476
+ * Both waves, because a page that fixes an end wants the whole thing that way
477
+ * round; the two only part company when the pointer sends them.
478
+ */
479
+ @container style(--animate-weight-direction: end) {
203
480
  .animate-text-weight-unit {
204
- --animate-weight-progress: 1;
481
+ --atw-flip-in: 1;
482
+ --atw-flip-out: 1;
205
483
  }
206
484
  }
207
485