openvibers/openvibe.shared ov-look.js

ov-look.js

main 15.4 KB · 166 lines · javascript

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 'use strict';
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