openvibers/openvibe.shared ov-mark.js

ov-mark.js

main 45.2 KB · 585 lines · javascript

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 'use strict';
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}
105a: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)}
109a: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}
117html[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. */
125a: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}
126a: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}
127a: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}
133html[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}
215html[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))}}
220html[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