maverick-wave 5.33.0 → 5.35.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.
package/src/js/main.js CHANGED
@@ -50,6 +50,7 @@
50
50
  initTagRemovals();
51
51
  initCopyButtons();
52
52
  initLocalhostIndicator();
53
+ initOccasions();
53
54
  warnMissingViewportFit();
54
55
  }
55
56
 
@@ -62,6 +63,7 @@
62
63
  initPortraitGalleries(scope);
63
64
  initMosaics(scope);
64
65
  initStoryReels(scope);
66
+ initDecks(scope);
65
67
  initAccordions(scope);
66
68
  initProgressBars(scope);
67
69
  initReveals(scope);
@@ -75,10 +77,16 @@
75
77
  initLangSwitches(scope);
76
78
  }
77
79
 
78
- document.addEventListener('DOMContentLoaded', function () {
79
- initPage();
80
+ // A failure in the page-wide part must not take the components with it,
81
+ // and a script that arrives after the parse (async, injected) still starts
82
+ function start() {
83
+ try {
84
+ initPage();
85
+ } catch (error) {
86
+ console.error('MaverickWave: page behaviour not initialised', error);
87
+ }
80
88
  initComponents(document);
81
- });
89
+ }
82
90
 
83
91
  // The way back in for anything that renders after DOMContentLoaded: an htmx
84
92
  // swap, a modal filled from a fetch, a view transition that replaces the
@@ -809,6 +817,250 @@
809
817
  });
810
818
  }
811
819
 
820
+ // ===== Decks =====
821
+ // The swipe is the browser's own scroll snap. This adds what snapping cannot:
822
+ // buttons, keys and the wheel, counter, progress, fullscreen and the entrance.
823
+ // A deck that fails leaves the others, and the rest of the page, working.
824
+ function initDecks(root) {
825
+ root.querySelectorAll('.mw-deck').forEach((deck) => {
826
+ if (!fresh(deck)) return;
827
+ try {
828
+ initDeck(deck);
829
+ } catch (error) {
830
+ console.error('MaverickWave: deck not initialised', error);
831
+ }
832
+ });
833
+ }
834
+
835
+ function initDeck(deck) {
836
+ const scroller = deck.querySelector('.mw-deck-slides');
837
+ if (!scroller) return;
838
+ const slides = Array.from(scroller.children).filter((child) =>
839
+ child.classList.contains('mw-slide')
840
+ );
841
+ if (!slides.length) return;
842
+
843
+ const across = !deck.classList.contains('mw-deck-portrait');
844
+ // A deck that is the page takes the keys, the wheel and the address bar;
845
+ // one sitting inside a page leaves them to it
846
+ const page = deck === document.body;
847
+ const counter = deck.querySelector('.mw-deck-counter');
848
+ const segments = deck.querySelector('.mw-deck-segments');
849
+ const pad = (n) => String(n).padStart(2, '0');
850
+ const start = Number.parseInt(location.hash.slice(1), 10) - 1;
851
+ const first = page && start > 0 && start < slides.length ? start : 0;
852
+ // Where the controls count from - set at once, while the slide itself is
853
+ // only lit a frame later, after its entrance could be painted
854
+ let current = first;
855
+ let lit = -1;
856
+ let started = false;
857
+
858
+ if (segments) {
859
+ segments.replaceChildren(
860
+ ...slides.map(() => document.createElement('span'))
861
+ );
862
+ }
863
+
864
+ function activate(index) {
865
+ if (index < 0 || index >= slides.length) return;
866
+ current = index;
867
+ if (index === lit) return;
868
+ lit = index;
869
+ slides.forEach((slide, i) => setActive(slide, i === index));
870
+ if (counter) {
871
+ const now = document.createElement('span');
872
+ now.className = 'mw-deck-counter-current';
873
+ now.textContent = pad(index + 1);
874
+ counter.replaceChildren(now, ' / ' + pad(slides.length));
875
+ }
876
+ deck.style.setProperty(
877
+ '--mw-deck-progress',
878
+ String((index + 1) / slides.length)
879
+ );
880
+ if (segments) {
881
+ Array.from(segments.children).forEach((segment, i) =>
882
+ segment.classList.toggle('mw-done', i <= index)
883
+ );
884
+ }
885
+ // A sandboxed frame or a page from the file system may refuse it
886
+ if (page) {
887
+ try {
888
+ history.replaceState(history.state, '', '#' + (index + 1));
889
+ } catch {
890
+ // the slide number in the address is a convenience, not a feature
891
+ }
892
+ }
893
+ }
894
+
895
+ const offset = (slide) => (across ? slide.offsetLeft : slide.offsetTop);
896
+
897
+ function show(index, smooth = true) {
898
+ const slide = slides[Math.max(0, Math.min(slides.length - 1, index))];
899
+ const reduced =
900
+ window.matchMedia &&
901
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
902
+ scroller.scrollTo({
903
+ left: across ? slide.offsetLeft : 0,
904
+ top: across ? 0 : slide.offsetTop,
905
+ behavior: smooth && !reduced ? 'smooth' : 'auto',
906
+ });
907
+ }
908
+
909
+ // The slide nearest the scroll position is the one on screen. Read from
910
+ // the position rather than an observer's threshold, so it holds for any
911
+ // slide size and in a browser without IntersectionObserver.
912
+ function nearest() {
913
+ const position = across ? scroller.scrollLeft : scroller.scrollTop;
914
+ let best = 0;
915
+ slides.forEach((slide, i) => {
916
+ if (
917
+ Math.abs(offset(slide) - position) <
918
+ Math.abs(offset(slides[best]) - position)
919
+ ) {
920
+ best = i;
921
+ }
922
+ });
923
+ return best;
924
+ }
925
+
926
+ let queued = false;
927
+ scroller.addEventListener(
928
+ 'scroll',
929
+ () => {
930
+ if (!started || queued) return;
931
+ queued = true;
932
+ requestAnimationFrame(() => {
933
+ queued = false;
934
+ activate(nearest());
935
+ });
936
+ },
937
+ { passive: true }
938
+ );
939
+
940
+ const fullscreenElement = () =>
941
+ document.fullscreenElement || document.webkitFullscreenElement;
942
+ const fullscreenEnabled =
943
+ document.fullscreenEnabled || document.webkitFullscreenEnabled;
944
+ const fullscreenTarget = page ? document.documentElement : deck;
945
+
946
+ function toggleFullscreen() {
947
+ if (!fullscreenEnabled) return;
948
+ try {
949
+ const result = fullscreenElement()
950
+ ? (document.exitFullscreen || document.webkitExitFullscreen).call(
951
+ document
952
+ )
953
+ : (
954
+ fullscreenTarget.requestFullscreen ||
955
+ fullscreenTarget.webkitRequestFullscreen
956
+ ).call(fullscreenTarget);
957
+ // Refused by the browser or by the frame around the deck
958
+ if (result && result.catch) result.catch(() => {});
959
+ } catch {
960
+ // same refusal in a browser that throws instead of rejecting
961
+ }
962
+ }
963
+
964
+ // No fullscreen for anything but video on an iPhone, or in a frame
965
+ // without allow="fullscreen" - the button goes
966
+ if (!fullscreenEnabled) {
967
+ deck.querySelectorAll('[data-deck-fullscreen]').forEach((button) => {
968
+ button.hidden = true;
969
+ });
970
+ }
971
+
972
+ // Fullscreen or a turn changes the width, and the slide would stop half-way
973
+ const settle = () => show(current, false);
974
+ ['fullscreenchange', 'webkitfullscreenchange'].forEach((type) => {
975
+ document.addEventListener(type, () => {
976
+ deck.classList.toggle(
977
+ 'mw-deck-fullscreen',
978
+ Boolean(fullscreenElement())
979
+ );
980
+ settle();
981
+ });
982
+ });
983
+ window.addEventListener('resize', settle);
984
+
985
+ deck.addEventListener('click', (e) => {
986
+ if (!(e.target instanceof Element)) return;
987
+ const control = e.target.closest(
988
+ '[data-deck-goto], [data-deck-next], [data-deck-prev], [data-deck-fullscreen]'
989
+ );
990
+ if (!control) return;
991
+ if (control.hasAttribute('data-deck-goto')) {
992
+ const index = slides.findIndex(
993
+ (slide) => slide.id === control.dataset.deckGoto
994
+ );
995
+ if (index >= 0) show(index);
996
+ } else if (control.hasAttribute('data-deck-next')) show(current + 1);
997
+ else if (control.hasAttribute('data-deck-prev')) show(current - 1);
998
+ else toggleFullscreen();
999
+ });
1000
+
1001
+ (page ? document : deck).addEventListener('keydown', (e) => {
1002
+ if (e.defaultPrevented || e.isComposing) return;
1003
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
1004
+ const focus = e.target instanceof Element ? e.target : document.body;
1005
+ if (focus.closest('input, textarea, select, [contenteditable]')) return;
1006
+ // Space presses a button reached by keyboard. One focused by a click -
1007
+ // fullscreen, a jump tile - must not be pressed again by the next page.
1008
+ if (
1009
+ e.key === ' ' &&
1010
+ focus.closest('button, a, summary') &&
1011
+ focus.matches(':focus-visible')
1012
+ ) {
1013
+ return;
1014
+ }
1015
+ const steps = {
1016
+ ArrowRight: 1,
1017
+ ArrowDown: 1,
1018
+ PageDown: 1,
1019
+ ' ': e.shiftKey ? -1 : 1,
1020
+ ArrowLeft: -1,
1021
+ ArrowUp: -1,
1022
+ PageUp: -1,
1023
+ };
1024
+ if (e.key in steps) show(current + steps[e.key]);
1025
+ else if (e.key === 'Home') show(0);
1026
+ else if (e.key === 'End') show(slides.length - 1);
1027
+ else if (e.key === 'f' || e.key === 'F') toggleFullscreen();
1028
+ else return;
1029
+ e.preventDefault();
1030
+ });
1031
+
1032
+ // A wheel does not move a landscape deck at all and a portrait one only
1033
+ // over the phone, so it pages itself. Embedded in another page the wheel
1034
+ // belongs to that page, and a pinch on a trackpad (ctrlKey) stays a zoom.
1035
+ if (page && window.self === window.top) {
1036
+ let locked = false;
1037
+ document.addEventListener(
1038
+ 'wheel',
1039
+ (e) => {
1040
+ if (e.ctrlKey) return;
1041
+ if (!across && scroller.contains(e.target)) return;
1042
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
1043
+ e.preventDefault();
1044
+ if (locked || Math.abs(e.deltaY) < 8) return;
1045
+ locked = true;
1046
+ show(current + Math.sign(e.deltaY));
1047
+ setTimeout(() => (locked = false), 700);
1048
+ },
1049
+ { passive: false }
1050
+ );
1051
+ }
1052
+
1053
+ deck.classList.add('mw-deck-ready');
1054
+ show(first, false);
1055
+ // The entrance needs one painted frame in its hidden state
1056
+ requestAnimationFrame(() =>
1057
+ requestAnimationFrame(() => {
1058
+ started = true;
1059
+ activate(first);
1060
+ })
1061
+ );
1062
+ }
1063
+
812
1064
  // ===== Theme Toggle =====
813
1065
  function initThemeToggle() {
814
1066
  const themeToggle = document.querySelector('.mw-theme-toggle');
@@ -2195,4 +2447,520 @@
2195
2447
 
2196
2448
  render();
2197
2449
  }
2450
+
2451
+ // ===== Occasions =====
2452
+ // The CSS draws every piece, occasions.js in <head> picks the occasion before
2453
+ // the first paint. This plays the two parts that need a script once the page
2454
+ // is there: the intro over the whole page and the particles that drift with
2455
+ // the scroll. An SPA sets the classes on <html> itself - see
2456
+ // references/javascript.md.
2457
+ //
2458
+ // No constants at this level: start() can run before the end of this file
2459
+ // has been evaluated, and a const read at that point throws.
2460
+ function initOccasions() {
2461
+ const effects = [
2462
+ 'snow',
2463
+ 'christmas',
2464
+ 'newyear',
2465
+ 'spring',
2466
+ 'easter',
2467
+ 'summer',
2468
+ 'autumn',
2469
+ 'football',
2470
+ 'birthday',
2471
+ 'anniversary',
2472
+ ];
2473
+ const html = document.documentElement;
2474
+ const params = new URLSearchParams(location.search);
2475
+ const preview = params.get('mw-occasion');
2476
+
2477
+ // A preview on a page without occasions.js - a customer's site that has
2478
+ // not booked anything yet - applies here, a moment later
2479
+ if (
2480
+ effects.includes(preview) &&
2481
+ !html.classList.contains('mw-occasion-' + preview)
2482
+ ) {
2483
+ effects.forEach((name) => html.classList.remove('mw-occasion-' + name));
2484
+ html.classList.add('mw-occasion-' + preview);
2485
+ html.classList.toggle(
2486
+ 'mw-occasion-scroll',
2487
+ params.has('mw-occasion-scroll')
2488
+ );
2489
+ html.classList.toggle(
2490
+ 'mw-occasion-sm',
2491
+ params.get('mw-occasion-size') === 'sm'
2492
+ );
2493
+ html.classList.toggle(
2494
+ 'mw-occasion-lg',
2495
+ params.get('mw-occasion-size') === 'lg'
2496
+ );
2497
+ if (params.has('mw-occasion-intro')) {
2498
+ html.setAttribute('data-mw-occasion-intro', 'preview');
2499
+ }
2500
+ }
2501
+
2502
+ const effect = effects.find((name) =>
2503
+ html.classList.contains('mw-occasion-' + name)
2504
+ );
2505
+ if (!effect) return;
2506
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
2507
+
2508
+ if (html.classList.contains('mw-occasion-scroll')) {
2509
+ startOccasionDrift(effect);
2510
+ }
2511
+
2512
+ // 'preview' plays on every load, an empty value once per visit
2513
+ const intro = html.getAttribute('data-mw-occasion-intro');
2514
+ const saveData = navigator.connection && navigator.connection.saveData;
2515
+ if (
2516
+ intro !== null &&
2517
+ !saveData &&
2518
+ (intro === 'preview' || !occasionSeen(effect))
2519
+ ) {
2520
+ whenShown(() => playOccasionIntro(effect));
2521
+ }
2522
+ }
2523
+
2524
+ // Once per visit - on every page it would be a nuisance by the third click
2525
+ function occasionSeen(effect) {
2526
+ try {
2527
+ if (sessionStorage.getItem('mw-occasion-intro') === effect) return true;
2528
+ sessionStorage.setItem('mw-occasion-intro', effect);
2529
+ } catch {
2530
+ // Storage blocked: twice is better than never
2531
+ }
2532
+ return false;
2533
+ }
2534
+
2535
+ // After the load, so it does not compete with the page for the first frames,
2536
+ // and not into a background tab, where nobody would see it
2537
+ function whenShown(run) {
2538
+ function visible() {
2539
+ if (!document.hidden) return run();
2540
+ document.addEventListener('visibilitychange', function wait() {
2541
+ if (document.hidden) return;
2542
+ document.removeEventListener('visibilitychange', wait);
2543
+ run();
2544
+ });
2545
+ }
2546
+
2547
+ if (document.readyState === 'complete') visible();
2548
+ else window.addEventListener('load', visible, { once: true });
2549
+ }
2550
+
2551
+ function between(min, max) {
2552
+ return min + Math.random() * (max - min);
2553
+ }
2554
+
2555
+ function pick(list) {
2556
+ return list[Math.floor(Math.random() * list.length)];
2557
+ }
2558
+
2559
+ function signed(min, max) {
2560
+ return (Math.random() < 0.5 ? -1 : 1) * between(min, max);
2561
+ }
2562
+
2563
+ // CSSOM and not a style attribute - a CSP without 'unsafe-inline' allows
2564
+ // the one and drops the other
2565
+ function occasionParticle(layer, shape, motion, vars) {
2566
+ const el = document.createElement('span');
2567
+ el.className =
2568
+ 'mw-occasion-particle mw-occasion-' + shape + ' mw-occasion-' + motion;
2569
+ Object.keys(vars).forEach((name) => el.style.setProperty(name, vars[name]));
2570
+ layer.appendChild(el);
2571
+ }
2572
+
2573
+ // A phone gets fewer particles than a desktop, a slow device half of that
2574
+ function occasionDensity() {
2575
+ const area = (window.innerWidth * window.innerHeight) / (1440 * 900);
2576
+ const weak =
2577
+ (navigator.hardwareConcurrency || 8) <= 4 ||
2578
+ (navigator.deviceMemory || 8) <= 4;
2579
+ return (
2580
+ Math.min(1, Math.max(0.4, area)) * (weak ? 0.5 : 1) * occasionAmount()
2581
+ );
2582
+ }
2583
+
2584
+ // --mw-occasion-amount, set by mw-occasion-sm / -lg or by the site itself
2585
+ function occasionAmount() {
2586
+ const amount = parseFloat(
2587
+ getComputedStyle(document.documentElement).getPropertyValue(
2588
+ '--mw-occasion-amount'
2589
+ )
2590
+ );
2591
+ return amount > 0 ? amount : 1;
2592
+ }
2593
+
2594
+ // Confetti flips end over end. A leaf or a petal that did the same would
2595
+ // spend half its fall edge-on, a sliver nobody reads as a leaf - it mostly
2596
+ // turns in the plane and only tilts.
2597
+ function occasionAxis(flip) {
2598
+ if (flip === 'soft') {
2599
+ return (
2600
+ between(0.2, 0.6).toFixed(2) + ' ' + between(0, 0.5).toFixed(2) + ' 1'
2601
+ );
2602
+ }
2603
+ return flip ? '1 ' + between(0, 1).toFixed(2) + ' 0.3' : 'z';
2604
+ }
2605
+
2606
+ function occasionFall(layer, count, shapes, o) {
2607
+ for (let i = 0; i < count; i++) {
2608
+ occasionParticle(layer, pick(shapes), o.rising ? 'rising' : 'falling', {
2609
+ '--mw-occasion-x': between(-5, 100).toFixed(1) + 'vw',
2610
+ '--mw-occasion-drift':
2611
+ between(o.drift[0], o.drift[1]).toFixed(1) + 'vw',
2612
+ '--mw-occasion-size': between(o.size[0], o.size[1]).toFixed(1) + 'px',
2613
+ '--mw-occasion-time': between(o.time[0], o.time[1]).toFixed(2) + 's',
2614
+ '--mw-occasion-delay': between(0, o.spread).toFixed(2) + 's',
2615
+ '--mw-occasion-sway': between(6, 22).toFixed(0) + 'px',
2616
+ '--mw-occasion-sway-time': between(1.2, 2.4).toFixed(2) + 's',
2617
+ '--mw-occasion-spin': signed(0.3, o.spin || 0.3).toFixed(2) + 'turn',
2618
+ '--mw-occasion-spin-time': between(2.5, 5).toFixed(2) + 's',
2619
+ '--mw-occasion-axis': occasionAxis(o.flip),
2620
+ });
2621
+ }
2622
+ }
2623
+
2624
+ // Two confetti cannons in the bottom corners, a second volley a moment later
2625
+ function occasionBurst(layer, count, shapes, o) {
2626
+ for (let i = 0; i < count; i++) {
2627
+ const left = i % 2 === 0;
2628
+ occasionParticle(layer, pick(shapes), 'bursting', {
2629
+ '--mw-occasion-x': left ? '-2vw' : '100vw',
2630
+ '--mw-occasion-dx': (left ? 1 : -1) * between(8, 62) + 'vw',
2631
+ '--mw-occasion-peak': between(55, 92) + 'vh',
2632
+ '--mw-occasion-size': between(o.size[0], o.size[1]).toFixed(1) + 'px',
2633
+ '--mw-occasion-time': between(2.6, 3.6).toFixed(2) + 's',
2634
+ '--mw-occasion-delay':
2635
+ (between(0, 0.35) + (i % 3 === 2 ? 0.9 : 0)).toFixed(2) + 's',
2636
+ '--mw-occasion-spin': signed(1.5, o.spin).toFixed(2) + 'turn',
2637
+ '--mw-occasion-spin-time': between(0.8, 1.6).toFixed(2) + 's',
2638
+ '--mw-occasion-axis': occasionAxis(o.flip),
2639
+ });
2640
+ }
2641
+ }
2642
+
2643
+ // Glints of sunlight, most of them near the sun in the top right corner
2644
+ function occasionTwinkle(layer, count) {
2645
+ for (let i = 0; i < count; i++) {
2646
+ occasionParticle(layer, 'glint', 'twinkling', {
2647
+ '--mw-occasion-x': between(35, 96).toFixed(1) + 'vw',
2648
+ '--mw-occasion-y': between(4, 62).toFixed(1) + 'vh',
2649
+ '--mw-occasion-size': between(14, 30).toFixed(1) + 'px',
2650
+ '--mw-occasion-time': between(1.2, 2.2).toFixed(2) + 's',
2651
+ '--mw-occasion-delay': between(0.4, 2.8).toFixed(2) + 's',
2652
+ '--mw-occasion-spin': signed(0.1, 0.25).toFixed(2) + 'turn',
2653
+ });
2654
+ }
2655
+ }
2656
+
2657
+ function occasionFireworks(layer, bursts, sparks) {
2658
+ const tints = ['#ffd166', '#fff1c1', '#ff6b6b', '#4cc9f0', '#c4a7ff'];
2659
+
2660
+ for (let b = 0; b < bursts; b++) {
2661
+ const x = between(14, 86).toFixed(1) + 'vw';
2662
+ const y = between(14, 46).toFixed(1) + 'vh';
2663
+ const start = b * 0.55 + between(0, 0.2);
2664
+ const bang = (start + 0.65).toFixed(2) + 's';
2665
+
2666
+ occasionParticle(layer, 'trail', 'launching', {
2667
+ '--mw-occasion-x': x,
2668
+ '--mw-occasion-y': y,
2669
+ '--mw-occasion-size': '6px',
2670
+ '--mw-occasion-delay': start.toFixed(2) + 's',
2671
+ });
2672
+ occasionParticle(layer, 'flash', 'flashing', {
2673
+ '--mw-occasion-x': x,
2674
+ '--mw-occasion-y': y,
2675
+ '--mw-occasion-size': '26vmin',
2676
+ '--mw-occasion-delay': bang,
2677
+ });
2678
+
2679
+ for (let i = 0; i < sparks; i++) {
2680
+ const inner = i % 3 === 0;
2681
+ const angle = (360 / sparks) * i + between(-5, 5);
2682
+ const reach = inner ? between(6, 10) : between(14, 22);
2683
+ const rad = (angle * Math.PI) / 180;
2684
+ occasionParticle(layer, 'spark', 'exploding', {
2685
+ '--mw-occasion-x': x,
2686
+ '--mw-occasion-y': y,
2687
+ '--mw-occasion-dx': (Math.cos(rad) * reach).toFixed(2) + 'vmin',
2688
+ '--mw-occasion-dy': (Math.sin(rad) * reach).toFixed(2) + 'vmin',
2689
+ '--mw-occasion-angle': (angle + 90).toFixed(0) + 'deg',
2690
+ '--mw-occasion-size': inner ? '12px' : '20px',
2691
+ '--mw-occasion-tint': tints[b % tints.length],
2692
+ '--mw-occasion-time': between(1.3, 1.8).toFixed(2) + 's',
2693
+ '--mw-occasion-delay': bang,
2694
+ });
2695
+ }
2696
+ }
2697
+ }
2698
+
2699
+ // Attributes only - a style attribute in here would fall to the CSP
2700
+ function occasionTree() {
2701
+ const lights = [
2702
+ [46.5, 46.5],
2703
+ [61, 48.5],
2704
+ [74.5, 45.1],
2705
+ [39, 74],
2706
+ [53.7, 77],
2707
+ [68.2, 75.6],
2708
+ [81.3, 70.3],
2709
+ [31.6, 103.7],
2710
+ [49.8, 107.5],
2711
+ [69.3, 106.5],
2712
+ [88.6, 100.3],
2713
+ ]
2714
+ .map(
2715
+ ([x, y]) =>
2716
+ '<g class="mw-occasion-tree-light">' +
2717
+ `<circle cx="${x}" cy="${y}" r="4.6" fill="url(#mw-tree-light)"/>` +
2718
+ `<circle cx="${x}" cy="${y}" r="1.4" fill="#fffbe6"/></g>`
2719
+ )
2720
+ .join('');
2721
+
2722
+ return (
2723
+ '<svg class="mw-occasion-tree" viewBox="0 0 120 150">' +
2724
+ '<defs>' +
2725
+ '<linearGradient id="mw-tree-fir" x1="0" x2="1"><stop offset="0" stop-color="#2a7a45"/><stop offset=".55" stop-color="#17532d"/><stop offset="1" stop-color="#0d3a1e"/></linearGradient>' +
2726
+ '<radialGradient id="mw-tree-light"><stop offset="0" stop-color="#fffbe6"/><stop offset=".35" stop-color="#ffe08a"/><stop offset="1" stop-color="#ffd166" stop-opacity="0"/></radialGradient>' +
2727
+ '<radialGradient id="mw-tree-glow"><stop offset="0" stop-color="#fff3b0" stop-opacity=".9"/><stop offset="1" stop-color="#ffd166" stop-opacity="0"/></radialGradient>' +
2728
+ '<linearGradient id="mw-tree-gold" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff4c2"/><stop offset=".5" stop-color="#f1c84b"/><stop offset="1" stop-color="#b8860b"/></linearGradient>' +
2729
+ '</defs>' +
2730
+ '<rect x="53" y="108" width="14" height="18" rx="2" fill="#6b4226"/>' +
2731
+ '<g fill="url(#mw-tree-fir)">' +
2732
+ '<path d="M60 56C70 76 86 94 106 112C84 106 36 106 14 112C34 94 50 76 60 56Z"/>' +
2733
+ '<path d="M60 32C68 48 80 64 96 80C80 76 40 76 24 80C40 64 52 48 60 32Z"/>' +
2734
+ '<path d="M60 12C66 24 74 36 86 50C76 47 44 47 34 50C46 36 54 24 60 12Z"/>' +
2735
+ '</g>' +
2736
+ '<g fill="none" stroke="#e9c46a" stroke-width="1.4" stroke-opacity=".85">' +
2737
+ '<path d="M40 44Q62 54 80 42"/><path d="M30 70Q60 86 88 66"/><path d="M22 100Q62 118 100 94"/>' +
2738
+ '</g>' +
2739
+ '<circle cx="54" cy="38" r="3.4" fill="#d62839"/><circle cx="68" cy="39" r="3" fill="#e9b949"/>' +
2740
+ '<circle cx="46" cy="62" r="3.4" fill="#e9b949"/><circle cx="74" cy="62" r="3.4" fill="#d62839"/><circle cx="60" cy="66" r="3" fill="#cfd6de"/>' +
2741
+ '<circle cx="40" cy="94" r="3.6" fill="#d62839"/><circle cx="62" cy="95" r="3.6" fill="#e9b949"/><circle cx="84" cy="92" r="3.4" fill="#cfd6de"/>' +
2742
+ '<g>' +
2743
+ lights +
2744
+ '</g>' +
2745
+ '<circle class="mw-occasion-tree-glow" cx="60" cy="12" r="18" fill="url(#mw-tree-glow)"/>' +
2746
+ '<path class="mw-occasion-tree-star" fill="url(#mw-tree-gold)" d="M60 3L62.23 8.93L68.56 9.22L63.61 13.17L65.29 19.28L60 15.8L54.71 19.28L56.39 13.17L51.44 9.22L57.77 8.93Z"/>' +
2747
+ '</svg>'
2748
+ );
2749
+ }
2750
+
2751
+ // One recipe per occasion: what the intro throws, how many, how fast
2752
+ function occasionRecipes() {
2753
+ const petals = {
2754
+ size: [18, 28],
2755
+ drift: [12, 40],
2756
+ time: [3, 4.2],
2757
+ spread: 1.4,
2758
+ spin: 1.2,
2759
+ flip: 'soft',
2760
+ };
2761
+
2762
+ return {
2763
+ snow: (layer, n) =>
2764
+ occasionFall(layer, n(70), ['flake', 'flake', 'flake', 'crystal'], {
2765
+ size: [6, 17],
2766
+ drift: [-4, 6],
2767
+ time: [4.2, 6.2],
2768
+ spread: 2.4,
2769
+ spin: 0.5,
2770
+ }),
2771
+ christmas: (layer, n) => {
2772
+ layer.insertAdjacentHTML('beforeend', occasionTree());
2773
+ occasionFall(layer, n(34), ['flake', 'flake', 'star'], {
2774
+ size: [6, 16],
2775
+ drift: [-4, 6],
2776
+ time: [4.4, 6.4],
2777
+ spread: 2.4,
2778
+ spin: 0.6,
2779
+ });
2780
+ },
2781
+ newyear: (layer, n) =>
2782
+ occasionFireworks(
2783
+ layer,
2784
+ Math.min(6, Math.max(3, n(5))),
2785
+ Math.max(18, n(36))
2786
+ ),
2787
+ spring: (layer, n) => occasionFall(layer, n(44), ['petal'], petals),
2788
+ easter: (layer, n) => occasionFall(layer, n(44), ['petal'], petals),
2789
+ summer: (layer, n) => {
2790
+ const sun = document.createElement('div');
2791
+ sun.className = 'mw-occasion-sun';
2792
+ layer.appendChild(sun);
2793
+ occasionTwinkle(layer, n(18));
2794
+ },
2795
+ autumn: (layer, n) =>
2796
+ occasionFall(layer, n(32), ['maple', 'oak', 'birch'], {
2797
+ size: [30, 46],
2798
+ drift: [-8, 22],
2799
+ time: [3, 4.2],
2800
+ spread: 1.4,
2801
+ spin: 1.5,
2802
+ flip: 'soft',
2803
+ }),
2804
+ football: (layer, n) => {
2805
+ occasionBurst(layer, n(80), ['strip'], {
2806
+ size: [12, 20],
2807
+ spin: 5,
2808
+ flip: true,
2809
+ });
2810
+ occasionBurst(layer, 3, ['ball'], { size: [34, 46], spin: 3 });
2811
+ },
2812
+ birthday: (layer, n) => {
2813
+ occasionBurst(layer, n(90), ['confetti'], {
2814
+ size: [10, 15],
2815
+ spin: 5,
2816
+ flip: true,
2817
+ });
2818
+ occasionFall(layer, Math.max(4, n(8)), ['balloon'], {
2819
+ size: [44, 62],
2820
+ drift: [-6, 6],
2821
+ time: [3.8, 4.8],
2822
+ spread: 0.8,
2823
+ rising: true,
2824
+ });
2825
+ },
2826
+ anniversary: (layer, n) =>
2827
+ occasionBurst(layer, n(90), ['confetti', 'confetti', 'glint'], {
2828
+ size: [10, 16],
2829
+ spin: 5,
2830
+ flip: true,
2831
+ }),
2832
+ };
2833
+ }
2834
+
2835
+ // Click-through from the first frame; any click, key or scroll takes it
2836
+ // away early instead of making the reader wait for it
2837
+ function playOccasionIntro(effect) {
2838
+ const layer = document.createElement('div');
2839
+ layer.className = 'mw-occasion-intro mw-occasion-intro-' + effect;
2840
+ layer.setAttribute('aria-hidden', 'true');
2841
+ const density = occasionDensity();
2842
+ occasionRecipes()[effect](layer, (count) =>
2843
+ Math.max(1, Math.round(count * density))
2844
+ );
2845
+ document.body.appendChild(layer);
2846
+
2847
+ const events = ['pointerdown', 'keydown', 'wheel', 'touchmove'];
2848
+ function remove() {
2849
+ layer.remove();
2850
+ events.forEach((type) => window.removeEventListener(type, leave));
2851
+ }
2852
+ function leave() {
2853
+ layer.classList.add('mw-occasion-leaving');
2854
+ setTimeout(remove, 350);
2855
+ }
2856
+
2857
+ events.forEach((type) =>
2858
+ window.addEventListener(type, leave, { once: true, passive: true })
2859
+ );
2860
+ layer.addEventListener('animationend', (event) => {
2861
+ if (event.target === layer) remove();
2862
+ });
2863
+ }
2864
+
2865
+ // Scrubbed by the scroll on the compositor, so it stands still whenever the
2866
+ // page does. A lap is a little more than a screen of scrolling, which makes
2867
+ // the count a matter of page length.
2868
+ function startOccasionDrift(effect) {
2869
+ // Not CSS.supports: Firefox behind its flag parses the syntax, but plays
2870
+ // the whole page as one lap - pieces that crawl read as stuck
2871
+ if (!window.ScrollTimeline) return;
2872
+
2873
+ const layer = document.createElement('div');
2874
+ layer.className = 'mw-occasion-drift';
2875
+ layer.setAttribute('aria-hidden', 'true');
2876
+ if (effect === 'summer') occasionBeams(layer);
2877
+ else occasionDriftParticles(layer, effect);
2878
+ document.body.appendChild(layer);
2879
+
2880
+ function laps() {
2881
+ const scrollable = document.documentElement.scrollHeight - innerHeight;
2882
+ layer.style.setProperty(
2883
+ '--mw-occasion-laps',
2884
+ String(Math.max(1, Math.round(scrollable / (innerHeight * 1.2))))
2885
+ );
2886
+ }
2887
+ laps();
2888
+ window.addEventListener('load', laps);
2889
+ window.addEventListener('resize', debounce(laps, 200), { passive: true });
2890
+ }
2891
+
2892
+ // Sizes at a 1280px window, scaled with it within limits. Smaller than this a
2893
+ // leaf on the page reads as a speck, and a speck as a rendering fault.
2894
+ function occasionDriftParticles(layer, effect) {
2895
+ const shapes = {
2896
+ snow: ['flake', 'flake', 'crystal'],
2897
+ christmas: ['flake', 'flake', 'star'],
2898
+ newyear: ['glint'],
2899
+ spring: ['petal'],
2900
+ easter: ['petal'],
2901
+ autumn: ['maple', 'oak', 'birch'],
2902
+ football: ['strip'],
2903
+ birthday: ['confetti'],
2904
+ anniversary: ['confetti', 'glint'],
2905
+ }[effect];
2906
+ const sizes = {
2907
+ flake: [9, 16],
2908
+ crystal: [16, 22],
2909
+ star: [14, 20],
2910
+ glint: [16, 24],
2911
+ petal: [18, 26],
2912
+ maple: [28, 40],
2913
+ oak: [28, 40],
2914
+ birch: [24, 34],
2915
+ strip: [12, 18],
2916
+ confetti: [11, 17],
2917
+ };
2918
+ const scale = Math.min(1.15, Math.max(0.85, window.innerWidth / 1280));
2919
+ const count = Math.round(
2920
+ (window.innerWidth < 768 ? 11 : 18) * occasionAmount()
2921
+ );
2922
+
2923
+ for (let i = 0; i < count; i++) {
2924
+ const shape = shapes[i % shapes.length];
2925
+ const soft = ['maple', 'oak', 'birch', 'petal'].includes(shape);
2926
+ occasionParticle(layer, shape, 'falling', {
2927
+ '--mw-occasion-x':
2928
+ ((i + between(0.1, 0.9)) * (100 / count)).toFixed(1) + 'vw',
2929
+ '--mw-occasion-size':
2930
+ (between(sizes[shape][0], sizes[shape][1]) * scale).toFixed(1) + 'px',
2931
+ '--mw-occasion-start': (((i * 37) % 100) * 0.3).toFixed(1) + '%',
2932
+ '--mw-occasion-lap': String(i % 3),
2933
+ '--mw-occasion-drift': between(-6, 6).toFixed(1) + 'vw',
2934
+ '--mw-occasion-sway': between(8, 24).toFixed(0) + 'px',
2935
+ '--mw-occasion-spin': signed(0.5, 2).toFixed(2) + 'turn',
2936
+ '--mw-occasion-axis': occasionAxis(
2937
+ soft ? 'soft' : ['confetti', 'strip'].includes(shape)
2938
+ ),
2939
+ });
2940
+ }
2941
+ }
2942
+
2943
+ // Summer has no particles, it has rays - fanned out from above the top right
2944
+ // corner and swinging with the scroll
2945
+ function occasionBeams(layer) {
2946
+ const count = Math.round(5 * Math.min(1.4, occasionAmount()));
2947
+
2948
+ for (let i = 0; i < count; i++) {
2949
+ occasionParticle(layer, 'beam', 'beaming', {
2950
+ '--mw-occasion-size': between(9, 16).toFixed(1) + 'vw',
2951
+ '--mw-occasion-angle':
2952
+ (24 + (i * 54) / Math.max(1, count - 1) + between(-3, 3)).toFixed(1) +
2953
+ 'deg',
2954
+ '--mw-occasion-lap': String(i % 2),
2955
+ });
2956
+ }
2957
+ }
2958
+
2959
+ // Last, not next to start(): a script that arrives after the parse starts at
2960
+ // once, and every const below start() would still be uninitialised
2961
+ if (document.readyState === 'loading') {
2962
+ document.addEventListener('DOMContentLoaded', start);
2963
+ } else {
2964
+ start();
2965
+ }
2198
2966
  })();