openvibers/openvibe.shared ov-look.js
ov-look.js
| 1 | /** |
| 2 | * openvibe-shared/ov-look.js - the Look panel (plan L42, 4.64.0): change your look from any OpenVibe site, signed in |
| 3 | * or not, without leaving it. navbar.js loads it from beside itself the first time someone opens Look (the palette in |
| 4 | * the account menu, or Look in a guest's menu), so it costs nothing on first paint. |
| 5 | * |
| 6 | * OVLook.mount(box, { signedIn, season }) draws the panel into `box` and keeps it current (ov:theme, ov:display); |
| 7 | * season: false leaves out the season switch where the host has its own |
| 8 | * |
| 9 | * Theme swatches (the season's own while there is one), the season switch, Icons (4.71.0: Auto, Line or Solid, the |
| 10 | * person's preference for every OpenVibe icon; plan L56), where this look comes from, Share (4.67.0: |
| 11 | * a link that opens this page in your theme for anyone, with theme-loader's Use it / Stop bar; built-in themes only), |
| 12 | * and "More looks" on openvibe.design, the home of every theme. State is theme-loader.js's: a pick applies at once, is |
| 13 | * kept in this browser, saved to the account when signed in, and travels with links to the other OpenVibe sites. |
| 14 | */ |
| 15 | (function (root) { |
| 16 | ; |
| 17 | if (!root || root.OVLook) return; |
| 18 | const DESIGN = 'https://openvibe.design'; |
| 19 | const PICKS = [['vibe', 'Vibe'], ['midnight', 'Midnight'], ['violet-hour', 'Violet'], ['nord', 'Nord'], ['dracula', 'Dracula'], ['ocean-deep', 'Ocean'], |
| 20 | ['forest', 'Forest'], ['sunset', 'Sunset'], ['high-contrast', 'Contrast'], ['daylight', 'Daylight'], ['paper', 'Paper']]; |
| 21 | const CSS = '.ovlk{container-type:inline-size;display:grid;gap:10px;padding:12px 4px 4px;font-size:13px;color:var(--text-secondary,#a8b3c4)}' |
| 22 | + '.ovlk-h{display:flex;align-items:baseline;justify-content:space-between;gap:8px}.ovlk-h b{color:var(--text-primary,#e6edf7);font-size:13.5px}.ovlk-h span{font-size:11.5px;color:var(--text-muted,#7d8aa0)}' |
| 23 | + '.ovlk-sw{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:6px}@container (max-width:330px){.ovlk-sw{grid-template-columns:repeat(4,minmax(0,1fr))}}' |
| 24 | + '.ovlk .ovlk-sw button{all:unset;box-sizing:border-box;display:grid;justify-items:center;gap:4px;padding:6px 0;border-radius:12px;cursor:pointer;border:1px solid transparent}' |
| 25 | + '.ovlk-sw button:hover{background:var(--bg-hover,rgba(255,255,255,.06))}.ovlk-sw button:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:1px}' |
| 26 | + '.ovlk-sw button[aria-checked=true]{border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}' |
| 27 | + '.ovlk-dot{position:relative;width:28px;height:28px;border-radius:50%;overflow:hidden;box-shadow:inset 0 0 0 1px rgba(127,127,127,.35)}.ovlk-dot i{position:absolute;inset:0 0 0 50%}' |
| 28 | + '.ovlk-sw small{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10.5px;letter-spacing:-.02em;color:var(--text-muted,#7d8aa0)}.ovlk-sw button[aria-checked=true] small{color:var(--text-primary,#e6edf7)}' |
| 29 | + '.ovlk .ovlk-season{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-radius:12px;background:var(--bg-tertiary,rgba(255,255,255,.05))}' |
| 30 | + '.ovlk-season b{color:var(--text-primary,#e6edf7);font-weight:650}' |
| 31 | + '.ovlk .ovlk-switch{all:unset;position:relative;display:block;width:38px!important;height:22px!important;padding:0!important;flex:none;border-radius:999px;background:var(--border,#2c3d5c);cursor:pointer}.ovlk-switch::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;transition:transform .15s}' |
| 32 | + '.ovlk .ovlk-switch[aria-checked=true]{background:var(--accent-strong,var(--accent,#3472d8))}.ovlk .ovlk-switch[aria-checked=true]::after{transform:translateX(16px)}.ovlk .ovlk-switch:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}' |
| 33 | + '.ovlk-foot{display:flex;align-items:center;justify-content:space-between;gap:10px}.ovlk-why{min-width:0;font-size:11.5px;color:var(--text-muted,#7d8aa0)}' |
| 34 | + '.ovlk .ovlk-share{all:unset;flex:none;display:inline-flex;align-items:center;min-height:30px;padding:0 12px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text-primary,#e6edf7);font-size:12px;font-weight:650;cursor:pointer}' |
| 35 | + '.ovlk .ovlk-share:hover{border-color:var(--accent,#3b82f6)}.ovlk .ovlk-share:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}' |
| 36 | + '.ovlk a.ovlk-more{display:flex!important;align-items:center;justify-content:space-between;gap:8px;padding:9px 10px!important;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.1));color:var(--text-primary,#e6edf7)!important;text-decoration:none;font-size:13px!important;font-weight:600!important;line-height:1.35}' |
| 37 | + '.ovlk-more:hover{border-color:var(--accent,#3b82f6)}.ovlk-more span{color:var(--accent-light,var(--accent,#60a5fa))}' |
| 38 | + '.ovlk-icons{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 6px 6px 10px;border-radius:12px;background:var(--bg-tertiary,rgba(255,255,255,.05))}' |
| 39 | + '.ovlk-icons>b{color:var(--text-primary,#e6edf7);font-weight:650}' |
| 40 | + '.ovlk-seg{display:inline-flex;gap:2px;padding:2px;border-radius:999px;background:var(--bg-primary,rgba(0,0,0,.25))}' |
| 41 | + '.ovlk .ovlk-seg button{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;gap:5px;min-height:30px;padding:0 10px;border-radius:999px;cursor:pointer;font-size:12.5px;color:var(--text-secondary,#a8b3c4)}' |
| 42 | + '.ovlk .ovlk-seg button[aria-checked=true]{background:var(--accent-strong,var(--accent,#3472d8));color:var(--on-accent-strong,#fff)}' |
| 43 | + '.ovlk .ovlk-seg button:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:1px}' |
| 44 | + '.ovlk-seg svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ovlk-seg svg.is-solid{fill:currentColor;stroke:none}' |
| 45 | + ''; |
| 46 | |
| 47 | function css() { |
| 48 | if (document.getElementById('ov-look-css')) return; |
| 49 | const st = document.createElement('style'); st.id = 'ov-look-css'; st.textContent = CSS; document.head.appendChild(st); |
| 50 | } |
| 51 | const el = (tag, attrs, kids) => { |
| 52 | const n = document.createElement(tag); |
| 53 | for (const k in attrs || {}) n.setAttribute(k, attrs[k]); |
| 54 | (kids || []).forEach((c) => n.append(c)); |
| 55 | return n; |
| 56 | }; |
| 57 | function swatch(id, name, vars, checked) { |
| 58 | const dot = el('span', { class: 'ovlk-dot', 'aria-hidden': 'true' }, [el('i')]); |
| 59 | dot.style.background = vars['--bg-primary'] || '#0a0f1c'; |
| 60 | dot.firstChild.style.background = vars['--accent'] || '#3b82f6'; |
| 61 | return el('button', { type: 'button', role: 'radio', 'aria-checked': String(checked), 'data-look': id, title: name }, [dot, el('small', {}, [name])]); |
| 62 | } |
| 63 | |
| 64 | function draw(box, opts) { |
| 65 | const L = root.OpenVibeThemeLoader; |
| 66 | box.textContent = ''; |
| 67 | if (!L || !L.THEMES) { box.append(el('a', { class: 'ovlk-more', href: `${DESIGN}/look` }, ['Your look on openvibe.design', el('span', {}, ['\u2192'])])); return; } |
| 68 | const season = L.seasons && L.seasons.now(); |
| 69 | const showing = L.seasons && L.seasons.showing(); |
| 70 | const cur = showing ? `season:${showing.id}` : L.getCurrent(); |
| 71 | const panel = el('div', { class: 'ovlk' }); |
| 72 | panel.append(el('div', { class: 'ovlk-h' }, [el('b', {}, ['Look']), el('span', {}, ['Follows you to every OpenVibe site'])])); |
| 73 | const sw = el('div', { class: 'ovlk-sw', role: 'radiogroup', 'aria-label': 'Theme' }); |
| 74 | if (season && L.THEMES[season.theme]) sw.append(swatch(`season:${season.id}`, season.name, L.THEMES[season.theme], cur === `season:${season.id}`)); |
| 75 | PICKS.forEach(([id, name]) => { if (L.THEMES[id]) sw.append(swatch(id, name, L.THEMES[id], cur === id)); }); |
| 76 | panel.append(sw); |
| 77 | if (season && opts.season !== false) { |
| 78 | const on = L.display.get().seasonal !== 'off'; |
| 79 | panel.append(el('div', { class: 'ovlk-season' }, [el('span', {}, [el('b', {}, [`${season.name} theme`]), ` on the default look`]), |
| 80 | el('button', { type: 'button', class: 'ovlk-switch', role: 'switch', 'aria-checked': String(on), 'aria-label': `${season.name} theme`, 'data-look-season': '' })])); |
| 81 | } |
| 82 | // Icons (4.71.0, plan L56; the owner, 2026-11-01: "a setting to prefer a certain icon type... configurable for users |
| 83 | // in most cases"): Auto (line, solid when small or for the tab you are on), Line or Solid, everywhere OpenVibe |
| 84 | // draws its icons. A site still forces one where it must (.ovg-solid / .ovg-line). |
| 85 | if (L.display && L.display.get) { |
| 86 | const now = L.display.get().icons || 'auto'; |
| 87 | const BELL = '<path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0"/>'; |
| 88 | const BELL_S = '<path d="M5.8 16.2V11a6.2 6.2 0 0 1 12.4 0v5.2l1.7 2.3H4.1ZM9.9 19.6h4.2a2.1 2.1 0 0 1-4.2 0Z"/>'; |
| 89 | const sample = (solid) => { const sv = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); sv.setAttribute('viewBox', '0 0 24 24'); sv.setAttribute('aria-hidden', 'true'); if (solid) sv.setAttribute('class', 'is-solid'); sv.innerHTML = solid ? BELL_S : BELL; return sv; }; |
| 90 | const choice = (id, label, solid) => { const b = el('button', { type: 'button', role: 'radio', 'aria-checked': String(now === id), 'data-look-icons': id }, []); if (solid != null) b.append(sample(solid)); b.append(label); return b; }; |
| 91 | panel.append(el('div', { class: 'ovlk-icons' }, [el('b', {}, ['Icons']), el('div', { class: 'ovlk-seg', role: 'radiogroup', 'aria-label': 'Icons' }, [choice('auto', 'Auto', null), choice('line', 'Line', false), choice('solid', 'Solid', true)])])); |
| 92 | } |
| 93 | // Text size and motion live here, with the theme (11-09, the owner: "the typography and the Calm button... should |
| 94 | // just be in the settings... keep day and night"): the account menu keeps only light, dark and auto. |
| 95 | if (L.display && L.display.get && L.display.options) { |
| 96 | const d = L.display.get(); |
| 97 | const row = (key, label, items) => el('div', { class: 'ovlk-icons' }, [el('b', {}, [label]), el('div', { class: 'ovlk-seg', role: 'radiogroup', 'aria-label': label }, |
| 98 | items.filter(([v]) => (L.display.options[key] || []).map(String).includes(String(v))).map(([v, t]) => el('button', { type: 'button', role: 'radio', 'aria-checked': String(String(d[key]) === String(v)), 'data-look-set': `${key}:${v}` }, [t])))]); |
| 99 | if (L.scheme && L.scheme.get) { |
| 100 | const sch = L.scheme.get(); |
| 101 | panel.append(el('div', { class: 'ovlk-icons' }, [el('b', {}, ['Appearance']), el('div', { class: 'ovlk-seg', role: 'radiogroup', 'aria-label': 'Appearance' }, |
| 102 | [['light', 'Light'], ['dark', 'Dark'], ['auto', 'Auto']].map(([v, t]) => el('button', { type: 'button', role: 'radio', 'aria-checked': String(sch === v), 'data-look-scheme': v }, [t])))])); |
| 103 | } |
| 104 | panel.append(row('text', 'Text size', [[100, 'Default'], [112, 'Large'], [125, 'Largest']])); |
| 105 | // Motion (4.183.0): Auto follows the device; Calm keeps every effect, smaller and slower; only Off stops them. |
| 106 | panel.append(row('motion', 'Motion', [['auto', 'Auto'], ['full', 'Full'], ['reduced', 'Calm'], ['off', 'Off']])); |
| 107 | // Windows (4.202.0, asks 304 and 318): every window at once (ov-window.js), glass, solid or outlined. |
| 108 | panel.append(row('window', 'Windows', [['glass', 'Glass'], ['solid', 'Solid'], ['outline', 'Outline']])); |
| 109 | } |
| 110 | const signedIn = opts.signedIn != null ? !!opts.signedIn : /(?:^|; )ov_sso_hint=account/.test(document.cookie || '') || !!(root.localStorage && (() => { try { return root.localStorage.getItem('ov_token'); } catch (e) { return null; } })()); |
| 111 | const named = showing ? showing.name : ((PICKS.find(([id]) => id === cur) || [0, cur])[1]); |
| 112 | const why = el('div', { class: 'ovlk-why' }, [`${named} \u00b7 ${signedIn ? 'saved to your account' : 'saved in this browser'}`]); |
| 113 | const shareId = showing ? showing.theme : cur; |
| 114 | panel.append(el('div', { class: 'ovlk-foot' }, [why].concat(L.THEMES[shareId] ? [el('button', { type: 'button', class: 'ovlk-share', 'data-look-share': shareId, 'aria-label': `Share the ${named} look` }, ['Share'])] : []))); |
| 115 | panel.append(el('a', { class: 'ovlk-more', href: `${DESIGN}/` }, ['More looks on openvibe.design', el('span', {}, ['\u2192'])])); |
| 116 | box.append(panel); |
| 117 | } |
| 118 | |
| 119 | /** A link to this page in that theme: anyone who opens it tries it (theme-loader's bar: Use it, Stop). */ |
| 120 | function shareLink(id) { |
| 121 | const u = new URL(root.location.href); |
| 122 | u.hash = ''; |
| 123 | ['ov_look', 'ov-try'].forEach((k) => u.searchParams.delete(k)); |
| 124 | u.searchParams.set('ov-try', id); |
| 125 | return u.toString(); |
| 126 | } |
| 127 | function share(btn) { |
| 128 | const url = shareLink(btn.getAttribute('data-look-share')); |
| 129 | const said = (t) => { btn.textContent = t; root.setTimeout(() => { if (btn.isConnected) btn.textContent = 'Share'; }, 1800); }; |
| 130 | const nav = root.navigator || {}; |
| 131 | if (nav.share && /Android|iPhone|iPad/i.test(nav.userAgent || '')) return nav.share({ title: 'My OpenVibe look', url }).catch(() => {}); |
| 132 | if (nav.clipboard && nav.clipboard.writeText) return nav.clipboard.writeText(url).then(() => said('Link copied'), () => { root.prompt && root.prompt('Copy this link', url); }); |
| 133 | if (root.prompt) root.prompt('Copy this link', url); |
| 134 | } |
| 135 | |
| 136 | function mount(box, opts) { |
| 137 | if (!box || box.__ovlk) return; |
| 138 | box.__ovlk = true; |
| 139 | opts = opts || {}; |
| 140 | css(); |
| 141 | draw(box, opts); |
| 142 | box.addEventListener('click', (e) => { |
| 143 | const L = root.OpenVibeThemeLoader; if (!L) return; |
| 144 | const sh = e.target.closest && e.target.closest('[data-look-share]'); |
| 145 | if (sh) { e.stopPropagation(); return share(sh); } |
| 146 | const ic = e.target.closest && e.target.closest('[data-look-icons]'); |
| 147 | if (ic) { e.stopPropagation(); L.display.set({ icons: ic.getAttribute('data-look-icons') }); return draw(box, opts); } |
| 148 | const sc = e.target.closest && e.target.closest('[data-look-scheme]'); |
| 149 | if (sc && L.scheme) { e.stopPropagation(); L.scheme.set(sc.getAttribute('data-look-scheme')); return draw(box, opts); } |
| 150 | const ds = e.target.closest && e.target.closest('[data-look-set]'); |
| 151 | if (ds) { e.stopPropagation(); const [k, v] = ds.getAttribute('data-look-set').split(':'); L.display.set({ [k]: /^\d+$/.test(v) ? Number(v) : v }); return draw(box, opts); } |
| 152 | const s = e.target.closest && e.target.closest('[data-look-season]'); |
| 153 | if (s) { e.stopPropagation(); L.display.set({ seasonal: s.getAttribute('aria-checked') === 'true' ? 'off' : 'on' }); return draw(box, opts); } |
| 154 | const b = e.target.closest && e.target.closest('[data-look]'); if (!b) return; |
| 155 | e.stopPropagation(); |
| 156 | const id = b.getAttribute('data-look'); |
| 157 | if (id.indexOf('season:') === 0) { L.display.set({ seasonal: 'on' }); L.save('vibe', null, { keepSeasonal: true }); } |
| 158 | else L.save(id); |
| 159 | draw(box, opts); |
| 160 | }); |
| 161 | ['ov:theme', 'ov:display'].forEach((ev) => root.addEventListener(ev, () => { if (box.isConnected) draw(box, opts); })); |
| 162 | } |
| 163 | |
| 164 | root.OVLook = { mount, PICKS, shareLink }; |
| 165 | })(typeof window !== 'undefined' ? window : this); |
| 166 |