maverick-wave 5.8.0 → 5.11.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.
Files changed (46) hide show
  1. package/.claude/skills/mw-maverick-wave/SKILL.md +26 -14
  2. package/.claude/skills/mw-maverick-wave/examples/angular-services.md +10 -1
  3. package/.claude/skills/mw-maverick-wave/examples/static-landing-page.md +4 -3
  4. package/.claude/skills/mw-maverick-wave/references/components.md +23 -9
  5. package/.claude/skills/mw-maverick-wave/references/javascript.md +14 -14
  6. package/.claude/skills/mw-maverick-wave/references/layout.md +29 -9
  7. package/.claude/skills/mw-maverick-wave/references/theming.md +48 -25
  8. package/CHANGELOG.md +23 -0
  9. package/README.md +41 -24
  10. package/index.html +356 -20
  11. package/maverick-wave.min.css +61 -5
  12. package/maverick-wave.min.js +1 -1
  13. package/package.json +2 -2
  14. package/scripts/verify.js +7 -1
  15. package/src/js/main.js +27 -7
  16. package/src/partials/palette-container.html +105 -65
  17. package/src/partials/parallax-container.html +20 -1
  18. package/src/partials/preview-container.html +136 -0
  19. package/src/partials/utilities-container.html +38 -0
  20. package/src/scss/abstracts/_functions.scss +31 -3
  21. package/src/scss/abstracts/_mixins.scss +62 -12
  22. package/src/scss/abstracts/_variables.scss +47 -21
  23. package/src/scss/base/_base.scss +97 -8
  24. package/src/scss/components/_badge.scss +1 -1
  25. package/src/scss/components/_buttons.scss +1 -1
  26. package/src/scss/components/_cards.scss +23 -14
  27. package/src/scss/components/_dropdown.scss +58 -1
  28. package/src/scss/components/_kanban.scss +10 -9
  29. package/src/scss/components/_lang-switch.scss +1 -1
  30. package/src/scss/components/_localhost-indicator.scss +1 -1
  31. package/src/scss/components/_modals.scss +13 -1
  32. package/src/scss/components/_pricing.scss +1 -1
  33. package/src/scss/components/_progress.scss +9 -5
  34. package/src/scss/components/_skeleton.scss +1 -1
  35. package/src/scss/components/_spinners.scss +5 -5
  36. package/src/scss/components/_stepper.scss +4 -1
  37. package/src/scss/components/_tabs.scss +28 -8
  38. package/src/scss/components/_tiles.scss +41 -16
  39. package/src/scss/components/_timelines.scss +13 -5
  40. package/src/scss/layout/_header-reveal.scss +5 -5
  41. package/src/scss/layout/_main.scss +1 -1
  42. package/src/scss/layout/_parallax.scss +65 -14
  43. package/src/scss/utilities/_elevation.scss +23 -0
  44. package/src/scss/utilities/_reveal.scss +5 -5
  45. package/src/scss/utilities/_touch-targets.scss +1 -1
  46. package/src/scss/utilities/_variants.scss +74 -7
@@ -197,10 +197,67 @@
197
197
  overflow: visible;
198
198
  }
199
199
 
200
+ // The clipping caveat at the top of this file, lifted where the browser can do
201
+ // it. `fixed` takes the menu out of every ancestor's containing block and
202
+ // anchor positioning keeps it attached to the trigger, so an actions menu in a
203
+ // scrolling table or a clipping card is no longer cut in half - and the
204
+ // `:has()` list above becomes belt and braces rather than the only thing
205
+ // holding it together.
206
+ //
207
+ // `anchor-scope` is what makes this safe and why it is in the @supports test:
208
+ // an anchor name is global, so without it every menu on the page would resolve
209
+ // to whichever trigger came last in the DOM. Scoped to the dropdown, each menu
210
+ // sees its own.
211
+ @supports (anchor-scope: all) and (position-area: block-end) {
212
+ .mw-dropdown {
213
+ anchor-scope: --mw-dropdown-trigger;
214
+
215
+ > summary {
216
+ anchor-name: --mw-dropdown-trigger;
217
+ }
218
+ }
219
+
220
+ .mw-dropdown-menu {
221
+ position: fixed;
222
+ position-anchor: --mw-dropdown-trigger;
223
+ // Below the trigger, left edges aligned - the same place the absolute
224
+ // version sits. The inset properties it used are cleared, or they fight
225
+ // the position-area, which resolves them itself.
226
+ position-area: block-end span-inline-end;
227
+ // Flip up when the trigger sits low, in when it sits near the right edge.
228
+ // The menu has no arrow to contradict a flip, so unlike the tooltip it can
229
+ // take all of them.
230
+ position-try-fallbacks:
231
+ flip-block,
232
+ flip-inline,
233
+ flip-block flip-inline;
234
+ // Never taller than the room the fallback landed in
235
+ position-try-order: most-block-size;
236
+ top: auto;
237
+ right: auto;
238
+ bottom: auto;
239
+ left: auto;
240
+ margin-block: spacing('2');
241
+ // The viewport cap no longer applies - what matters is the slot the anchor
242
+ // left, and 60dvh was measured against the window anyway
243
+ max-height: min(60dvh, 420px);
244
+ }
245
+
246
+ // Both variants are what position-try-fallbacks now does on its own, so they
247
+ // only have to stop pushing the menu around with insets
248
+ .mw-dropdown-end .mw-dropdown-menu {
249
+ position-area: block-end span-inline-start;
250
+ }
251
+
252
+ .mw-dropdown-up .mw-dropdown-menu {
253
+ position-area: block-start span-inline-end;
254
+ }
255
+ }
256
+
200
257
  // A menu opened by a thumb needs rows a thumb can hit, and the room to show
201
258
  // them - so it takes the trigger's full width rather than a 200px column
202
259
  // floating beside it.
203
- @media (pointer: coarse), (max-width: #{breakpoint('sm')}) {
260
+ @media (pointer: coarse), (width <= #{breakpoint('sm')}) {
204
261
  .mw-dropdown-menu {
205
262
  min-width: max(100%, 240px);
206
263
  }
@@ -7,6 +7,8 @@
7
7
  // Room the ribbon needs in the top right corner of a ticket
8
8
  $_mw-kanban-ribbon-clearance: 66px;
9
9
  $_mw-kanban-ribbon-clearance-compact: 58px;
10
+ // Narrowest a lane may get before the board wraps it to the next row
11
+ $_mw-kanban-lane-min: 260px;
10
12
 
11
13
  @keyframes mw-kanban-card-in {
12
14
  from {
@@ -58,21 +60,20 @@
58
60
  --mw-kanban-column-min-height: 120px;
59
61
 
60
62
  display: grid;
61
- grid-auto-flow: column;
62
- grid-auto-columns: minmax(0, 1fr);
63
+ // As many lanes as fit at a readable width, the rest wrap. The board asks
64
+ // its own width, so one inside a panel wraps on a desktop too. `min()`
65
+ // against 100% keeps a lane out of a container narrower than the minimum.
66
+ grid-template-columns: repeat(
67
+ auto-fit,
68
+ minmax(min(#{$_mw-kanban-lane-min}, 100%), 1fr)
69
+ );
63
70
  gap: spacing('4');
64
71
  padding: spacing('4');
65
72
  background: var(--mw-kanban-background);
66
73
  border-radius: radius('md');
67
74
 
68
- // Two lanes side by side stay readable, four do not
69
- @include media-down('lg') {
70
- grid-auto-flow: row;
71
- grid-template-columns: repeat(2, minmax(0, 1fr));
72
- }
73
-
75
+ // Frame only - how tight the board sits on a phone is a viewport call
74
76
  @include media-down('sm') {
75
- grid-template-columns: minmax(0, 1fr);
76
77
  gap: spacing('3');
77
78
  padding: spacing('3');
78
79
  }
@@ -52,7 +52,7 @@
52
52
  .mw-lang-switch .mw-dropdown-menu {
53
53
  min-width: 170px;
54
54
 
55
- @media (pointer: coarse), (max-width: #{breakpoint('sm')}) {
55
+ @media (pointer: coarse), (width <= #{breakpoint('sm')}) {
56
56
  min-width: max(100%, 240px);
57
57
  }
58
58
  }
@@ -10,7 +10,7 @@
10
10
  height: 4px;
11
11
  background: var(--mw-warning-color);
12
12
  z-index: z-index('modal')-10;
13
- animation: pulse-indicator 4s infinite;
13
+ animation: pulse-indicator motion(4s) infinite;
14
14
  }
15
15
 
16
16
  // Keyframes for the pulsing indicator
@@ -16,12 +16,24 @@
16
16
  justify-content: center;
17
17
  padding: spacing('7');
18
18
  opacity: 0;
19
- transition: opacity var(--mw-duration-base) var(--mw-ease-out);
19
+ // `display` in the list and `allow-discrete` behind it, or the overlay is
20
+ // taken off the page the frame the class comes off and the fade out never
21
+ // renders - the modal inside it would slide down over nothing. The dialog
22
+ // version below has had this; the div version was landing hard.
23
+ transition:
24
+ opacity var(--mw-duration-base) var(--mw-ease-out),
25
+ display var(--mw-duration-base) allow-discrete;
20
26
 
21
27
  // Show modal when class is added
22
28
  &.mw-modal-open {
23
29
  display: flex;
24
30
  opacity: 1;
31
+
32
+ // A box that goes from `display: none` starts at its end state otherwise,
33
+ // so there is nothing to fade from
34
+ @starting-style {
35
+ opacity: 0;
36
+ }
25
37
  }
26
38
  }
27
39
 
@@ -200,7 +200,7 @@
200
200
  // row survives the narrow-card stack and the action takes half of it. Here and
201
201
  // not in _cards.scss because the price is the guest - the footer's own rule is
202
202
  // right for everything that is not one.
203
- @container mw-card (max-width: #{$mw-card-narrow}) {
203
+ @container mw-card (width <= #{$mw-card-narrow}) {
204
204
  .mw-card-footer:has(> .mw-price) {
205
205
  flex-direction: row;
206
206
  align-items: center;
@@ -1,6 +1,15 @@
1
1
  @use '../abstracts' as *;
2
2
 
3
3
  @layer mw.components {
4
+ // How far the bar is filled - written by the script, read by the keyframe at
5
+ // the bottom. Registered and not declared on the element: a `data-value` the
6
+ // script cannot parse lands on 0% instead of dropping `width` altogether.
7
+ @property --mw-progress-value {
8
+ syntax: '<length-percentage>';
9
+ inherits: true;
10
+ initial-value: 0%;
11
+ }
12
+
4
13
  .mw-progress {
5
14
  &-container {
6
15
  margin: spacing('1');
@@ -61,11 +70,6 @@
61
70
  // the variants at the bottom of the file
62
71
  --mw-progress-ink: var(--mw-secondary-accent-text-color);
63
72
 
64
- // How far the bar is filled. Written by the script from data-value or from
65
- // an inline width, and read by the keyframe below. Declared here so it has
66
- // a definition rather than only a fallback - an unset bar is an empty one.
67
- --mw-progress-value: 0;
68
-
69
73
  height: 100%;
70
74
  background: var(--mw-secondary-color);
71
75
  border-radius: radius('sm');
@@ -18,7 +18,7 @@
18
18
  var(--mw-card-background) 75%
19
19
  );
20
20
  background-size: 200% 100%;
21
- animation: mw-skeleton-shimmer 1.5s infinite linear;
21
+ animation: mw-skeleton-shimmer motion(1.5s) infinite linear;
22
22
  border-radius: radius('sm');
23
23
  display: block;
24
24
  }
@@ -16,20 +16,20 @@
16
16
  border: 0.5rem solid var(--mw-border);
17
17
  border-radius: radius('full');
18
18
  border-top-color: var(--mw-primary-text-color);
19
- animation: mw-spin 1.2s linear infinite;
19
+ animation: mw-spin motion(1.2s) linear infinite;
20
20
 
21
21
  &.mw-spinner-sm {
22
22
  width: $_size-sm;
23
23
  height: $_size-sm;
24
24
  border-width: 0.3rem;
25
- animation: mw-spin 1.4s linear infinite;
25
+ animation: mw-spin motion(1.4s) linear infinite;
26
26
  }
27
27
 
28
28
  &.mw-spinner-lg {
29
29
  width: $_size-lg;
30
30
  height: $_size-lg;
31
31
  border-width: 0.7rem;
32
- animation: mw-spin 1s linear infinite;
32
+ animation: mw-spin motion(1s) linear infinite;
33
33
  }
34
34
 
35
35
  &.mw-spinner-primary {
@@ -54,7 +54,7 @@
54
54
  border-radius: radius('full');
55
55
  display: inline-block;
56
56
  margin: spacing('0') spacing('2');
57
- animation: mw-bounce 1.4s infinite ease-in-out both;
57
+ animation: mw-bounce motion(1.4s) infinite ease-in-out both;
58
58
 
59
59
  &:nth-child(1) {
60
60
  animation-delay: -0.32s;
@@ -110,7 +110,7 @@
110
110
  border: 0.5rem solid var(--mw-primary-text-color);
111
111
  border-color: var(--mw-primary-text-color) transparent
112
112
  var(--mw-primary-text-color) transparent;
113
- animation: mw-spin 1.5s linear infinite;
113
+ animation: mw-spin motion(1.5s) linear infinite;
114
114
  }
115
115
 
116
116
  &.mw-spinner-sm {
@@ -6,6 +6,7 @@
6
6
  display: flex;
7
7
  align-items: flex-start;
8
8
  width: 100%;
9
+ container: mw-stepper / inline-size;
9
10
 
10
11
  &-step {
11
12
  display: flex;
@@ -79,7 +80,9 @@
79
80
  }
80
81
  }
81
82
 
82
- @include media-up('sm') {
83
+ // The step labels follow the strip's own width, not the window's - a
84
+ // stepper in a narrow card keeps the small label on a wide desktop.
85
+ @container mw-stepper (width >= #{breakpoint('sm')}) {
83
86
  &-label {
84
87
  font-size: font-size('xs');
85
88
  }
@@ -1,6 +1,34 @@
1
1
  @use '../abstracts' as *;
2
2
 
3
3
  @layer mw.components {
4
+ // Registered, so a half-written value from the script cannot invalidate the
5
+ // whole `transform` and leave the marker somewhere other than at rest.
6
+ // `inherits: true` because the bar is the nav's ::after, and a pseudo element
7
+ // only ever sees these through inheritance.
8
+ @property --mw-tabs-marker-x {
9
+ syntax: '<length>';
10
+ inherits: true;
11
+ initial-value: 0px;
12
+ }
13
+
14
+ @property --mw-tabs-marker-y {
15
+ syntax: '<length>';
16
+ inherits: true;
17
+ initial-value: 0px;
18
+ }
19
+
20
+ @property --mw-tabs-marker-w {
21
+ syntax: '<number>';
22
+ inherits: true;
23
+ initial-value: 0;
24
+ }
25
+
26
+ @property --mw-tabs-marker-h {
27
+ syntax: '<number>';
28
+ inherits: true;
29
+ initial-value: 0;
30
+ }
31
+
4
32
  .mw-tabs {
5
33
  display: flex;
6
34
  flex-direction: column;
@@ -71,14 +99,6 @@
71
99
  // A 1px bar scaled to width, not a bar whose width is set: scaleX runs on the
72
100
  // compositor, `width` relayouts the strip on every frame of the slide.
73
101
  &-nav[data-mw-tabs-marker] {
74
- // Declared with a zero length so the four are real tokens with a resting
75
- // value rather than four names the stylesheet only ever reads. The script
76
- // sets them on the element, which outranks this either way.
77
- --mw-tabs-marker-x: 0;
78
- --mw-tabs-marker-w: 0;
79
- --mw-tabs-marker-y: 0;
80
- --mw-tabs-marker-h: 0;
81
-
82
102
  .mw-tabs-nav-item.mw-active,
83
103
  .mw-tabs-nav-item.active {
84
104
  border-bottom-color: transparent;
@@ -57,7 +57,22 @@
57
57
  }
58
58
  }
59
59
 
60
+ // The tile sizes its type off its own width, not the window's. In
61
+ // `.mw-tiles-4col` on a wide desktop a tile is a quarter of 85% of the page -
62
+ // narrower than the same tile alone on a phone, where it used to be the one
63
+ // getting the small type.
64
+ //
65
+ // The cost: `container-type` makes the tile a containing block for its own
66
+ // fixed-position descendants, and unlike a card the tile clips - so a
67
+ // [data-tooltip] inside one is now cut off at its edge. Taken knowingly, there
68
+ // being nothing in a tile's anatomy that wants a tooltip. Drop the two lines
69
+ // below and the queries fall back to the window.
70
+ $_mw-tile-wide: 300px;
71
+ $_mw-tile-narrow: 220px;
72
+
60
73
  .mw-tile {
74
+ width: 100%;
75
+ container: mw-tile / inline-size;
61
76
  background: var(--mw-card-background);
62
77
  @include surface;
63
78
  padding: spacing('5');
@@ -116,11 +131,11 @@
116
131
  line-height: 1.2;
117
132
  text-align: center;
118
133
 
119
- @include media-up('sm') {
134
+ @container mw-tile (width >= #{$_mw-tile-narrow}) {
120
135
  font-size: font-size('xl');
121
136
  }
122
137
 
123
- @include media-up('md') {
138
+ @container mw-tile (width >= #{$_mw-tile-wide}) {
124
139
  font-size: font-size('2xl');
125
140
  }
126
141
  }
@@ -166,7 +181,7 @@
166
181
  font-size: font-size('md');
167
182
  margin-bottom: spacing('2');
168
183
 
169
- @include media-up('md') {
184
+ @container mw-tile (width >= #{$_mw-tile-wide}) {
170
185
  font-size: font-size('lg');
171
186
  }
172
187
  }
@@ -194,11 +209,11 @@
194
209
  font-size: font-size('xl');
195
210
  margin-bottom: spacing('4');
196
211
 
197
- @include media-up('sm') {
212
+ @container mw-tile (width >= #{$_mw-tile-narrow}) {
198
213
  font-size: font-size('2xl');
199
214
  }
200
215
 
201
- @include media-up('md') {
216
+ @container mw-tile (width >= #{$_mw-tile-wide}) {
202
217
  font-size: font-size('3xl');
203
218
  }
204
219
  }
@@ -214,20 +229,30 @@
214
229
  }
215
230
  }
216
231
 
217
- @include media-down('sm') {
218
- .mw-tile {
219
- padding: spacing('4');
232
+ // A cramped tile reads better ranged left whatever the window is doing - a
233
+ // centred line inside 200px is two words and a gap. Every selector here is a
234
+ // child: a container cannot answer a query about itself, and the tile *is* the
235
+ // container - which is also why the padding below stays on the window.
236
+ @container mw-tile (width < #{$_mw-tile-narrow}) {
237
+ .mw-tile-header,
238
+ .mw-tile-body {
220
239
  text-align: left;
240
+ }
221
241
 
222
- &-img {
223
- width: 60px;
224
- height: 60px;
225
- margin: spacing('0') auto spacing('2');
226
- }
242
+ .mw-tile-body {
243
+ margin-bottom: spacing('2');
244
+ }
227
245
 
228
- &-body {
229
- margin-bottom: spacing('2');
230
- }
246
+ .mw-tile-img {
247
+ width: 60px;
248
+ height: 60px;
249
+ margin: spacing('0') auto spacing('2');
250
+ }
251
+ }
252
+
253
+ @include media-down('sm') {
254
+ .mw-tile {
255
+ padding: spacing('4');
231
256
  }
232
257
  }
233
258
  }
@@ -57,6 +57,7 @@
57
57
  margin: spacing('1') auto;
58
58
  padding: spacing('5') 0;
59
59
  width: min(100%, 900px);
60
+ container: mw-timeline / inline-size;
60
61
 
61
62
  // Center vertical line
62
63
  &::before {
@@ -185,7 +186,7 @@
185
186
  background: var(--mw-primary-color);
186
187
  border-color: var(--mw-primary-text-color);
187
188
  transform: scale(1.1);
188
- animation: mw-pulse-ring-big 2s infinite;
189
+ animation: mw-pulse-ring-big motion(2s) infinite;
189
190
 
190
191
  .mw-timeline-big-date-main,
191
192
  .mw-timeline-big-date-sub {
@@ -193,9 +194,10 @@
193
194
  }
194
195
  }
195
196
 
196
- @include media-down('sm') {
197
- width: 100%;
198
-
197
+ // The two-sided layout collapses when the timeline itself runs out of room,
198
+ // not when the window does - the same timeline in a half-width column had
199
+ // its content overlapping the centre line on a wide desktop.
200
+ @container mw-timeline (width < #{breakpoint('sm')}) {
199
201
  // Line moves to the left
200
202
  &::before {
201
203
  left: 0;
@@ -258,6 +260,7 @@
258
260
  margin: spacing('1') auto;
259
261
  padding: spacing('3') 0;
260
262
  width: min(96%, 930px);
263
+ container: mw-timeline / inline-size;
261
264
 
262
265
  // Vertical line centered over the date column
263
266
  &::before {
@@ -319,12 +322,16 @@
319
322
  background: var(--mw-primary-color);
320
323
  color: var(--mw-primary-accent-text-color);
321
324
  transform: scale(1.1);
322
- animation: mw-pulse-ring-simple 2s infinite;
325
+ animation: mw-pulse-ring-simple motion(2s) infinite;
323
326
  }
324
327
 
328
+ // The inset is a page-level call: on a phone the timeline takes the full
329
+ // width, however wide its column happens to be.
325
330
  @include media-down('sm') {
326
331
  width: 100%;
332
+ }
327
333
 
334
+ @container mw-timeline (width < #{breakpoint('sm')}) {
328
335
  &::before {
329
336
  left: 0;
330
337
  transform: none;
@@ -341,6 +348,7 @@
341
348
  }
342
349
 
343
350
  &-content {
351
+ align-self: stretch;
344
352
  padding-left: spacing('3');
345
353
  }
346
354
  }
@@ -63,11 +63,11 @@
63
63
  }
64
64
  }
65
65
 
66
- // Firefox ships no scroll timelines, so there the script toggles these
67
- // classes instead (main.js) - the same two positions, triggered instead of
68
- // scrubbed. State and transition are separate because the script sets the
69
- // state a frame earlier: together they would send the bar sliding away in
70
- // front of the reader instead of having it gone already.
66
+ // Older Safari and Firefox ship no scroll timelines, so there the script
67
+ // toggles these classes instead (main.js) - the same two positions,
68
+ // triggered instead of scrubbed. State and transition are separate because
69
+ // the script sets the state a frame earlier: together they would send the
70
+ // bar sliding away in front of the reader instead of having it gone already.
71
71
  @media (prefers-reduced-motion: no-preference) {
72
72
  .mw-header-reveal.mw-header-away {
73
73
  top: calc(-1 * var(--mw-header-reveal-offset));
@@ -208,7 +208,7 @@
208
208
  letter-spacing: 0.08em;
209
209
  text-transform: uppercase;
210
210
  text-decoration: none;
211
- animation: mw-scroll-hint-float 2.2s var(--mw-ease-in-out) infinite;
211
+ animation: mw-scroll-hint-float motion(2.2s) var(--mw-ease-in-out) infinite;
212
212
  text-shadow: 0 1px 4px rgb(0 0 0 / 65%);
213
213
 
214
214
  // The arrow carries the meaning, so it gets the size and the label stays quiet
@@ -1,6 +1,15 @@
1
1
  @use '../abstracts' as *;
2
2
 
3
3
  @layer mw.layout {
4
+ // Registered so a bad value cannot invalidate the calc() and drop the layer
5
+ // somewhere else. 0.5 and not 0 is the resting point - see the transform
6
+ // at the bottom of this file.
7
+ @property --mw-parallax-progress {
8
+ syntax: '<number>';
9
+ inherits: true;
10
+ initial-value: 0.5;
11
+ }
12
+
4
13
  // The image gets its own layer instead of `background-attachment: fixed`: that
5
14
  // one is ignored on iOS and sizes against the viewport rather than the block.
6
15
  // Here a transform runs on the browser's own scroll timeline - no listener, no
@@ -77,6 +86,35 @@
77
86
  --mw-parallax-pattern-depth: 4vh;
78
87
  }
79
88
 
89
+ // A third way to use the scroll, between the two above: the block keeps its
90
+ // place in the flow but travels slower than the page, so the section after it
91
+ // closes in from below while picture and text are still moving.
92
+ //
93
+ // `exit` and never `cover`: the block leaves a gap where it used to sit, and
94
+ // the exit phase is the one that starts once its top edge is already past the
95
+ // top of the screen - which is where that gap then stays.
96
+ //
97
+ // Travel over the block's own height is what sets the tempo: half the height
98
+ // is half speed, the full height stands still and is mw-parallax-sticky.
99
+ .mw-parallax-slow {
100
+ --mw-parallax-slow-travel: 45vh;
101
+
102
+ position: relative;
103
+ z-index: 0;
104
+ }
105
+
106
+ @supports (animation-timeline: view()) {
107
+ @media (prefers-reduced-motion: no-preference) {
108
+ .mw-parallax-slow {
109
+ animation-name: mw-parallax-band;
110
+ animation-timing-function: linear;
111
+ animation-fill-mode: both;
112
+ animation-timeline: view();
113
+ animation-range: exit 0% exit 100%;
114
+ }
115
+ }
116
+ }
117
+
80
118
  // A second layer on top of the first, added to the same media element. It
81
119
  // travels a third of the distance, and it is that difference between the two
82
120
  // that reads as depth - one layer alone only slides. What it carries is a
@@ -88,10 +126,15 @@
88
126
  background-image: var(--mw-parallax-pattern-image, none);
89
127
  }
90
128
 
91
- // Everything after it has to cover it, or the pinned picture shows through the
92
- // gaps. The z-index needs the weight of a class to beat the plain
93
- // `position: relative` those sections carry.
94
- .mw-parallax-sticky ~ * {
129
+ // Both variants above are overtaken rather than scrolled away, so everything
130
+ // after them has to cover them or the picture shows through the gaps. The
131
+ // z-index needs the weight of a class to beat the plain `position: relative`
132
+ // those sections carry.
133
+ //
134
+ // Except a pinned block: `position: relative` here and `sticky` above carry
135
+ // the same weight, and this rule is the later one - it would take the pinning
136
+ // away from every band after the first.
137
+ :is(.mw-parallax-sticky, .mw-parallax-slow) ~ *:not(.mw-parallax-sticky) {
95
138
  position: relative;
96
139
  z-index: 1;
97
140
  }
@@ -99,7 +142,9 @@
99
142
  // Zero specificity on purpose: it is a floor, not a rule. A section that
100
143
  // paints its own surface - mw-section-alternate, a project's own band - has
101
144
  // to win here, and this file is forwarded last.
102
- :where(.mw-parallax-sticky ~ *) {
145
+ :where(
146
+ :is(.mw-parallax-sticky, .mw-parallax-slow) ~ *:not(.mw-parallax-sticky)
147
+ ) {
103
148
  background: var(--mw-page-background);
104
149
  }
105
150
 
@@ -146,18 +191,14 @@
146
191
  }
147
192
  }
148
193
 
149
- // Firefox ships no scroll timelines, so there the script writes the progress
150
- // and the travel is worked out here (main.js). Keeping the transform in CSS
151
- // is what lets every depth variant above - and an inline
152
- // `--mw-parallax-depth` - go on working untouched.
194
+ // Older Safari and Firefox ship no scroll timelines, so there the script
195
+ // writes the progress and the travel is worked out here (main.js). Keeping
196
+ // the transform in CSS is what lets every depth variant above - and an
197
+ // inline `--mw-parallax-depth` - go on working untouched.
153
198
  //
154
- // 0 puts the layer at +depth and 1 at -depth, the two ends of the keyframes
155
- // below. Declared and not only used, so the frame before the script has
156
- // written anything sits at rest instead of nowhere.
199
+ // 0 puts the layer at +depth and 1 at -depth, the two ends of the keyframes.
157
200
  @media (prefers-reduced-motion: no-preference) {
158
201
  .mw-parallax-driven {
159
- --mw-parallax-progress: 0.5;
160
-
161
202
  transform: translate3d(
162
203
  0,
163
204
  calc(var(--mw-parallax-depth) * (1 - 2 * var(--mw-parallax-progress))),
@@ -166,6 +207,16 @@
166
207
  }
167
208
  }
168
209
 
210
+ @keyframes mw-parallax-band {
211
+ from {
212
+ transform: translateY(0);
213
+ }
214
+
215
+ to {
216
+ transform: translateY(var(--mw-parallax-slow-travel));
217
+ }
218
+ }
219
+
169
220
  @keyframes mw-parallax {
170
221
  from {
171
222
  transform: translate3d(0, var(--mw-parallax-depth), 0);
@@ -15,4 +15,27 @@
15
15
  box-shadow: var(--mw-elevation-#{$level});
16
16
  }
17
17
  }
18
+
19
+ // Light instead of shadow. Elevation says how far a surface floats above the
20
+ // page; this says it is the thing giving off the light - for the one element on
21
+ // a screen that has to be looked at first, and for nothing else.
22
+ //
23
+ // Two layers like elevation, and for the same reason: the tight one reads as
24
+ // the edge of the source, the wide one as the air around it. Both are
25
+ // offsetless, which is what separates a glow from a shadow - light does not
26
+ // fall downwards. Bare `.mw-glow` rides along on primary, because the brand
27
+ // colour is what a glow means unless it says otherwise.
28
+ @each $name, $col in $semantic-colors {
29
+ $selector: '.mw-glow-#{$name}';
30
+
31
+ @if $name == 'primary' {
32
+ $selector: '.mw-glow, #{$selector}';
33
+ }
34
+
35
+ #{$selector} {
36
+ box-shadow:
37
+ 0 0 6px color-mix(in srgb, #{$col} 45%, transparent),
38
+ 0 0 26px color-mix(in srgb, #{$col} 28%, transparent);
39
+ }
40
+ }
18
41
  }