openvibers/openvibe.shared ov-mark.js
ov-mark.js
| 1 | /** |
| 2 | * ov-mark.js — the OpenVibe mark as a drop-in. |
| 3 | * <span class="ov-mark"></span> → a 32px mark (the box is square, the mark centred in it) |
| 4 | * <span class="ov-mark" data-size="56"> → any size (the box's width); add data-static="1" for no motion |
| 5 | * <span class="ov-mark" data-wide> → the box hugs the mark (92 × 56) instead of a square |
| 6 | * <span class="ov-mark" data-variant="live"> → the site's own touch (Live's record light in the O, see below) |
| 7 | * <span class="ov-mark" data-tone="ink|white"> → one colour: the text's (stamps, UI) or white (on a colour tile) |
| 8 | * <span class="ov-mark" data-form="heavy"> → force a form (default: heavy up to 22px wide, the pair above) |
| 9 | * data-state="busy|ok|error|no|notify|live|sleep" → what it shows (island.js sets busy/ok/error on the navbar's) |
| 10 | * data-progress="0.4" with style="--ovm-p:.4" → a known progress: the ring draws round to that much |
| 11 | * OVMark.busy(button) → { done, fail, reset }: a button at work shows the mark busy, then the tick or the "!". A form |
| 12 | * posted the old way (POST) does this by itself until the next page; data-ov-busy="off" on a form opts out. |
| 13 | * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages). The same mark as static |
| 14 | * SVG, for servers and icons: openvibe-shared/mark (mark.js), whose numbers this file draws. |
| 15 | * |
| 16 | * The mark is the Pair (the owner, 2026-10-31: "Pair, halo … this should be our new logo"): a ring O and a round V, |
| 17 | * with a gap of the ground around the V where it crosses the ring. The gap is a mask, so the mark sits on any ground. |
| 18 | * The V is as tall as the O, so it sits level beside a word in the navbar and centred in a tile. Two forms: the pair, |
| 19 | * and the heavy (thicker strokes) for 22px and under, where the pair's gap would close. |
| 20 | * |
| 21 | * Everything it does means something, and nothing spins: |
| 22 | * - intro: the O rises and draws itself round, the V drops in on a spring, then a glint of light crosses the mark; |
| 23 | * - hover: the V leans back, the O hops, a glint crosses and a soft glow of its two colours comes up behind it; |
| 24 | * - press: it gives a little and springs back; |
| 25 | * Each stroke runs light to deep in its own colour (data-glow keeps the glow on, for heroes and brand pages). |
| 26 | * - a page on its way (navbar): the ring empties and draws itself round again as the next page loads, paced by how |
| 27 | * long this site's pages usually take in this browser (learned per site, boost moves and full loads apart); the |
| 28 | * V nods while it waits and hops when the page is here (window.OVMark.navStart/navEnd for single-page apps); |
| 29 | * - busy (working): the O stays the logo's own ring, a faint track, and a bright arc runs round it, growing as it |
| 30 | * goes and drawing in behind, while the V lifts in time and the glow breathes (4.236.0; it was eight thinner dots, |
| 31 | * 2026-11-10, which sat inside the ring's line: "improve the O so it actually looks like the original"); think (your agent is thinking or writing): three dots rise in turn |
| 32 | * inside the O and the V's two strokes glimmer one after the other, the one sign used wherever tokens are on |
| 33 | * their way; ok: the V becomes a tick, green, and hops (the tick only ever |
| 34 | * means "done"); error: the V becomes "!", red, and the mark shakes; no: the V becomes "×", grey, and the O |
| 35 | * shakes its head; notify: a dot pops up by the V and the O bounces; live: the O is the record light, red, and |
| 36 | * ripples go out from it; sleep: the O closes like an eye and the V sinks. |
| 37 | * |
| 38 | * Motion budget: SVG animations restyle and re-lay-out on the main thread every frame, so an endlessly moving mark kept |
| 39 | * idle pages 12-29% busy (browser check, OpenVibe.Host). A mark moves only for its intro (the first INTRO_MS of a |
| 40 | * visit, from the first time a mark is on screen in this tab; later pages of a multi-page site open with the mark at |
| 41 | * rest), while it or its link is hovered or focused (and LINGER_MS after), and while it shows a state, a page load or |
| 42 | * progress. Otherwise its CSS animations are paused where they are. It never moves while the page is hidden, while it |
| 43 | * is off-screen (IntersectionObserver), with data-static, or with motion off (html[data-ov-motion=off], |
| 44 | * theme-loader.js); its colours and shapes still change. Calm (html[data-ov-motion=reduced]: the person's choice, or |
| 45 | * the device asking for less motion) keeps the intro, the hover and every state, drops the linger after a hover and |
| 46 | * lets fewer leaves fall a shorter way. |
| 47 | * |
| 48 | * The season (theme-loader.js sets <html data-ov-season-mark="halloween|winter"> for anyone who has not turned seasonal |
| 49 | * themes off, on any theme): the mark wears it, the O a pumpkin with its stem and leaf in October, caps of snow in |
| 50 | * December. Now and then (every 4 to 8 minutes) a mark on screen flickers for a moment, and on the first visit of the |
| 51 | * day a few leaves (snowflakes in December) drift down from the first mark seen: seven in full, three under Calm, none |
| 52 | * with motion off. |
| 53 | */ |
| 54 | (function () { |
| 55 | ; |
| 56 | if (window.__ovMark) return; window.__ovMark = true; |
| 57 | let n = 0; |
| 58 | const f = (v) => +v.toFixed(2); |
| 59 | // The ground around the V where it crosses the O: the wide gap (the owner's pick, 2026-11-10, the Arena's P07: |
| 60 | // "more ground around the V where it crosses"); it was 7. |
| 61 | const HALO = 14; |
| 62 | // The Pair in a 100-unit box, centred on (50, 50) (openvibe-shared/mark: the same numbers; test/mark.test.js). |
| 63 | function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) { |
| 64 | const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50; |
| 65 | const A = [f(cx + a * R), f(cy - R + vsw / 2)], T = [f(cx + t * R), f(cy + R - vsw / 2)], B = [f(cx + b * R), f(cy - R + vsw / 2)]; |
| 66 | const ring = `M${f(cx)} ${f(cy - r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy + r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy - r)}`; |
| 67 | const x0 = f(cx - R), x1 = f(B[0] + vsw / 2), pad = 2, w = f(x1 - x0 + 2 * pad); |
| 68 | const box = `${f(50 - w / 2)} ${f(50 - w / 2)} ${w} ${w}`, wideBox = `${f(50 - w / 2)} ${f(50 - R - pad)} ${w} ${f(2 * R + 2 * pad)}`; |
| 69 | // The V's two strokes, each from its first point, for every shape it takes. |
| 70 | const vw = B[0] - A[0], vh = T[1] - A[1], mx = A[0] + vw / 2, my = A[1] + vh * 0.55, s = Math.min(vw, vh) * 0.42, tick = [A[0] + vw * 0.46, T[1]]; |
| 71 | const shapes = { |
| 72 | v: [T[0], T[1], A[0], A[1], T[0], T[1], B[0], B[1]], |
| 73 | tip: [T[0], T[1], T[0], T[1], T[0], T[1], T[0], T[1]], |
| 74 | ok: [tick[0], tick[1], A[0] + vw * 0.16, A[1] + vh * 0.52, tick[0], tick[1], B[0] + vw * 0.06, A[1]], |
| 75 | no: [mx - s + vw * 0.1, my - s, mx + s + vw * 0.1, my + s, mx - s + vw * 0.1, my + s, mx + s + vw * 0.1, my - s], |
| 76 | err: [mx, A[1], mx, T[1] - vsw * 1.28, mx, T[1], mx, T[1] + 0.01], |
| 77 | }; |
| 78 | for (const k in shapes) shapes[k] = shapes[k].map(f); |
| 79 | return { cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + HALO, A, T, B, ring, box, wideBox, shapes }; |
| 80 | } |
| 81 | const FORMS = { pair: pair(20, 13, 13), heavy: pair(18, 18, 17) }; |
| 82 | const formOf = (v) => (v === 'heavy' || v === 'solid' ? 'heavy' : 'pair'); |
| 83 | const CSS = ` |
| 84 | @property --ovm-fill{syntax:'<number>';inherits:true;initial-value:1} |
| 85 | .ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;flex:none;vertical-align:middle;line-height:0;--ovm-o:#4f7cff;--ovm-v:#8b5cf6;--ovm-fill:1;transition:--ovm-fill .55s cubic-bezier(.3,.8,.2,1)} |
| 86 | .ov-mark[data-wide]{height:auto;aspect-ratio:94/57} |
| 87 | .ov-mark svg{width:100%;height:100%;overflow:visible} |
| 88 | .ov-mark .m,.ov-mark .og,.ov-mark .vg{transform-box:view-box} |
| 89 | .ov-mark .m{transform-origin:50px 50px} |
| 90 | .ov-mark .o,.ov-mark .ot{fill:none;stroke:var(--ovm-o);transition:stroke .35s} |
| 91 | .ov-mark .ot{opacity:0;transition:opacity .3s,stroke .35s} |
| 92 | .ov-mark .va,.ov-mark .vb{fill:none;stroke:var(--ovm-v);stroke-linecap:round;stroke-linejoin:round;transition:stroke .35s,opacity .5s} |
| 93 | .ov-mark .ha,.ov-mark .hb{fill:none;stroke:#000;stroke-linecap:round} |
| 94 | /* Depth (4.65.0): each stroke runs from a lighter to a deeper tone of its own colour, so states, seasons, tones and a |
| 95 | page's own colour still decide it. Without color-mix the strokes are flat. */ |
| 96 | .ov-mark .g1,.ov-mark .g2{stop-color:var(--ovm-o);transition:stop-color .35s}.ov-mark .g3,.ov-mark .g4{stop-color:var(--ovm-v);transition:stop-color .35s} |
| 97 | @supports (color:color-mix(in srgb,red 50%,blue)){.ov-mark .g1{stop-color:color-mix(in srgb,var(--ovm-o) 68%,#fff)}.ov-mark .g2{stop-color:color-mix(in srgb,var(--ovm-o) 84%,#000)}.ov-mark .g3{stop-color:color-mix(in srgb,var(--ovm-v) 70%,#fff)}.ov-mark .g4{stop-color:color-mix(in srgb,var(--ovm-v) 86%,#000)} |
| 98 | .ov-mark[data-tone] .g1,.ov-mark[data-tone] .g2{stop-color:var(--ovm-o)}.ov-mark[data-tone] .g3,.ov-mark[data-tone] .g4{stop-color:var(--ovm-v)}} |
| 99 | /* The glint: a band of light crosses the mark (its own shape is the mask) on the intro and on hover. */ |
| 100 | .ov-mark .gr{transform-box:view-box;transform-origin:50px 50px;transform:translateX(-80px) skewX(-20deg)} |
| 101 | /* The glow: a soft light of the mark's two colours behind it, on hover; always on with data-glow (heroes, brand pages). */ |
| 102 | .ov-mark{position:relative;isolation:isolate} |
| 103 | .ov-mark::before{content:"";position:absolute;inset:6%;z-index:-1;border-radius:50%;pointer-events:none;background:radial-gradient(closest-side at 36% 50%,var(--ovm-o),transparent),radial-gradient(closest-side at 70% 50%,var(--ovm-v),transparent);filter:blur(8px);opacity:0;transform:scale(.7);transition:opacity .45s,transform .6s cubic-bezier(.3,.8,.2,1)} |
| 104 | .ov-mark[data-glow]::before{opacity:.42;transform:none} |
| 105 | a:hover>.ov-mark::before,button:hover>.ov-mark::before,.ov-mark:hover::before,a:focus-visible>.ov-mark::before{opacity:.55;transform:scale(1.14)} |
| 106 | .ov-mark[data-tone]::before,.ov-mark[data-state=sleep]::before{display:none} |
| 107 | /* A press: the mark gives a little and springs back. */ |
| 108 | .ov-mark .m{transition:transform .42s cubic-bezier(.34,1.56,.64,1)} |
| 109 | a:active>.ov-mark .m,button:active>.ov-mark .m,.ov-mark:active .m{transform:scale(.9);transition-duration:.1s} |
| 110 | .ov-mark .cd{display:none;fill:var(--ovm-dot,#ff3b5c);transform-box:fill-box;transform-origin:center} |
| 111 | .ov-mark .nd{fill:var(--danger,#ef4444);opacity:0;transform:scale(0);transform-box:fill-box;transform-origin:center;transition:opacity .2s,transform .42s cubic-bezier(.34,1.56,.64,1)} |
| 112 | .ov-mark .rp{fill:none;stroke:#ff3b5c;stroke-width:3;opacity:0;transform-box:fill-box;transform-origin:center} |
| 113 | .ov-mark[data-tone=ink]{--ovm-o:currentColor;--ovm-v:currentColor} |
| 114 | .ov-mark[data-tone=white]{--ovm-o:#fff;--ovm-v:#fff} |
| 115 | /* The season: the O is a pumpkin in October (states below still colour it). */ |
| 116 | .ov-mark .sh,.ov-mark .sw2{display:none} |
| 117 | html[data-ov-season-mark=halloween] .ov-mark .sh,html[data-ov-season-mark=winter] .ov-mark .sw2{display:inline} |
| 118 | /* As weak as the base rule, so a state, a tone or a page's own colour (a brand page) wins over the season. */ |
| 119 | :where(html[data-ov-season-mark=halloween]) .ov-mark{--ovm-o:#f97316} |
| 120 | .ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:4.4;stroke-linecap:round} |
| 121 | .ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 80%} |
| 122 | .ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:5.6;stroke-linecap:round} |
| 123 | .ov-mark .sd{fill:#f4fbff} |
| 124 | /* Hover and focus: the V leans back and the O hops. */ |
| 125 | a:hover>.ov-mark:not([data-state]) .vg,button:hover>.ov-mark:not([data-state]) .vg,.ov-mark:not([data-state]):hover .vg,a:focus-visible>.ov-mark:not([data-state]) .vg{animation:ovmLean .62s ease-out 1} |
| 126 | a:hover>.ov-mark:not([data-state]) .og,button:hover>.ov-mark:not([data-state]) .og,.ov-mark:not([data-state]):hover .og,a:focus-visible>.ov-mark:not([data-state]) .og{animation:ovmHopO .62s ease-out 1} |
| 127 | a:hover>.ov-mark .gr,button:hover>.ov-mark .gr,.ov-mark:hover .gr,a:focus-visible>.ov-mark .gr{animation:ovmGlint .8s cubic-bezier(.45,0,.25,1) .05s 1} |
| 128 | /* The intro: the O rises and draws itself round, the V drops in on a spring, and a glint crosses the two. */ |
| 129 | .ov-mark[data-ovm-intro] .og{animation:ovmRise .6s cubic-bezier(.34,1.4,.64,1) backwards} |
| 130 | .ov-mark[data-ovm-intro] .o{animation:ovmDraw .8s cubic-bezier(.65,0,.25,1) backwards} |
| 131 | .ov-mark[data-ovm-intro] .vg{animation:ovmDrop .62s cubic-bezier(.34,1.56,.64,1) .32s backwards} |
| 132 | .ov-mark[data-ovm-intro] .gr{animation:ovmGlint .9s cubic-bezier(.45,0,.25,1) .8s backwards} |
| 133 | html[data-ov-season-mark] .ov-mark[data-ovm-intro] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) .5s both} |
| 134 | /* A page on its way, or a known progress: the ring draws itself round to the level over a faint track. */ |
| 135 | .ov-mark[data-nav],.ov-mark[data-progress]{transition:--ovm-fill .3s linear} |
| 136 | .ov-mark[data-progress]{--ovm-fill:var(--ovm-p,0)} |
| 137 | .ov-mark[data-nav] .o,.ov-mark[data-progress] .o{stroke-dasharray:100 100;stroke-dashoffset:calc((1 - var(--ovm-fill)) * 100px)} |
| 138 | .ov-mark[data-nav] .ot,.ov-mark[data-progress] .ot{opacity:.24} |
| 139 | .ov-mark[data-nav]:not([data-ovm-arrive]) .vg{animation:ovmNod .9s ease-in-out infinite} |
| 140 | .ov-mark[data-ovm-arrive] .vg{animation:ovmHop .5s ease-out 1} |
| 141 | .ov-mark[data-ovm-arrive] .og{animation:ovmPulse .5s ease-out 1} |
| 142 | /* The states. */ |
| 143 | /* Working (4.236.0; the owner, 11-13: "improve the O so it actually looks like the original, aligned properly … a better |
| 144 | cool animation"): the O stays the logo's own ring, its full weight a faint track, and a bright arc runs round it, |
| 145 | growing as it goes and drawing in behind (its head runs ahead of its tail). The V lifts a little in time with it and |
| 146 | the glow behind breathes; one rhythm, 1.25 s. The eight thinner dots it replaces sat inside the ring's line. */ |
| 147 | .ov-mark[data-state=busy] .ot{opacity:.24} |
| 148 | .ov-mark[data-state=busy] .o{stroke-linecap:round;stroke-dasharray:6 94;animation:ovmArc 1.25s cubic-bezier(.5,.1,.3,1) infinite} |
| 149 | .ov-mark[data-state=busy] .og{animation:ovmSettle .4s cubic-bezier(.2,1.3,.3,1) 1} |
| 150 | .ov-mark[data-state=busy] .vg{animation:ovmLift 1.25s ease-in-out infinite} |
| 151 | .ov-mark[data-state=busy]::before{animation:ovmGlowBusy 1.25s ease-in-out infinite} |
| 152 | /* Thinking: three dots inside the O rise in turn, the ring quiets, the V's strokes glimmer one after the other. */ |
| 153 | .ov-mark .td{display:none;fill:var(--ovm-o);transform-box:fill-box;transform-origin:center} |
| 154 | .ov-mark[data-state=think] .td{display:inline;animation:ovmThink 1.2s ease-in-out infinite} |
| 155 | .ov-mark[data-state=think] .td2{animation-delay:.16s} |
| 156 | .ov-mark[data-state=think] .td3{animation-delay:.32s} |
| 157 | .ov-mark[data-state=think] .o{stroke-opacity:.5;transition:stroke-opacity .3s} |
| 158 | .ov-mark[data-state=think] .va{animation:ovmSpeak 1.2s ease-in-out infinite} |
| 159 | .ov-mark[data-state=think] .vb{animation:ovmSpeak 1.2s ease-in-out .6s infinite} |
| 160 | .ov-mark[data-state=ok]{--ovm-o:var(--success,#22c55e);--ovm-v:var(--success,#22c55e)} |
| 161 | .ov-mark[data-state=ok] .vg{animation:ovmHop .56s ease-out .14s 1 both} |
| 162 | .ov-mark[data-state=error]{--ovm-o:var(--danger,#ef4444);--ovm-v:var(--danger,#ef4444)} |
| 163 | .ov-mark[data-state=error] .m{animation:ovmShake .56s ease-in-out 1} |
| 164 | .ov-mark[data-state=no]{--ovm-o:var(--text-muted,#94a3b8);--ovm-v:var(--text-muted,#94a3b8)} |
| 165 | .ov-mark[data-state=no] .og{animation:ovmNo .52s ease-in-out .1s 1} |
| 166 | .ov-mark[data-state=notify] .nd{opacity:1;transform:scale(1)} |
| 167 | .ov-mark[data-state=notify] .og{animation:ovmBounce .76s ease-out .14s 1} |
| 168 | .ov-mark[data-state=live]{--ovm-o:#ff3b5c} |
| 169 | .ov-mark[data-state=live] .og{animation:ovmPulse 1.6s ease-in-out infinite} |
| 170 | .ov-mark[data-state=live] .rp{animation:ovmRipple 1.6s ease-out infinite} |
| 171 | .ov-mark[data-state=live] .rp2{animation-delay:.8s} |
| 172 | .ov-mark[data-state=sleep] svg{filter:grayscale(1);opacity:.7} |
| 173 | .ov-mark[data-state=sleep] .og{transform:scaleY(.14);transition:transform .52s ease-in-out} |
| 174 | .ov-mark[data-state=sleep] .vg{transform:translateY(4px);transition:transform .52s ease-in-out} |
| 175 | .ov-mark[data-state=sleep] .va,.ov-mark[data-state=sleep] .vb{opacity:.5} |
| 176 | /* Per-site touches: one family, each site with its own light in the O and its own pace. */ |
| 177 | .ov-mark[data-variant=live] .cd,.ov-mark[data-variant=vip] .cd,.ov-mark[data-variant=tips] .cd,.ov-mark[data-variant=deals] .cd,.ov-mark[data-variant=coupons] .cd{display:inline} |
| 178 | .ov-mark[data-variant=live][data-ovm-run]:not([data-state]) .cd{animation:ovmOnAir 1.4s ease-in-out infinite} |
| 179 | .ov-mark[data-variant=vip]{--ovm-dot:#facc15} |
| 180 | .ov-mark[data-variant=tips]{--ovm-dot:#22c55e} |
| 181 | .ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b} |
| 182 | .ov-mark[data-variant=games][data-state=busy] .vg,.ov-mark[data-variant=games][data-state=busy] .og{animation-duration:.6s} |
| 183 | .ov-mark[data-variant=chat][data-state=busy] .vg,.ov-mark[data-variant=chat][data-state=busy] .og{animation-duration:.55s} |
| 184 | .ov-mark[data-variant=codes][data-state=busy] .vg{animation-timing-function:steps(4,end)} |
| 185 | .ov-mark[data-variant=blog][data-state=busy] .vg,.ov-mark[data-variant=wiki][data-state=busy] .vg,.ov-mark[data-variant=news][data-state=busy] .vg,.ov-mark[data-variant=blog][data-state=busy] .og,.ov-mark[data-variant=wiki][data-state=busy] .og,.ov-mark[data-variant=news][data-state=busy] .og{animation-duration:1.2s} |
| 186 | @keyframes ovmRise{from{transform:scale(.55);opacity:0}} |
| 187 | @keyframes ovmDraw{from{stroke-dasharray:100 100;stroke-dashoffset:100}to{stroke-dasharray:100 100;stroke-dashoffset:0}} |
| 188 | @keyframes ovmDrop{from{transform:translateY(-18px) scale(.5);opacity:0}} |
| 189 | @keyframes ovmGlint{from{transform:translateX(-80px) skewX(-20deg)}to{transform:translateX(80px) skewX(-20deg)}} |
| 190 | @keyframes ovmLean{0%,100%{transform:rotate(0)}45%{transform:rotate(-11deg)}} |
| 191 | @keyframes ovmHopO{0%,100%{transform:translateY(0)}45%{transform:translateY(-3px)}} |
| 192 | @keyframes ovmNod{0%,100%{transform:rotate(0)}50%{transform:rotate(-7deg)}} |
| 193 | @keyframes ovmHop{0%,100%{transform:translateY(0) scaleY(1)}35%{transform:translateY(-9px) scaleY(1.04)}70%{transform:translateY(0) scaleY(.96)}} |
| 194 | @keyframes ovmBob{0%,100%{transform:translateY(0)}50%{transform:translateY(6px)}} |
| 195 | @keyframes ovmBreathe{0%,100%{transform:scale(1)}50%{transform:scale(.88)}} |
| 196 | @keyframes ovmArc{0%{stroke-dasharray:6 94;stroke-dashoffset:0}50%{stroke-dasharray:44 56;stroke-dashoffset:-20}100%{stroke-dasharray:6 94;stroke-dashoffset:-100}} |
| 197 | @keyframes ovmLift{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}} |
| 198 | @keyframes ovmGlowBusy{0%,100%{opacity:.16;transform:scale(.96)}50%{opacity:.4;transform:scale(1.08)}} |
| 199 | @keyframes ovmSettle{from{transform:scale(.86)}} |
| 200 | @keyframes ovmThink{0%,55%,100%{transform:translateY(0);opacity:.4}28%{transform:translateY(-2.6px);opacity:1}} |
| 201 | @keyframes ovmSpeak{0%,100%{opacity:1}50%{opacity:.5}} |
| 202 | @keyframes ovmPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}} |
| 203 | @keyframes ovmShake{0%,100%{transform:translateX(0)}15%{transform:translateX(-7px)}30%{transform:translateX(7px)}45%{transform:translateX(-6px)}60%{transform:translateX(6px)}80%{transform:translateX(-3px)}} |
| 204 | @keyframes ovmNo{0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}} |
| 205 | @keyframes ovmBounce{0%,80%,100%{transform:translateY(0)}20%{transform:translateY(-6px)}40%{transform:translateY(0)}60%{transform:translateY(-4px)}} |
| 206 | @keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.8}50%{transform:scale(1.3);opacity:1}} |
| 207 | @keyframes ovmRipple{0%{transform:scale(1);opacity:.7}100%{transform:scale(1.9);opacity:0}} |
| 208 | .ov-mark[data-static] *{animation:none!important} |
| 209 | .ov-mark[data-ov]:not([data-ovm-run]) *{animation-play-state:paused!important} |
| 210 | .ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1} |
| 211 | .ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1} |
| 212 | .ov-mark[data-ovm-flourish] .vg{animation:ovmLean .7s ease-out 1} |
| 213 | .ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:10010} |
| 214 | .ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards} |
| 215 | html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%} |
| 216 | @keyframes ovmLeaf{from{transform:scale(0) rotate(-50deg)}to{transform:none}} |
| 217 | @keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}} |
| 218 | @keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}} |
| 219 | @keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}72%{opacity:1;transform:translate(var(--x),var(--y)) rotate(var(--r))}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}} |
| 220 | html[data-ov-motion=off] .ov-mark:not([data-fx-demo] *) *{animation-iteration-count:1!important}html[data-ov-motion=off] .ovm-fall{display:none} |
| 221 | .ov-busy{cursor:progress;pointer-events:none} |
| 222 | .ov-busy-mark{display:inline-block;vertical-align:-.2em;margin-right:.45em;flex:none} |
| 223 | `; |
| 224 | const seg = (p, i) => `M${f(p[i])} ${f(p[i + 1])}L${f(p[i + 2])} ${f(p[i + 3])}`; |
| 225 | function svg(id, G, wide) { |
| 226 | const p = G.shapes.v, oc = `transform-origin:${G.cx}px ${G.cy}px`, vc = `transform-origin:${G.T[0]}px ${G.T[1]}px`; |
| 227 | const ri = G.r - G.sw / 2, top = G.cy - G.R; |
| 228 | // The season: a pumpkin's stem and leaf on the O; snow on the O and on the V's caps. |
| 229 | const stem = `M${f(G.cx + 0.6)} ${f(top + 2.5)}c.3-4.6 1.6-8 5-10.6`, leaf = `M${f(G.cx + 5.2)} ${f(top - 7.4)}c4.4-4.8 11-5.2 15.8-2.4-3.6 5.2-10.4 6.8-15.8 2.4z`; |
| 230 | const capR = f(G.r + G.sw / 2 - 1.6), at = (deg) => [f(G.cx + capR * Math.cos(deg * Math.PI / 180)), f(G.cy + capR * Math.sin(deg * Math.PI / 180))]; |
| 231 | const c0 = at(-150), c1 = at(-40); |
| 232 | return `<svg viewBox="${wide ? G.wideBox : G.box}" aria-hidden="true"><defs> |
| 233 | <mask id="${id}h" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><g class="vg" style="${vc}"><path class="ha" d="${seg(p, 0)}" stroke-width="${G.halo}"/><path class="hb" d="${seg(p, 4)}" stroke-width="${G.halo}"/></g></mask> |
| 234 | <linearGradient id="${id}go" gradientUnits="userSpaceOnUse" x1="${f(G.cx - G.R)}" y1="${f(G.cy - G.R)}" x2="${f(G.cx + G.R)}" y2="${f(G.cy + G.R)}"><stop class="g1" offset="0"/><stop class="g2" offset="1"/></linearGradient> |
| 235 | <linearGradient id="${id}gv" gradientUnits="userSpaceOnUse" x1="0" y1="${G.A[1]}" x2="0" y2="${G.T[1]}"><stop class="g3" offset="0"/><stop class="g4" offset="1"/></linearGradient> |
| 236 | <linearGradient id="${id}gg"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".8"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient> |
| 237 | <mask id="${id}s" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><g mask="url(#${id}h)"><g class="og" style="${oc}"><path d="${G.ring}" fill="none" stroke="#fff" stroke-width="${G.sw}"/></g></g><g class="vg" style="${vc}"><path class="ma" d="${seg(p, 0)}" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="${G.vsw}"/><path class="mb" d="${seg(p, 4)}" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="${G.vsw}"/></g></mask></defs> |
| 238 | <g class="m"><circle class="rp" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/><circle class="rp rp2" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/> |
| 239 | <g mask="url(#${id}h)"><g class="og" style="${oc}"><path class="ot" d="${G.ring}" pathLength="100" stroke-width="${G.sw}"/><path class="o" d="${G.ring}" pathLength="100" stroke-width="${G.sw}" style="stroke:url(#${id}go)"/><circle class="cd" cx="${G.cx}" cy="${G.cy}" r="${f(ri * 0.62)}"/>${[-1, 0, 1].map((k, n) => `<circle class="td${n ? ` td${n + 1}` : ''}" cx="${f(G.cx + k * ri * 0.52)}" cy="${G.cy}" r="${f(ri * 0.21)}"/>`).join('')} |
| 240 | <g class="sw2"><path class="sc" d="M${c0[0]} ${c0[1]}A${capR} ${capR} 0 0 1 ${c1[0]} ${c1[1]}"/></g></g></g> |
| 241 | <g class="vg" style="${vc}"><path class="va" d="${seg(p, 0)}" stroke-width="${G.vsw}" style="stroke:url(#${id}gv)"/><path class="vb" d="${seg(p, 4)}" stroke-width="${G.vsw}" style="stroke:url(#${id}gv)"/> |
| 242 | <g class="sw2"><circle class="sd" cx="${G.A[0]}" cy="${f(G.A[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/><circle class="sd" cx="${G.B[0]}" cy="${f(G.B[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/></g></g> |
| 243 | <g mask="url(#${id}s)"><rect class="gr" x="38" y="-10" width="24" height="120" fill="url(#${id}gg)"/></g> |
| 244 | <circle class="nd" cx="${f(G.B[0] + 5.5)}" cy="${f(G.B[1] - 9)}" r="${f(G.vsw * 0.55)}"/> |
| 245 | <g class="sh"><path class="ss" d="${stem}"/><path class="sl" d="${leaf}"/></g> |
| 246 | <g class="sw2"><circle class="sd" cx="${f(G.cx - 12)}" cy="${f(top - 6)}" r="1.8"/><circle class="sd" cx="${f(G.B[0] - 6)}" cy="${f(G.B[1] - 12)}" r="1.5"/></g></g></svg>`; |
| 247 | } |
| 248 | |
| 249 | // ── The V's shapes: a state turns it into a tick, a cross or "!" and back, the gap in the ring following it ── |
| 250 | const SHAPE_OF = { ok: 'ok', error: 'err', no: 'no' }; |
| 251 | const backOut = (p) => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2); }; |
| 252 | function drawV(q, p) { |
| 253 | q.pts = p; |
| 254 | const a = seg(p, 0), b = seg(p, 4); |
| 255 | q.va.setAttribute('d', a); q.ha.setAttribute('d', a); q.vb.setAttribute('d', b); q.hb.setAttribute('d', b); |
| 256 | if (q.ma) { q.ma.setAttribute('d', a); q.mb.setAttribute('d', b); } |
| 257 | } |
| 258 | function shape(el, animate) { |
| 259 | const q = el.__ovm; if (!q) return; |
| 260 | const name = SHAPE_OF[el.getAttribute('data-state')] || 'v'; |
| 261 | if (q.name === name) return; |
| 262 | q.name = name; |
| 263 | const to = FORMS[q.form].shapes[name], from = q.pts.slice(); |
| 264 | const raf = typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame.bind(window) : null; |
| 265 | if (q.raf && window.cancelAnimationFrame) window.cancelAnimationFrame(q.raf); |
| 266 | if (!animate || !raf) { drawV(q, to); return; } |
| 267 | const t0 = Date.now(), ms = 420; |
| 268 | const step = () => { const t = Math.min(1, (Date.now() - t0) / ms), e = backOut(t); drawV(q, from.map((v, i) => v + (to[i] - v) * e)); q.raf = t < 1 ? raf(step) : 0; }; |
| 269 | q.raf = raf(step); |
| 270 | } |
| 271 | |
| 272 | const INTRO_MS = 8000, LINGER_MS = 1200; |
| 273 | const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer } |
| 274 | let io = null, rm = null; |
| 275 | // One intro per visit (tab): the deadline is kept in sessionStorage so the next page of a |
| 276 | // multi-page site finishes the same intro instead of starting another. |
| 277 | const INTRO_KEY = 'ov-mark-intro-end'; |
| 278 | function introDeadline(now) { |
| 279 | let end = 0; |
| 280 | try { end = Number(sessionStorage.getItem(INTRO_KEY)) || 0; } catch { /* no storage */ } |
| 281 | if (!end || end > now + INTRO_MS) { end = now + INTRO_MS; try { sessionStorage.setItem(INTRO_KEY, String(end)); } catch { /* */ } } |
| 282 | return end; |
| 283 | } |
| 284 | // Inside [data-fx-demo] (ov-fx.js: a motion page's own container, set when the person asks) a mark plays in full. |
| 285 | // 'full' | 'calm' | 'off': <html data-ov-motion> is the signal (theme-loader.js, the device's setting counted in); |
| 286 | // a page without the loader asks the device. |
| 287 | function motion(el) { |
| 288 | if (el && el.closest && el.closest('[data-fx-demo]')) return 'full'; |
| 289 | const m = document.documentElement.getAttribute('data-ov-motion'); |
| 290 | if (m === 'off') return 'off'; |
| 291 | return m === 'reduced' || (!m && rm && rm.matches) ? 'calm' : 'full'; |
| 292 | } |
| 293 | const off = (el) => motion(el) === 'off'; |
| 294 | function update(el) { |
| 295 | const st = state.get(el); if (!st) return; |
| 296 | const svgEl = el.querySelector('svg'); |
| 297 | if (!el.isConnected || !svgEl) { forget(el); return; } |
| 298 | const now = Date.now(); |
| 299 | const may = !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true; |
| 300 | if (may && !off(el) && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); } |
| 301 | const s = el.getAttribute('data-state'); |
| 302 | // Anything the mark is showing keeps it moving: a state, a page on its way, a known progress. |
| 303 | const showing = (s && s !== 'idle') || el.hasAttribute('data-nav') || el.hasAttribute('data-progress'); |
| 304 | const intro = now < st.introEnd; |
| 305 | // Calm plays the intro and answers a hover or focus, and rests as soon as that ends (no linger). |
| 306 | const run = may && (showing || (!off(el) && (intro || st.hover || st.focus || (motion(el) === 'full' && now < st.lingerEnd)))); |
| 307 | if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); } |
| 308 | // The intro's pop and growth are on only inside the intro (paused with the rest while the mark cannot move). |
| 309 | if ((may && intro) !== el.hasAttribute('data-ovm-intro') && (may || !intro)) { if (may && intro) el.setAttribute('data-ovm-intro', ''); else el.removeAttribute('data-ovm-intro'); } |
| 310 | shape(el, run); |
| 311 | try { if (run) svgEl.unpauseAnimations(); else svgEl.pauseAnimations(); } catch { /* no SMIL */ } |
| 312 | clearTimeout(st.timer); st.timer = 0; |
| 313 | const next = Math.min(...[st.introEnd, st.lingerEnd].filter((t) => t > now)); |
| 314 | if (run && next !== Infinity) st.timer = setTimeout(() => update(el), next - now + 20); |
| 315 | } |
| 316 | // A mark taken out of the page stops and is let go (a navbar re-render replaces its mark); |
| 317 | // mount() picks it up again if it comes back. |
| 318 | function forget(el) { |
| 319 | const st = state.get(el); if (!st) return; |
| 320 | clearTimeout(st.timer); state.delete(el); |
| 321 | if (io) try { io.unobserve(el); } catch { /* */ } |
| 322 | el.removeAttribute('data-ovm-run'); |
| 323 | try { el.querySelector('svg').pauseAnimations(); } catch { /* */ } |
| 324 | } |
| 325 | function updateAll() { document.querySelectorAll('.ov-mark[data-ov]').forEach(update); } |
| 326 | // ── The season ── |
| 327 | const season = () => document.documentElement.getAttribute('data-ov-season-mark'); |
| 328 | let flourishTimer = 0; |
| 329 | function scheduleFlourish() { |
| 330 | clearTimeout(flourishTimer); flourishTimer = 0; |
| 331 | if (!season()) return; |
| 332 | flourishTimer = setTimeout(() => { |
| 333 | if (season() && !off() && !document.hidden) { |
| 334 | document.querySelectorAll('.ov-mark[data-ov]:not([data-static])').forEach((el) => { |
| 335 | const st = state.get(el); if (!st || st.onScreen === false) return; |
| 336 | el.setAttribute('data-ovm-flourish', ''); setTimeout(() => el.removeAttribute('data-ovm-flourish'), 1700); |
| 337 | }); |
| 338 | } |
| 339 | scheduleFlourish(); |
| 340 | }, (4 + Math.random() * 4) * 60000); |
| 341 | } |
| 342 | const FALL_KEY = 'ov-season-fall'; |
| 343 | function fallOnce(el) { |
| 344 | const id = season(); if (!id || off() || document.hidden || !el.isConnected) return; |
| 345 | const calm = motion() === 'calm'; |
| 346 | const day = id + ':' + new Date().toDateString(); |
| 347 | try { const ls = window.localStorage; if (ls.getItem(FALL_KEY) === day) return; ls.setItem(FALL_KEY, day); } catch { return; } |
| 348 | const r = el.getBoundingClientRect(); if (!r.width || !document.body) return; |
| 349 | const box = document.createElement('div'); box.className = 'ovm-fall'; box.setAttribute('aria-hidden', 'true'); |
| 350 | const x0 = r.left + r.width / 2, y0 = r.top + r.height * 0.6; |
| 351 | box.style.left = x0 + 'px'; box.style.top = y0 + 'px'; |
| 352 | // The leaves land on the bottom edge of the bar the mark sits in and fade there, so they never cross a page's |
| 353 | // heading (they fell 120-280 px over it on phones, 10-30). A mark outside a bar gets a short fall. |
| 354 | const bar = el.closest('.openvibe-navbar,header,nav'); |
| 355 | const rb = bar && bar.getBoundingClientRect(); |
| 356 | const floor = rb && rb.bottom > y0 + 8 ? rb.bottom - y0 - 4 : 0; |
| 357 | const colors = id === 'winter' ? ['#f4fbff', '#d8f3ff', '#ffffff'] : ['#ff7a1a', '#f5a524', '#d9481f', '#7fd04e']; |
| 358 | for (let i = 0; i < (calm ? 3 : 7); i++) { |
| 359 | const s = document.createElement('i'); |
| 360 | const dx = Math.max(8 - x0, Math.min((window.innerWidth || 0) - 8 - x0, Math.round((Math.random() - 0.3) * (calm ? 70 : 200)))); |
| 361 | const dy = floor ? Math.round(floor - Math.random() * 3) : Math.round(36 + Math.random() * 28); |
| 362 | s.style.cssText = `--x:${dx}px;--y:${dy}px;--r:${Math.round((Math.random() * 540 - 270) * (calm ? 0.35 : 1))}deg;animation-delay:${(i * 0.18 + Math.random() * 0.2).toFixed(2)}s;animation-duration:${(2.4 + Math.random() * 1.2).toFixed(2)}s;background:${colors[i % colors.length]}`; |
| 363 | box.appendChild(s); |
| 364 | } |
| 365 | document.body.appendChild(box); |
| 366 | setTimeout(() => box.remove(), 5200); |
| 367 | } |
| 368 | const bound = new WeakSet(); |
| 369 | function track(el) { |
| 370 | state.set(el, { introEnd: 0, lingerEnd: 0, hover: false, focus: false, onScreen: io ? null : true, timer: 0 }); |
| 371 | if (!bound.has(el)) { |
| 372 | bound.add(el); |
| 373 | const host = el.closest('a,button') || el; |
| 374 | const set = (k, v) => () => { |
| 375 | const st = state.get(el); if (!st) return; |
| 376 | st[k] = v; if (!v) st.lingerEnd = Date.now() + LINGER_MS; |
| 377 | update(el); |
| 378 | }; |
| 379 | host.addEventListener('pointerenter', set('hover', true)); host.addEventListener('pointerleave', set('hover', false)); |
| 380 | host.addEventListener('focusin', set('focus', true)); host.addEventListener('focusout', set('focus', false)); |
| 381 | } |
| 382 | if (io) io.observe(el); |
| 383 | update(el); |
| 384 | } |
| 385 | function mount(root) { |
| 386 | (root || document).querySelectorAll('.ov-mark').forEach(el => { |
| 387 | if (!el.hasAttribute('data-ov')) { |
| 388 | el.setAttribute('data-ov', '1'); |
| 389 | const size = parseInt(el.getAttribute('data-size'), 10); |
| 390 | if (size) { el.style.width = size + 'px'; if (!el.hasAttribute('data-wide')) el.style.height = size + 'px'; } |
| 391 | // The form follows the size unless the page names one: under 22px the pair's gap closes, the heavy holds. |
| 392 | if (!el.hasAttribute('data-form')) { |
| 393 | let px = size; |
| 394 | if (!px) { try { px = Math.round(el.getBoundingClientRect().width); } catch { px = 0; } } |
| 395 | el.setAttribute('data-form', px && px <= 22 ? 'heavy' : 'pair'); |
| 396 | } |
| 397 | const form = formOf(el.getAttribute('data-form')); |
| 398 | el.innerHTML = svg('ovm' + (++n) + '_', FORMS[form], el.hasAttribute('data-wide')); |
| 399 | const pick = (c) => el.querySelector(c); |
| 400 | el.__ovm = { form, name: 'v', pts: FORMS[form].shapes.v.slice(), raf: 0, va: pick('.va'), vb: pick('.vb'), ha: pick('.ha'), hb: pick('.hb'), ma: pick('.ma'), mb: pick('.mb') }; |
| 401 | } |
| 402 | if (!state.has(el) && el.isConnected) track(el); |
| 403 | }); |
| 404 | } |
| 405 | function init() { |
| 406 | if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); } |
| 407 | try { |
| 408 | io = new IntersectionObserver((entries) => entries.forEach((e) => { |
| 409 | const st = state.get(e.target); if (!st) return; |
| 410 | st.onScreen = e.isIntersecting; update(e.target); |
| 411 | })); |
| 412 | } catch { io = null; } |
| 413 | try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; } |
| 414 | document.addEventListener('visibilitychange', updateAll); |
| 415 | mount(); |
| 416 | hookNav(); |
| 417 | scheduleFlourish(); |
| 418 | try { |
| 419 | new MutationObserver((records) => { |
| 420 | let added = false; |
| 421 | for (const r of records) { |
| 422 | if (r.type === 'attributes') { if (r.attributeName === 'data-fx-demo') { updateAll(); continue; } if (r.target === document.documentElement) { updateAll(); if (r.attributeName === 'data-ov-season-mark') scheduleFlourish(); } else if (state.has(r.target)) update(r.target); continue; } |
| 423 | if (r.addedNodes.length) added = true; |
| 424 | r.removedNodes.forEach((node) => { |
| 425 | if (node.nodeType !== 1) return; |
| 426 | if (state.has(node) && !node.isConnected) forget(node); |
| 427 | if (node.querySelectorAll) node.querySelectorAll('.ov-mark[data-ov]').forEach((m) => { if (!m.isConnected) forget(m); }); |
| 428 | }); |
| 429 | } |
| 430 | if (added) mount(); |
| 431 | }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-nav', 'data-progress', 'data-ov-motion', 'data-ov-season-mark', 'data-fx-demo'] }); |
| 432 | } catch { /* */ } |
| 433 | } |
| 434 | // ── A page on its way: the navbar's mark draws its ring round as the next page loads ── |
| 435 | // The pace is learned: the usual time of this site's boost moves and full page loads in this browser (a running |
| 436 | // average in localStorage). The ring reaches about four fifths at the usual time and never closes before the page |
| 437 | // is really here; then it closes, the V hops, and the track fades on the new page. |
| 438 | const loc = () => window.location || { host: '', href: 'http://localhost/', origin: 'http://localhost', pathname: '/', search: '' }; |
| 439 | const NAV_KEY = 'ov-nav-ms', NAV_FLAG = 'ov-nav-click', NAV_DEFAULT = { boost: 450, page: 1600 }; |
| 440 | const navMarks = () => [...document.querySelectorAll('.openvibe-navbar-brand .ov-mark, .navbar .nav-brand .ov-mark, .ov-mark[data-ov-nav]')].filter((m) => !m.closest('.openvibe-network-badge')); |
| 441 | const navStore = () => { try { return JSON.parse(window.localStorage.getItem(NAV_KEY) || '{}') || {}; } catch { return {}; } }; |
| 442 | function usual(kind) { const v = Number(navStore()[`${loc().host}:${kind}`]); return v > 0 ? v : NAV_DEFAULT[kind] || 1000; } |
| 443 | function learn(kind, ms) { |
| 444 | if (!(ms > 30 && ms < 30000)) return; |
| 445 | try { |
| 446 | const all = navStore(), k = `${loc().host}:${kind}`; |
| 447 | all[k] = Math.round(all[k] > 0 ? all[k] * 0.7 + ms * 0.3 : ms); |
| 448 | window.localStorage.setItem(NAV_KEY, JSON.stringify(all)); |
| 449 | } catch { /* no storage: the default pace */ } |
| 450 | } |
| 451 | let nav = null; |
| 452 | function setFill(marks, p) { marks.forEach((m) => { m.setAttribute('data-nav', ''); m.style.setProperty('--ovm-fill', String(p)); }); } |
| 453 | function navStart(kind = 'page') { |
| 454 | navStop(); |
| 455 | const marks = navMarks(); if (!marks.length) return; |
| 456 | marks.forEach((m) => m.removeAttribute('data-ovm-arrive')); |
| 457 | const t0 = Date.now(), avg = usual(kind); |
| 458 | const me = { kind, t0, timer: 0 }; |
| 459 | const tick = () => { |
| 460 | if (nav !== me) return; |
| 461 | const t = Date.now() - t0; |
| 462 | setFill(navMarks(), Math.min(0.94, 0.08 + 0.86 * (1 - Math.exp(-1.6 * t / avg))).toFixed(3)); |
| 463 | me.timer = setTimeout(tick, 120); |
| 464 | }; |
| 465 | nav = me; |
| 466 | tick(); |
| 467 | } |
| 468 | function navStop() { if (nav) { clearTimeout(nav.timer); nav = null; } } |
| 469 | /** The page is here: the ring closes and the V hops. A navbar re-rendered by the move gets the same ending. */ |
| 470 | // The ending runs after every other listener of the arrival (the navbar re-renders on it), and no timer keeps a mark: |
| 471 | // each step looks the marks up again, so a replaced navbar is never held in memory. |
| 472 | const clearNav = () => { if (!nav) navMarks().forEach((m) => { m.removeAttribute('data-nav'); m.removeAttribute('data-ovm-arrive'); m.style.removeProperty('--ovm-fill'); }); }; |
| 473 | function navEnd(kind) { |
| 474 | const was = nav; navStop(); |
| 475 | if (was && kind) learn(kind, Date.now() - was.t0); |
| 476 | setTimeout(() => { |
| 477 | if (nav) return; mount(); |
| 478 | const marks = navMarks(); setFill(marks, 1); marks.forEach((m) => m.setAttribute('data-ovm-arrive', '')); |
| 479 | setTimeout(clearNav, 520); |
| 480 | }, 0); |
| 481 | } |
| 482 | function hookNav() { |
| 483 | document.addEventListener('ov:boost:before', () => navStart('boost')); |
| 484 | document.addEventListener('ov:boost:load', () => navEnd('boost')); |
| 485 | // A full page load: a plain click on a link that leaves this page, in this tab, not taken by boost or the app. |
| 486 | document.addEventListener('click', (e) => { |
| 487 | if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; |
| 488 | const a = e.target && e.target.closest && e.target.closest('a[href]'); |
| 489 | if (!a || a.hasAttribute('download') || (a.target && a.target !== '_self')) return; |
| 490 | const here = loc(); let u; try { u = new URL(a.href, here.href); } catch { return; } |
| 491 | if (!/^https?:$/.test(u.protocol) || (u.origin === here.origin && u.pathname === here.pathname && u.search === here.search)) return; |
| 492 | navStart('page'); |
| 493 | try { if (u.origin === here.origin) sessionStorage.setItem(NAV_FLAG, String(Date.now())); } catch { /* */ } |
| 494 | }); |
| 495 | // Back to a page from the back/forward cache: nothing is loading any more. |
| 496 | window.addEventListener('pageshow', (e) => { if (e.persisted) { navStop(); clearNav(); } }); |
| 497 | // This page came from a click on the last one: learn how long it took, and finish the ring here. |
| 498 | let t = 0; try { t = Number(sessionStorage.getItem(NAV_FLAG)) || 0; sessionStorage.removeItem(NAV_FLAG); } catch { /* */ } |
| 499 | // soft.js ends the ring itself at the arriving page's first paint (it starts at the press, not the click); the |
| 500 | // pace is still learned here, from the click's marker, before soft.js's ending takes over. |
| 501 | if (t && Date.now() - t < 20000) { |
| 502 | learn('page', Date.now() - t); |
| 503 | if (window.__ovSoftNavEnded) return; |
| 504 | const finish = () => { if (window.__ovSoftNavEnded) return true; const marks = navMarks(); if (!marks.length) return false; setFill(marks, 0.9); setTimeout(() => navEnd(null), 60); return true; }; |
| 505 | let tries = 0; const again = () => { if (!finish() && ++tries <= 20) setTimeout(again, 100); }; again(); |
| 506 | } |
| 507 | } |
| 508 | /** Replay the intro on one mark (a brand page, a splash): the O draws itself round, the V drops in, a glint crosses. */ |
| 509 | function intro(el) { |
| 510 | const st = el && state.get(el); if (!st) return; |
| 511 | el.removeAttribute('data-ovm-intro'); |
| 512 | try { void el.getBoundingClientRect(); } catch { /* */ } |
| 513 | st.introEnd = Date.now() + 2000; update(el); |
| 514 | } |
| 515 | // ── Busy buttons (the plan: "buttons' busy, then done or failed, from the mark's states", 2026-11-01) ── |
| 516 | // A button that starts work shows the mark at work beside its label, keeps its width and takes no second press. |
| 517 | // Script work ends it with done (the tick, and "Done" or a word) or fail (the "!", and a word), then the label |
| 518 | // comes back; a form posted the old way stays busy until the next page arrives. The button is never disabled: |
| 519 | // a disabled submitter's name and value would drop out of the form it posts. |
| 520 | // OVMark.busy(button) → { done(text?), fail(text?), reset() } |
| 521 | const BUSY = new Map(); |
| 522 | function busy(btn) { |
| 523 | if (!btn || !btn.isConnected) return null; |
| 524 | if (BUSY.has(btn)) return BUSY.get(btn); |
| 525 | const isInput = btn.tagName === 'INPUT'; |
| 526 | const before = isInput ? btn.value : btn.innerHTML; |
| 527 | const width = btn.offsetWidth; |
| 528 | if (width) btn.style.minWidth = width + 'px'; |
| 529 | btn.setAttribute('aria-busy', 'true'); |
| 530 | btn.classList.add('ov-busy'); |
| 531 | let mark = null; |
| 532 | if (!isInput) { |
| 533 | mark = document.createElement('span'); |
| 534 | mark.className = 'ov-mark ov-busy-mark'; |
| 535 | mark.setAttribute('data-size', '18'); mark.setAttribute('data-tone', 'ink'); mark.setAttribute('data-state', 'busy'); mark.setAttribute('aria-hidden', 'true'); |
| 536 | btn.insertBefore(mark, btn.firstChild); |
| 537 | mount(btn); |
| 538 | } |
| 539 | let timer = 0; |
| 540 | const reset = () => { |
| 541 | clearTimeout(timer); |
| 542 | if (!BUSY.has(btn)) return; |
| 543 | BUSY.delete(btn); |
| 544 | if (isInput) btn.value = before; else btn.innerHTML = before; |
| 545 | btn.style.minWidth = ''; |
| 546 | btn.removeAttribute('aria-busy'); |
| 547 | btn.classList.remove('ov-busy'); |
| 548 | const form = btn.form || (btn.closest && btn.closest('form')); |
| 549 | if (form) delete form.__ovBusy; |
| 550 | }; |
| 551 | const end = (st, text, ms) => { |
| 552 | if (!BUSY.has(btn)) return; |
| 553 | if (mark) { |
| 554 | mark.setAttribute('data-state', st); |
| 555 | // The label after the mark says what happened, in the button's own place. |
| 556 | while (mark.nextSibling) btn.removeChild(mark.nextSibling); |
| 557 | btn.appendChild(document.createTextNode(text)); |
| 558 | } else btn.value = text; |
| 559 | btn.setAttribute('aria-busy', 'false'); |
| 560 | clearTimeout(timer); timer = setTimeout(reset, ms); |
| 561 | }; |
| 562 | const api = { done: (text) => end('ok', text || 'Done', 1400), fail: (text) => end('error', text || 'Failed', 2600), reset }; |
| 563 | BUSY.set(btn, api); |
| 564 | return api; |
| 565 | } |
| 566 | // A form posted the old way (POST, no script of the page's own took the submit): its button shows the mark at work |
| 567 | // until the next page arrives, and a second submit does nothing. data-ov-busy="off" on a form opts out. |
| 568 | document.addEventListener('submit', (e) => { |
| 569 | const form = e.target; |
| 570 | if (!form || form.tagName !== 'FORM' || form.getAttribute('data-ov-busy') === 'off') return; |
| 571 | if (String(form.getAttribute('method') || 'get').toLowerCase() !== 'post') return; |
| 572 | if (form.__ovBusy) { e.preventDefault(); return; } |
| 573 | if (e.defaultPrevented) return; |
| 574 | const btn = e.submitter || form.querySelector('button[type=submit],button:not([type]),input[type=submit]'); |
| 575 | if (!btn) return; |
| 576 | form.__ovBusy = true; |
| 577 | busy(btn); |
| 578 | }); |
| 579 | // Back to a page from the back/forward cache: nothing is busy any more. |
| 580 | window.addEventListener('pageshow', (e) => { if (e.persisted) [...BUSY.values()].forEach((b) => b.reset()); }); |
| 581 | window.OVMark = { mount, navStart, navEnd, usual, intro, busy, FORMS }; |
| 582 | window.ovMarkMount = mount; |
| 583 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); |
| 584 | })(); |
| 585 |