openvibers/openvibe.shared theme-loader.js

theme-loader.js

main 66.2 KB · 739 lines · javascript

1// OpenVibe — Theme Loader (browser): the person's look on <html> before first paint. Load it before navbar.js.
2
3(function (root) {
4 'use strict';
5
6 const COOKIE_NAME = 'ov_theme_id';
7 const STORAGE_KEY = 'ov_theme';
8 // The account's look lives on openvibe.network for every site (init({ apiBase }) overrides it).
9 const DEFAULT_API_BASE = 'https://openvibe.network';
10 let _config = { apiBase: DEFAULT_API_BASE };
11
12 // ── Condensed built-in theme variable maps ───────────────
13 // Kept in sync with packages/openvibe-shared/builtin-themes.js
14 // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand
15 const THEME_KEYS = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-card','--bg-hover','--bg-input','--text-primary','--text-secondary','--text-muted','--accent','--accent-light','--accent-dark','--border','--border-light','--live-red','--success','--warning','--danger','--info','--shadow','--shadow-lg','--success-text','--warning-text','--danger-text','--info-text','--live-red-text','--accent-strong','--on-accent-strong','--on-accent','--accent-rgb','--accent-glow','--color-scheme'];
16 const THEME_ROWS = {
17 'vibe': ['#0a0f1c','#101828','#172238','#131c2e','#1c2a44','#0d1424','#e6edf7','#96a7c2','#8192ac','#3b82f6','#60a5fa','#1d4ed8','#1f2d47','#2c3d5c','#ef4444','#22c55e','#f59e0b','#ef4444','#38bdf8','0 2px 12px rgba(0,0,0,0.45)','0 8px 32px rgba(0,0,0,0.6)','#22c55e','#f59e0b','#f26262','#38bdf8','#f26262','#3472d8','#ffffff','#ffffff','59,130,246','rgba(59,130,246,0.35)','dark'],
18 'violet-hour': ['#0d0d0f','#16161a','#1e1e24','#1a1a20','#242430','#12121a','#e8e6e3','#9a9a9a','#8d8c8c','#8b5cf6','#a78bfa','#6d28d9','#2a2a32','#3a3a44','#e74c3c','#2ecc71','#f39c12','#e74c3c','#3498db','0 2px 12px rgba(0,0,0,0.4)','','#2ecc71','#f39c12','#e95e50','#3498db','#e95e50','#8558ec','','','139,92,246','rgba(139,92,246,0.35)'],
19 'midnight': ['#0a0e17','#111827','#1a2235','#151d2e','#1e2a42','#0d1220','#e2e8f0','#94a3b8','#8693a5','#6366f1','#818cf8','#4f46e5','#1e293b','#334155','','','','','','','','','','','','','#6164ec','','','99,102,241','rgba(99,102,241,0.35)'],
20 'forest': ['#0a120a','#121e12','#1a2a1a','#152015','#1e3420','#0d160d','#d4e8d4','#8aaa8a','#849e84','#4ade80','#86efac','#22c55e','#1a3a1a','#2a4a2a','','','','','','','','','','#f36d6d','','#f36d6d','#4ade80','#0b0d10','#0b0d10','74,222,128','rgba(74,222,128,0.35)'],
21 'neon-tokyo': ['#0a0a0f','#12101a','#1a1625','#15111f','#221a32','#0d0b14','#f0e6ff','#b4a0d0','#8e81a2','#ff006e','#ff4d94','#c9005a','#2a1a3a','#3a2a4a','','','','','','','','','','#ef4848','','#ef4848','#e60063','','','255,0,110','rgba(255,0,110,0.35)'],
22 'dracula': ['#282a36','#1e1f29','#343746','#2c2e3c','#3a3d50','#21222c','#f8f8f2','#bfbfb2','#9ea8c5','#bd93f9','#d4b8ff','#9b6ddb','#44475a','#565970','#ff5555','#50fa7b','#f1fa8c','#ff5555','#8be9fd','','','#50fa7b','#f1fa8c','#ff8585','#8be9fd','#ff8585','#bd93f9','#0b0d10','#0b0d10','189,147,249','rgba(189,147,249,0.35)'],
23 'monokai': ['#272822','#1e1f1c','#3e3d32','#2d2e27','#49483e','#22231e','#f8f8f2','#cfcfc2','#b8b6ab','#a6e22e','#c4ff50','#7dac1c','#49483e','#5b5a50','','','#e6db74','#f92672','#66d9ef','','','#45ce78','#e6db74','#fc97bb','#66d9ef','#f79e9e','#a6e22e','#0b0d10','#0b0d10','166,226,46','rgba(166,226,46,0.35)'],
24 'ocean-deep': ['#0a1215','#0f1a1f','#162228','#121c22','#1a2a32','#0c1418','#d0e8ef','#7aa8ba','#7494a1','#00bcd4','#4dd0e1','#0097a7','#1a3040','#2a4050','','','','','','','','','','#f15e5e','','#f15e5e','#00bcd4','#0b0d10','#0b0d10','0,188,212','rgba(0,188,212,0.35)'],
25 'sunset': ['#120a0a','#1a1010','#241616','#1e1212','#2e1a1a','#140c0c','#ffe8e0','#c49080','#9d8179','#ff6b35','#ff8f5e','#d04a1a','#3a2020','#4a3030','','','','','','','','','','#f04b4b','','#f04b4b','#ff6b35','#0b0d10','#0b0d10','255,107,53','rgba(255,107,53,0.35)'],
26 'arctic': ['#0b1620','#0f1e2b','#163040','#122534','#1a3a4c','#0c1a26','#eaf6ff','#9ec3d8','#81a5bb','#22d3ee','#a5f3fc','#0891b2','#1e3f52','#2a5268','','#34d399','','','#7dd3fc','0 2px 12px rgba(2,12,20,0.5)','0 8px 32px rgba(2,12,20,0.65)','#34d399','','#f47c7c','#7dd3fc','#f47c7c','#22d3ee','#0b0d10','#0b0d10','34,211,238','rgba(34,211,238,0.35)'],
27 'ember': ['#120808','#1a0e0e','#241414','#1e1010','#2e1818','#140a0a','#f0dada','#b0808a','#9b8084','#ef4444','#f87171','#b91c1c','#3a1a20','#4a2a30','','','','','','','','','','#ef4848','','#ef4848','#d73d3d','','','239,68,68','rgba(239,68,68,0.35)'],
28 'vapor': ['#0e0a14','#14101c','#1c1628','#181220','#241a34','#100c16','#f0e0ff','#c090e0','#967bbb','#e040fb','#ea80fc','#aa00d4','#2a1a40','#3a2a50','','#69f0ae','','','#00e5ff','','','#69f0ae','','#f04b4b','#00e5ff','#f04b4b','#bc36d3','','','224,64,251','rgba(224,64,251,0.35)'],
29 'slate': ['#0f1118','#161820','#1e2028','#1a1c24','#24262e','#12141c','#e4e6ea','#a0a4b0','#8a8d97','#a78bfa','#c4b5fd','#7c3aed','#2a2c38','#3a3c48','','','','','','','','','','#f15a5a','','#f15a5a','#a78bfa','#0b0d10','#0b0d10','167,139,250','rgba(167,139,250,0.35)'],
30 'matrix': ['#000000','#050a05','#0a140a','#071007','#0e1e0e','#030803','#00ff41','#00b030','#149836','#00ff41','#66ff8a','#00aa2a','#0a2a0a','#144014','','','','','','','','','','#ef4444','','#ef4444','#00ff41','#0b0d10','#0b0d10','0,255,65','rgba(0,255,65,0.35)'],
31 'nord': ['#2e3440','#272c36','#3b4252','#333a48','#434c5e','#2a303c','#eceff4','#d8dee9','#b5bcca','#88c0d0','#8fbcbb','#5e81ac','#3b4252','#4c566a','','#a3be8c','#ebcb8b','#bf616a','#81a1c1','','','#acc598','#ebcb8b','#dfb0b5','#a9bfd5','#f8a9a9','#88c0d0','#0b0d10','#0b0d10','136,192,208','rgba(136,192,208,0.35)'],
32 'gruvbox-dark': ['#1d2021','#282828','#3c3836','#32302f','#504945','#242424','#ebdbb2','#d5c4a1','#c2b8aa','#fabd2f','#fce566','#d79921','#3c3836','#504945','','#b8bb26','#fe8019','#fb4934','#83a598','','','#bec037','#fea65e','#fda49a','#a8c0b7','#f7a5a5','#fabd2f','#0b0d10','#0b0d10','250,189,47','rgba(250,189,47,0.35)'],
33 'abyss': ['#000000','#080808','#111111','#0a0a0a','#181818','#050505','#cccccc','#888888','#828282','#ffffff','#ffffff','#aaaaaa','#1a1a1a','#252525','','','','','','','','','','#ef4444','','#ef4444','#ffffff','#0b0d10','#0b0d10','255,255,255','rgba(255,255,255,0.35)'],
34 'copper': ['#110d09','#1a140e','#231c14','#1e1710','#2c2218','#13100b','#e8ddd0','#b49a80','#978976','#cd7f32','#daa060','#a06020','#2a2016','#3a3020','','','','','','','','','','#f15757','','#f15757','#a46628','','','205,127,50','rgba(205,127,50,0.35)'],
35 'sakura-night': ['#0e0a14','#150f1c','#1c1526','#181020','#221830','#100c16','#f0e0f0','#c0a0c0','#997d99','#f472b6','#f9a8d4','#db2777','#2a1a30','#3a2a40','','','','','','','','','','#ef4848','','#ef4848','#f472b6','#0b0d10','#0b0d10','244,114,182','rgba(244,114,182,0.35)'],
36 'hacker': ['#000000','#0a0800','#141000','#0e0c00','#1c1800','#060400','#ffb000','#cc8d00','#a97a0f','#ffb000','#ffd050','#cc8d00','#1a1400','#2a2200','','','','','','','','','','#ef4444','','#ef4444','#ffb000','#0b0d10','#0b0d10','255,176,0','rgba(255,176,0,0.35)'],
37 'daylight': ['#ffffff','#f7f7f8','#eeeef0','#ffffff','#f0f0f2','#f5f5f7','#1a1a2e','#4a4a6a','#68687b','#7a4ddb','#725faa','#6d28d9','#e0e0e6','#d0d0d8','','','','','','0 2px 12px rgba(0,0,0,0.08)','0 8px 32px rgba(0,0,0,0.12)','#157a3a','#935f07','#c43838','#227195','#c43838','#7a4ddb','','','122,77,219','rgba(122,77,219,0.35)','light'],
38 'paper': ['#faf8f5','#f0ece6','#e8e2d8','#faf8f5','#f0ece6','#f5f2ed','#2c2416','#5a4e3e','#6c6355','#7d5f12','#7b6122','#6a5010','#ddd6c8','#ccc4b0','','','','','','0 2px 12px rgba(60,40,0,0.06)','0 8px 32px rgba(60,40,0,0.10)','#147237','#895806','#b63434','#1f6a8b','#b63434','#7d5f12','','','125,95,18','rgba(125,95,18,0.35)','light'],
39 'cloud': ['#f0f5ff','#e8eef8','#dde5f0','#f5f8ff','#e4ecf8','#edf2fa','#1a2040','#4a5070','#5e637b','#245dd5','#3c669b','#2563eb','#d0d8ea','#bcc8e0','','','','','','0 2px 12px rgba(0,20,60,0.06)','0 8px 32px rgba(0,20,60,0.10)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#245dd5','','','36,93,213','rgba(36,93,213,0.35)','light'],
40 'meadow': ['#f2f8f0','#e8f0e4','#dce8d6','#f5faf2','#e4f0de','#edf5ea','#1a2e16','#3a5a32','#526b4a','#137538','#147638','#15803d','#c8dcc0','#b0ccaa','','','','','','0 2px 12px rgba(0,40,0,0.06)','0 8px 32px rgba(0,40,0,0.10)','#147638','#895806','#ba3535','#1f6a8b','#ba3535','#137538','','','19,117,56','rgba(19,117,56,0.35)','light'],
41 'peach': ['#fff5f0','#fbeae2','#f5ddd2','#fff7f2','#fce8dc','#fdf0ea','#2e1a14','#6a4438','#7e5d51','#ad4107','#925523','#ea580c','#f0d0c0','#e4bfab','','','','','','0 2px 12px rgba(60,20,0,0.06)','0 8px 32px rgba(60,20,0,0.10)','#147237','#895806','#b63434','#1f6a8b','#b63434','#ad4107','','','173,65,7','rgba(173,65,7,0.35)','light'],
42 'lavender': ['#f8f5ff','#f0eaf8','#e6ddf0','#faf7ff','#eee6f8','#f4f0fa','#1e1430','#4a3a60','#6a5d80','#7443d5','#68569b','#7c3aed','#ddd0ee','#ccc0dd','','','','','','0 2px 12px rgba(40,0,60,0.06)','0 8px 32px rgba(40,0,60,0.10)','#147237','#895806','#b63434','#1f6a8b','#b63434','#7443d5','','','116,67,213','rgba(116,67,213,0.35)','light'],
43 'gruvbox-light': ['#fbf1c7','#f2e5bc','#ebdbb2','#fbf1c7','#f2e5bc','#f5ecc4','#3c3836','#504945','#685e54','#a73e06','#934a0e','#af3a03','#d5c4a1','#bdae93','','#98971a','#d79921','#cc241d','#458588','0 2px 12px rgba(40,30,0,0.08)','0 8px 32px rgba(40,30,0,0.12)','#646411','#7d5913','#bc211b','#36686a','#b13232','#a73e06','','','167,62,6','rgba(167,62,6,0.35)','light'],
44 'snow': ['#ffffff','#f8fafc','#f1f5f9','#ffffff','#f1f5f9','#f8fafc','#0f172a','#334155','#657185','#0275b0','#23759a','#0284c7','#e2e8f0','#cbd5e1','','','','','','0 2px 12px rgba(0,0,0,0.05)','0 8px 32px rgba(0,0,0,0.08)','#167e3c','#986207','#c93939','#23759a','#c93939','#0275b0','','','2,117,176','rgba(2,117,176,0.35)','light'],
45 'sand': ['#f8f4ef','#f0e8de','#e8ddd0','#faf6f0','#f0e8de','#f5f0e8','#2e2418','#5a4e3e','#6b6154','#7e5d06','#7a5c12','#8b6508','#ddd0be','#ccc0aa','','','','','','0 2px 12px rgba(60,40,0,0.06)','0 8px 32px rgba(60,40,0,0.10)','#136e35','#895806','#b13232','#1e6686','#b13232','#7e5d06','','','126,93,6','rgba(126,93,6,0.35)','light'],
46 'rose': ['#fff5f7','#fce7ec','#f5d5de','#fff7f9','#fce0e8','#fdeef2','#2e1420','#6a3850','#81536c','#bf183d','#b02d44','#be123c','#f0c8d4','#e4b0c0','','','','','','0 2px 12px rgba(60,0,20,0.06)','0 8px 32px rgba(60,0,20,0.10)','#136e35','#845506','#b13232','#1e6686','#b13232','#bf183d','','','191,24,61','rgba(191,24,61,0.35)','light'],
47 'solarized-dark': ['#002b36','#073642','#0a3f4e','#053542','#0d4a58','#01303d','#fdf6e3','#a6b2b2','#a8b2b0','#b58900','#d4a017','#8a6a00','#0a4050','#105060','','#859900','#cb4b16','#dc322f','#268bd2','','','#aab84d','#e4a186','#ee9997','#78b7e3','#f69696','#947000','','','181,137,0','rgba(181,137,0,0.35)'],
48 'solarized-light': ['#fdf6e3','#eee8d5','#e4ddc6','#fdf6e3','#eee8d5','#f5eedb','#002b36','#51656c','#4e6467','#795d00','#7b5d0d','#8a6a00','#d6ceb5','#c8c0a5','','#859900','#cb4b16','#dc322f','#268bd2','0 2px 12px rgba(0,30,40,0.08)','0 8px 32px rgba(0,30,40,0.12)','#5a6800','#a63e12','#b92a27','#1b6497','#b13232','#795d00','','','121,93,0','rgba(121,93,0,0.35)','light'],
49 'catppuccin-mocha': ['#1e1e2e','#181825','#313244','#1e1e2e','#313244','#1a1a2c','#cdd6f4','#bac2de','#989caf','#cba6f7','#dcc0ff','#b07de0','#313244','#45475a','#f38ba8','#a6e3a1','#fab387','#f38ba8','#89b4fa','','','#a6e3a1','#fab387','#f38ba8','#89b4fa','#f38ba8','#cba6f7','#0b0d10','#0b0d10','203,166,247','rgba(203,166,247,0.35)'],
50 'catppuccin-latte': ['#eff1f5','#e6e9ef','#dce0e8','#eff1f5','#dce0e8','#e8ebf0','#4c4f69','#5c5f77','#5f6176','#7d34dc','#7a46c2','#7028d4','#ccd0da','#bcc0cc','#d20f39','#40a02b','#fe640b','#d20f39','#1e66f5','0 2px 12px rgba(0,0,0,0.06)','0 8px 32px rgba(0,0,0,0.10)','#2d701e','#a84207','#c50e36','#1a5ad8','#c50e36','#7d34dc','','','125,52,220','rgba(125,52,220,0.35)','light'],
51 'high-contrast': ['#000000','#0a0a0a','#1a1a1a','#0f0f0f','#222222','#0a0a0a','#ffffff','#f0f0f0','#aaaaaa','#ffd700','#ffed4a','#ccac00','#444444','#666666','#ff0000','#00ff00','#ffff00','#ff0000','#00ffff','','','#00ff00','#ffff00','#ff3d3d','#00ffff','#ff3d3d','#ffd700','#0b0d10','#0b0d10','255,215,0','rgba(255,215,0,0.35)'],
52 'halloween': ['','#0f1626','#151e33','#111a2b','#1a2540','#0c1322','#eef1f6','#a9b4c8','#8c99b0','#ff7a1a','#ffa14f','#d65d00','#1c2840','#2a3858','','#4ade80','#fbbf24','#f87171','#60a5fa','0 2px 12px rgba(0,4,14,0.5)','0 8px 32px rgba(0,4,14,0.65)','#4ade80','#fbbf24','#f87171','#60a5fa','#f15757','#ff7a1a','#0b0d10','#0b0d10','255,122,26','rgba(255,122,26,0.35)'],
53 'frost': ['#08111b','#0d1926','#132334','#0f1c2b','#18293d','#0a1520','#eaf4fb','#a3b9cc','#7f97ad','#2dd4bf','#7ee8da','#0f9e8c','#1b2d41','#294057','','#4ade80','#fbbf24','#f87171','#93c5fd','0 2px 12px rgba(0,6,14,0.5)','0 8px 32px rgba(0,6,14,0.65)','#4ade80','#fbbf24','#f87171','#93c5fd','#f15e5e','#2dd4bf','#0b0d10','#0b0d10','45,212,191','rgba(45,212,191,0.35)'],
54 'dawn': ['#f5f6fc','#eceefa','#e1e4f5','#fafbff','#e6e9f8','#f0f2fb','#161a33','#474d6b','#5e647f','#4f46e5','#5558cf','#3730a3','#d6daef','#c3c8e6','','','','','','0 2px 12px rgba(10,14,40,0.07)','0 8px 32px rgba(10,14,40,0.12)','#147638','#8e5c06','#ba3535','#1f6a8b','#ba3535','#4f46e5','','','79,70,229','rgba(79,70,229,0.35)','light'],
55 'tokyo-day': ['#fff6fa','#ffedf4','#fde1ec','#fffafc','#fde7f0','#fff0f6','#24101a','#5c3a4a','#7a5566','#cd0058','#c22261','#9c0043','#f3d3e0','#ebbfd1','','','','','','0 2px 12px rgba(40,0,16,0.07)','0 8px 32px rgba(40,0,16,0.12)','#147638','#8e5c06','#bf3636','#206e90','#bf3636','#cd0058','','','205,0,88','rgba(205,0,88,0.35)','light'],
56 'alucard': ['#fffbeb','#f6f0d8','#ece4c6','#fffdf3','#f2ead0','#fbf6e2','#1f1f1f','#4f4b38','#6c664b','#644ac9','#6b53bc','#4a33a3','#e3dab8','#d6cba3','','','','','','0 2px 12px rgba(40,30,0,0.07)','0 8px 32px rgba(40,30,0,0.12)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#644ac9','','','100,74,201','rgba(100,74,201,0.35)','light'],
57 'monokai-light': ['#fafaf6','#f1f1ea','#e7e7de','#fdfdfa','#ecece3','#f6f6f0','#272822','#4e4f44','#67685c','#4b7100','#537112','#436600','#dcdcd0','#cdcdbf','','','','','','0 2px 12px rgba(20,20,10,0.07)','0 8px 32px rgba(20,20,10,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#4b7100','','','75,113,0','rgba(75,113,0,0.35)','light'],
58 'shallows': ['#f0fafb','#e4f4f6','#d5ecef','#f7fcfd','#dcf0f2','#ebf7f8','#0c2226','#3a5458','#516b70','#007384','#00737f','#005662','#c9e3e7','#b4d6db','','','','','','0 2px 12px rgba(0,30,36,0.07)','0 8px 32px rgba(0,30,36,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#007384','','','0,115,132','rgba(0,115,132,0.35)','light'],
59 'ash': ['#faf7f6','#f3eeec','#eae2df','#fdfbfa','#efe7e4','#f7f3f1','#231716','#54403d','#725b57','#c22727','#bc2f2b','#8e1b1b','#e2d6d2','#d4c4bf','','','','','','0 2px 12px rgba(40,10,8,0.07)','0 8px 32px rgba(40,10,8,0.12)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#c22727','','','194,39,39','rgba(194,39,39,0.35)','light'],
60 'vapor-day': ['#fcf6ff','#f6ecfc','#eedff8','#fefaff','#f2e5fa','#f9f1fe','#22122c','#533f5f','#715c7e','#a21caf','#a921ba','#701a75','#e6d3f1','#dbc0ea','','','','','','0 2px 12px rgba(30,0,40,0.07)','0 8px 32px rgba(30,0,40,0.12)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#a21caf','','','162,28,175','rgba(162,28,175,0.35)','light'],
61 'greenbar': ['#f4faf3','#e9f4e7','#dcecd9','#f9fdf8','#e2f0df','#eff7ed','#0d1f10','#3b5240','#556e5a','#13782b','#18772c','#0b5a1f','#cfe3cb','#bcd7b7','','','','','','0 2px 12px rgba(0,30,6,0.07)','0 8px 32px rgba(0,30,6,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#13782b','','','19,120,43','rgba(19,120,43,0.35)','light'],
62 'nord-light': ['#eceff4','#e5e9f0','#d8dee9','#f2f4f8','#dfe4ec','#e8ecf2','#2e3440','#434c5e','#4c566a','#476283','#506478','#4c6a91','#d0d7e2','#c2cad8','','','','','','0 2px 12px rgba(20,26,40,0.07)','0 8px 32px rgba(20,26,40,0.12)','#136e35','#845506','#b13232','#1e6686','#b13232','#476283','','','71,98,131','rgba(71,98,131,0.35)','light'],
63 'bright': ['#ffffff','#f6f6f6','#ececec','#ffffff','#f0f0f0','#fafafa','#0a0a0a','#3d3d3d','#5c5c5c','#111111','#333333','#000000','#e2e2e2','#d0d0d0','','','','','','0 2px 12px rgba(0,0,0,0.07)','0 8px 32px rgba(0,0,0,0.12)','#157a3a','#935f07','#bf3636','#227195','#bf3636','#111111','','','17,17,17','rgba(17,17,17,0.35)','light'],
64 'high-contrast-light': ['#ffffff','#ffffff','#f2f2f2','#ffffff','#e8e8e8','#ffffff','#000000','#1a1a1a','#2b2b2b','#0000c8','#1a1aff','#00008b','#000000','#333333','','','','','','0 2px 12px rgba(0,0,0,0.07)','0 8px 32px rgba(0,0,0,0.12)','#147638','#8e5c06','#bf3636','#206e90','#bf3636','#0000c8','','','0,0,200','rgba(0,0,200,0.35)','light'],
65 'hayride': ['#fbf6ee','#f6eee2','#efe3d1','#ffffff','#f3e9da','#fffaf2','#23180f','#5a4838','#6b5a4a','#b63d0b','#ad4109','#9a3412','#eadfcd','#e0cfb6','','','','','','0 2px 12px rgba(40,24,8,0.07)','0 8px 32px rgba(40,24,8,0.12)','#147638','#8e5c06','#ba3535','#1f6a8b','#ba3535','#b63d0b','','','182,61,11','rgba(182,61,11,0.35)','light'],
66 'snowfall': ['#f2f8fb','#e8f2f7','#dbeaf1','#f8fbfd','#e1eef4','#edf5f9','#0e1d26','#3b5260','#526976','#0f746c','#0c7267','#115e59','#cfe2ea','#bcd5df','','','','','','0 2px 12px rgba(0,20,30,0.07)','0 8px 32px rgba(0,20,30,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#0f746c','','','15,116,108','rgba(15,116,108,0.35)','light'],
67 };
68 const THEMES = (function () {
69 const out = {}, base = THEME_ROWS.vibe;
70 for (const id in THEME_ROWS) {
71 const row = THEME_ROWS[id], vars = {};
72 for (let i = 0; i < THEME_KEYS.length; i++) { const v = row[i] || base[i]; if (v) vars[THEME_KEYS[i]] = v; }
73 out[id] = vars;
74 }
75 return out;
76 })();
77 const SEASONS = [
78 { id: 'halloween', name: 'Halloween', theme: 'halloween', light: 'hayride', from: '10-01', to: '10-31' },
79 { id: 'winter', name: 'Winter', theme: 'frost', light: 'snowfall', from: '12-01', to: '12-31' },
80 ];
81 const PAIRS = { 'vibe':'cloud','violet-hour':'daylight','midnight':'dawn','forest':'meadow','neon-tokyo':'tokyo-day','dracula':'alucard','monokai':'monokai-light','ocean-deep':'shallows','sunset':'peach','arctic':'snow','ember':'ash','vapor':'vapor-day','slate':'lavender','matrix':'greenbar','nord':'nord-light','gruvbox-dark':'gruvbox-light','abyss':'bright','copper':'sand','sakura-night':'rose','hacker':'paper','daylight':'violet-hour','paper':'hacker','cloud':'vibe','meadow':'forest','peach':'sunset','lavender':'slate','gruvbox-light':'gruvbox-dark','snow':'arctic','sand':'copper','rose':'sakura-night','solarized-dark':'solarized-light','solarized-light':'solarized-dark','catppuccin-mocha':'catppuccin-latte','catppuccin-latte':'catppuccin-mocha','high-contrast':'high-contrast-light','halloween':'hayride','frost':'snowfall','dawn':'midnight','tokyo-day':'neon-tokyo','alucard':'dracula','monokai-light':'monokai','shallows':'ocean-deep','ash':'ember','vapor-day':'vapor','greenbar':'matrix','nord-light':'nord','bright':'abyss','high-contrast-light':'high-contrast','hayride':'halloween','snowfall':'frost' };
82 const MODES = { 'vibe':'dark','violet-hour':'dark','midnight':'dark','forest':'dark','neon-tokyo':'dark','dracula':'dark','monokai':'dark','ocean-deep':'dark','sunset':'dark','arctic':'dark','ember':'dark','vapor':'dark','slate':'dark','matrix':'dark','nord':'dark','gruvbox-dark':'dark','abyss':'dark','copper':'dark','sakura-night':'dark','hacker':'dark','daylight':'light','paper':'light','cloud':'light','meadow':'light','peach':'light','lavender':'light','gruvbox-light':'light','snow':'light','sand':'light','rose':'light','solarized-dark':'dark','solarized-light':'light','catppuccin-mocha':'dark','catppuccin-latte':'light','high-contrast':'dark','halloween':'dark','frost':'dark','dawn':'light','tokyo-day':'light','alucard':'light','monokai-light':'light','shallows':'light','ash':'light','vapor-day':'light','greenbar':'light','nord-light':'light','bright':'light','high-contrast-light':'light','hayride':'light','snowfall':'light' };
83 // THEMES:END
84
85 // ── Seasons: in its dates a season's theme stands in for the default theme unless display `seasonal` is off (a picked
86 // theme stays). <html data-ov-season>, window 'ov:season'; the mark wears it on any theme (data-ov-season-mark).
87 const DEFAULT_ID = 'vibe';
88 function seasonNow(date) {
89 const d = date || new Date();
90 const md = String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
91 for (let i = 0; i < SEASONS.length; i++) {
92 const s = SEASONS[i];
93 if (s.from <= s.to ? (md >= s.from && md <= s.to) : (md >= s.from || md <= s.to)) return s;
94 }
95 return null;
96 }
97 let _season = null;
98 let _shown = { id: DEFAULT_ID, vars: null, custom: false };
99 let _trying = null; // { id, name, vars } while a theme is tried on this tab (?ov-try=, 4.41.0)
100 /** Paint the theme `themeId` (its variables `vars`), or the season's theme in its place. → the id painted. */
101 // ── Light, dark and Auto: every built-in has two halves (PAIRS); display `scheme` 'auto' paints the half the device
102 // asks for (a theme without one gives way to the default's half), 'manual' the picked theme as it is.
103 let _mq = null;
104 function deviceMode() {
105 try { _mq = _mq || window.matchMedia('(prefers-color-scheme: dark)'); return _mq.matches ? 'dark' : 'light'; } catch (e) { return 'dark'; }
106 }
107 function modeOf(id, vars) { return MODES[id] || ((vars && vars['--color-scheme']) === 'light' ? 'light' : 'dark'); }
108 function otherHalf(id, mode) { return PAIRS[id] && MODES[PAIRS[id]] === mode ? PAIRS[id] : (mode === 'light' ? PAIRS[DEFAULT_ID] : DEFAULT_ID); }
109 function halfFor(themeId, vars, custom) {
110 if (readDisplay().scheme !== 'auto') return { id: themeId, vars: vars, custom: custom };
111 const want = deviceMode();
112 if (modeOf(themeId, vars) === want) return { id: themeId, vars: vars, custom: custom };
113 const other = otherHalf(themeId, want);
114 return { id: other, vars: THEMES[other], custom: false };
115 }
116 // The default theme in either mode: Vibe, and its light half. A season stands in for both, in the same mode.
117 function isDefault(id) { return id === DEFAULT_ID || id === PAIRS[DEFAULT_ID]; }
118
119 function show(themeId, vars, custom) {
120 _shown = { id: themeId, vars: vars, custom: !!custom };
121 // A theme being tried (?ov-try=) wins every repaint; _shown keeps the real one for Stop.
122 if (_trying && _trying.vars) return paintTry();
123 const half = halfFor(themeId, vars, !!custom);
124 const s = isDefault(half.id) && !half.custom && readDisplay().seasonal !== 'off' ? seasonNow() : null;
125 const seasonTheme = s ? (modeOf(half.id) === 'light' && s.light ? s.light : s.theme) : null;
126 const seasonal = seasonTheme && THEMES[seasonTheme] ? s : null;
127 const painted = seasonal ? seasonTheme : half.id;
128 applyVars(seasonal ? THEMES[seasonTheme] : half.vars);
129 const changed = (_season && _season.id) !== (seasonal && seasonal.id);
130 _season = seasonal;
131 try {
132 const el = document.documentElement;
133 if (seasonal) el.setAttribute('data-ov-season', seasonal.id); else el.removeAttribute('data-ov-season');
134 const marked = readDisplay().seasonal !== 'off' ? seasonNow() : null;
135 if (marked) el.setAttribute('data-ov-season-mark', marked.id); else el.removeAttribute('data-ov-season-mark');
136 el.setAttribute('data-theme', painted);
137 } catch { /* no DOM */ }
138 if (changed) { try { window.dispatchEvent(new CustomEvent('ov:season', { detail: seasonal })); } catch { /* */ } }
139 return painted;
140 }
141
142 /** The Light / Dark / Auto switch's state: 'auto', else the mode of the theme on screen. */
143 function getScheme() { return readDisplay().scheme === 'auto' ? 'auto' : modeOf(_shown.id, _shown.vars); }
144 /** Light or Dark saves the picked theme's other half (Auto off); Auto follows the device. → the new state. */
145 function setScheme(mode) {
146 if (mode === 'auto') { setDisplay({ scheme: 'auto' }); return 'auto'; }
147 if (mode !== 'light' && mode !== 'dark') return getScheme();
148 const cur = _shown;
149 if (readDisplay().scheme === 'auto') setDisplay({ scheme: 'manual' }, { noRepaint: true });
150 if (modeOf(cur.id, cur.vars) !== mode) save(otherHalf(cur.id, mode), null, { keepSeasonal: true });
151 else show(cur.id, cur.vars || THEMES[cur.id] || THEMES[DEFAULT_ID], cur.custom);
152 return mode;
153 }
154
155 function getCookie(name) {
156 const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
157 return match ? decodeURIComponent(match[1]) : null;
158 }
159
160 function setCookie(name, value, days) {
161 const maxAge = days * 24 * 60 * 60;
162 const encoded = encodeURIComponent(value);
163 const host = (typeof location !== 'undefined' && location.hostname) || '';
164 // Domain=.openvibe.tools only on openvibe.tools and its subdomains (CONTRACTS.md); host-only everywhere else.
165 if (host && (host.endsWith('.openvibe.tools') || host === 'openvibe.tools')) {
166 document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};domain=.openvibe.tools;SameSite=Lax;Secure`;
167 } else {
168 const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
169 document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
170 }
171 }
172
173 /**
174 * Apply a theme's CSS variables to document.documentElement.
175 * If themeId is not found in the built-in map, does nothing.
176 */
177 function applyById(themeId) {
178 const vars = THEMES[themeId];
179 if (!vars) return false;
180 applyVars(vars);
181 return true;
182 }
183
184 // Every token any built-in sets: applying a theme clears the ones it does not set (no inherited --success).
185 const KNOWN_PROPS = (function () { const out = {}; for (const id in THEMES) for (const k in THEMES[id]) out[k] = true; return Object.keys(out); })();
186 /** Readable text on a colour: near-black or white, whichever contrasts more (WCAG relative luminance). */
187 function onColor(c) {
188 const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(c || '').trim());
189 if (!m) return null;
190 const h = m[1].length === 3 ? m[1].split('').map((x) => x + x).join('') : m[1];
191 const lin = (i) => { const v = parseInt(h.slice(i, i + 2), 16) / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
192 const L = 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4);
193 return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? '#0b0d10' : '#ffffff';
194 }
195 function applyVars(vars) {
196 if (!vars || typeof vars !== 'object') return;
197 // A theme without its own text-on-accent colours (custom and older server themes) gets readable ones derived.
198 if (vars['--accent'] && !vars['--on-accent']) {
199 const on = onColor(vars['--accent']);
200 if (on) vars = Object.assign({}, vars, { '--on-accent': on, '--on-accent-strong': vars['--on-accent-strong'] || onColor(vars['--accent-strong'] || vars['--accent']) || on });
201 }
202 const el = document.documentElement;
203 for (let i = 0; i < KNOWN_PROPS.length; i++) if (!(KNOWN_PROPS[i] in vars)) el.style.removeProperty(KNOWN_PROPS[i]);
204 for (const [prop, val] of Object.entries(vars)) {
205 if (prop.startsWith('--')) el.style.setProperty(prop, val);
206 }
207 // Native controls, scrollbars and form fields follow the theme's light/dark mode.
208 const scheme = vars['--color-scheme'];
209 if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', scheme); }
210 // The canvas follows the variables (value as fallback), so a page that sets --bg-primary later repaints it too.
211 if (vars['--bg-primary']) el.style.background = `var(--bg-primary, ${vars['--bg-primary']})`;
212 if (vars['--text-primary']) el.style.color = `var(--text-primary, ${vars['--text-primary']})`;
213 syncBrowserUi();
214 }
215
216 // ── A correction fades in ─────────────────────────────────
217 // A theme that changes under someone (the account's answer, another tab) crossfades for 0.4 s instead of jumping;
218 // first paint and their own picks stay instant. A colour fade moves nothing, so Calm keeps it; only Off stops it.
219 let _fadeTimer = 0;
220 function fadeNext() {
221 if (typeof document === 'undefined' || !document.head || motionOf() === 'off') return;
222 const el = document.documentElement;
223 if (!document.getElementById('ov-theme-fade-css')) {
224 const st = document.createElement('style'); st.id = 'ov-theme-fade-css';
225 st.textContent = 'html.ov-theme-fade,html.ov-theme-fade *,html.ov-theme-fade *::before,html.ov-theme-fade *::after{transition:background-color .4s ease,color .4s ease,border-color .4s ease,fill .4s ease,stroke .4s ease!important}';
226 document.head.appendChild(st);
227 }
228 el.classList.add('ov-theme-fade');
229 clearTimeout(_fadeTimer); _fadeTimer = setTimeout(function () { el.classList.remove('ov-theme-fade'); }, 600);
230 }
231
232 // ── The browser's UI follows the theme on every apply: theme-color, the favicon (<link rel="icon" data-ov-icon>),
233 // and window 'ov:theme' { accent, accentLight, bg, mode }.
234 // FAVICON:BEGIN — generated by scripts/build-theme-loader.js from mark.js and app-icon.js; do not edit by hand
235 const FAV_DEFS = '<mask id="ovh" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="#000" stroke-width="31" stroke-linecap="round"/></mask><linearGradient id="ovgo" gradientUnits="userSpaceOnUse" x1="3.09" y1="23" x2="57.09" y2="77"><stop offset="0" stop-color="#87a6ff"/><stop offset="1" stop-color="#4268d6"/></linearGradient><linearGradient id="ovgv" gradientUnits="userSpaceOnUse" x1="0" y1="31.5" x2="0" y2="68.5"><stop offset="0" stop-color="#ae8df9"/><stop offset="1" stop-color="#784fd4"/></linearGradient>';
236 const FAV_BODY = '<circle cx="30.09" cy="50" r="18" fill="none" stroke="url(#ovgo)" stroke-width="18" mask="url(#ovh)"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="url(#ovgv)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>';
237 const FAV_LIGHT = { cx: 30.09, cy: 50, r: 5.58 };
238 const FAV_SITES = { network: ['#60a5fa', ''], live: ['#60a5fa', '#ef4444'], tools: ['#38bdf8', ''], community: ['#34d399', ''], games: ['#a78bfa', '#facc15'], media: ['#fbbf24', ''], space: ['#818cf8', ''], ai: ['#22d3ee', ''] };
239 // FAVICON:END
240 const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/i;
241 function readColor(name, fallback) {
242 try { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return COLOR_RE.test(v) ? v : fallback; } catch { return fallback; }
243 }
244 /** The tab icon: app-icon.js's favicon() (the Pair, heavy form, the site's glow and light) on the theme's ground. */
245 function faviconSvg(site, bg) {
246 const s = FAV_SITES[site] || FAV_SITES.network, a = s[0];
247 const light = s[1] ? '<circle cx="' + FAV_LIGHT.cx + '" cy="' + FAV_LIGHT.cy + '" r="' + FAV_LIGHT.r + '" fill="' + s[1] + '"/>' : '';
248 return 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs>' + FAV_DEFS
249 + '\n<radialGradient id="glow"><stop offset="0" stop-color="' + a + '" stop-opacity=".3"/><stop offset="1" stop-color="' + a + '" stop-opacity="0"/></radialGradient></defs>'
250 + '<rect x="-32" y="-32" width="64" height="64" rx="15" fill="' + bg + '"/><g transform="scale(0.58) translate(-50 -50)"><ellipse cx="50" cy="50" rx="52" ry="40" fill="url(#glow)"/>'
251 + FAV_BODY + light + '</g></svg>');
252 }
253 let _browserUiKey = '';
254 function syncBrowserUi() {
255 if (typeof document === 'undefined' || !document.head) return;
256 const accent = readColor('--accent', '#3b82f6'), light = readColor('--accent-light', accent), bg = readColor('--bg-secondary', readColor('--bg-primary', '#0a0f1c'));
257 const mode = document.documentElement.getAttribute('data-theme-mode') || 'dark';
258 const key = [accent, light, bg, mode].join('|'); if (key === _browserUiKey) return; _browserUiKey = key;
259 try {
260 let meta = document.querySelector('meta[name="theme-color"]');
261 if (!meta) { meta = document.createElement('meta'); meta.name = 'theme-color'; document.head.appendChild(meta); }
262 meta.setAttribute('content', bg);
263 const link = document.querySelector('link[rel~="icon"][data-ov-icon]');
264 if (link) { link.type = 'image/svg+xml'; link.href = faviconSvg(link.getAttribute('data-ov-icon'), readColor('--bg-primary', '#0a0f1c')); }
265 } catch { /* cosmetic */ }
266 try { window.dispatchEvent(new CustomEvent('ov:theme', { detail: { accent: accent, accentLight: light, bg: bg, mode: mode } })); } catch { /* */ }
267 }
268
269 /** The theme from localStorage 'ov_theme' (id and variables), else the 'ov_theme_id' cookie, else the default. */
270 function resolveAndApply() {
271 // 1. Try localStorage (same-origin cache with full variables)
272 try {
273 const raw = localStorage.getItem(STORAGE_KEY);
274 if (raw) {
275 const saved = JSON.parse(raw);
276 if (saved && saved.variables && typeof saved.variables === 'object') {
277 return show(saved.id || saved.slug || DEFAULT_ID, saved.variables, saved.custom);
278 }
279 if (saved && (saved.id || saved.slug)) {
280 const id = saved.id || saved.slug;
281 if (THEMES[id]) return show(id, THEMES[id]);
282 }
283 }
284 } catch { /* corrupt localStorage */ }
285
286 // 2. Try cross-domain cookie
287 const cookieId = getCookie(COOKIE_NAME);
288 if (cookieId && THEMES[cookieId]) {
289 // Cache the full variable set so the next load paints from localStorage instantly.
290 cacheLocally(cookieId, THEMES[cookieId]);
291 return show(cookieId, THEMES[cookieId]);
292 }
293
294 // 3. Default — vibe (already the CSS fallback values, but apply explicitly), or the season's theme
295 return show(DEFAULT_ID, THEMES[DEFAULT_ID]);
296 }
297
298 function authToken() {
299 return getCookie('ov_token') ||
300 (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token'))) ||
301 null;
302 }
303
304 /** Cache the theme's FULL variables (custom ones too) and the date it was chosen, so the next load paints at once. */
305 function cacheLocally(themeId, vars, custom, at) {
306 try {
307 localStorage.setItem(STORAGE_KEY, JSON.stringify({
308 id: themeId, slug: themeId, variables: vars, at: at || Date.now(), ...(custom ? { custom: true } : {}),
309 }));
310 } catch { /* quota */ }
311 setCookie(COOKIE_NAME, themeId, 365);
312 }
313
314 /** Save a theme here and to the account (opts.custom: variable overrides; opts.sync === false: this device only). */
315 function save(themeId, variables, opts) {
316 const custom = (opts && opts.custom) || null;
317 const base = variables || THEMES[themeId] || {};
318 const vars = custom ? Object.assign({}, base, custom) : base;
319 if (!vars || !Object.keys(vars).length) return;
320 // Picking the default theme while a season's stands in for it means "my normal theme": seasonal themes go off.
321 if (isDefault(themeId) && !custom && _season && !(opts && opts.keepSeasonal)) setDisplay({ seasonal: 'off' }, { noRepaint: true });
322 cacheLocally(themeId, vars, !!custom);
323 show(themeId, vars, !!custom); // saving a theme shows it: every token, the canvas and the browser UI follow at once
324 if (opts && opts.sync === false) return;
325 if (typeof fetch === 'undefined') return;
326 const token = authToken();
327 const headers = { 'Content-Type': 'application/json' };
328 if (token) headers['Authorization'] = 'Bearer ' + token;
329 fetch(_config.apiBase + '/api/themes/me', {
330 method: 'PUT', headers: headers, credentials: 'include', keepalive: true,
331 body: JSON.stringify({ theme_id: themeId, custom_variables: custom, at: themeAt() }),
332 }).catch(function () { /* offline / signed out — local cache still holds */ });
333 }
334
335 /** When the theme in this browser was chosen (ms), or 0 for a copy older than 4.64.0. */
336 function themeAt() {
337 try { const t = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); return t && Number(t.at) > 0 ? Number(t.at) : 0; } catch { return 0; }
338 }
339
340 /**
341 * Get the current theme ID (from cookie or localStorage).
342 */
343 function getCurrent() {
344 try {
345 const raw = localStorage.getItem(STORAGE_KEY);
346 if (raw) {
347 const saved = JSON.parse(raw);
348 if (saved && (saved.id || saved.slug)) return saved.id || saved.slug;
349 }
350 } catch {}
351 return getCookie(COOKIE_NAME) || 'vibe';
352 }
353
354 // ── Display settings, one record for every OpenVibe site: display.get() → DISPLAY_OPTS' values; display.set(patch)
355 // applies here and in other tabs and saves to the account. <html data-ov-*> and --ov-text-scale before first paint.
356 const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'full', 'reduced', 'off'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'], window: ['glass', 'solid', 'outline'] };
357 // Status names some sites use (--error, --error-text, --ok, --warn, --accent-text) that no theme sets: they resolve to the theme's
358 // readable status colours instead of each site's fallback literal (pale on light themes). :where() keeps them
359 // below any site's own value (4.86.0).
360 // A control nobody styled wears the theme, not the browser's grey (4.186.0): only one with no class, only its face,
361 // at no specificity, so any rule a site wrote still wins and no layout moves. Its shape is the kit's job.
362 const BARE = ':where(:not([class]))', FACE = '{font-family:inherit;border:1px solid var(--border-light,var(--border,color-mix(in srgb,currentColor 30%,transparent)));border-radius:10px;color:var(--text-primary,inherit);background:';
363 const BARE_CSS = ':where(button,input:is([type=submit],[type=button],[type=reset]))' + BARE + FACE + 'var(--bg-hover,var(--bg-card,color-mix(in srgb,currentColor 8%,transparent)))}'
364 + ':where(input:not([type=checkbox],[type=radio],[type=range],[type=color],[type=file],[type=image],[type=submit],[type=button],[type=reset],[type=hidden]),select,textarea)' + BARE + FACE + 'var(--bg-input,var(--bg-primary,transparent))}'
365 + ':where(input:is([type=checkbox],[type=radio],[type=range]),progress)' + BARE + '{accent-color:var(--accent,auto)}';
366 const ALIAS_CSS = ':where(html){--error:var(--danger);--error-text:var(--danger-text);--ok:var(--success-text);--warn:var(--warning-text);--accent-text:var(--accent-strong)}';
367 // Comfort (4.45.0): one <html> attribute each, !important over the inline theme (Okabe-Ito colours, OLED black).
368 const COMFORT_CSS = 'html[data-ov-contrast=more]{--text-secondary:color-mix(in srgb,var(--text-primary) 88%,var(--bg-primary))!important;--text-muted:color-mix(in srgb,var(--text-primary) 76%,var(--bg-primary))!important;--border:color-mix(in srgb,var(--text-primary) 40%,var(--bg-primary))!important;--border-light:color-mix(in srgb,var(--text-primary) 55%,var(--bg-primary))!important}'
369 + 'html[data-ov-contrast=more] :is(p,dd,td) a:not([class]){text-decoration-line:underline;text-underline-offset:2px}html[data-ov-contrast=more] :focus-visible{outline:3px solid var(--accent-strong,var(--accent))!important;outline-offset:2px}'
370 + 'html[data-ov-font=readable]{--ov-font:"Atkinson Hyperlegible",Verdana,Tahoma,"Segoe UI",system-ui,sans-serif!important}html[data-ov-font=readable] body{font-family:var(--ov-font)!important;letter-spacing:.02em;word-spacing:.06em}html[data-ov-font=readable] :is(p,li,dd){line-height:1.7}'
371 + 'html[data-ov-colors=cb-safe]{--success:#56b4e9!important;--success-text:#7cc6ef!important;--warning:#f0e442!important;--warning-text:#f0e442!important;--danger:#e8743b!important;--danger-text:#f0985f!important}'
372 + 'html[data-ov-colors=cb-safe][data-theme-mode=light]{--success:#0072b2!important;--success-text:#005a8c!important;--warning:#e69f00!important;--warning-text:#8a6100!important;--danger:#d55e00!important;--danger-text:#a84400!important}'
373 + 'html[data-ov-oled=on][data-theme-mode=dark]{--bg-primary:#000!important;--bg-secondary:#050608!important;--bg-tertiary:#0b0d12!important;--bg-card:#0a0c10!important;--bg-input:#0b0d12!important;--bg-hover:#12151c!important}';
374 const COMFORT_ATTRS = { contrast: ['data-ov-contrast', 'normal'], font: ['data-ov-font', 'default'], colors: ['data-ov-colors', 'default'], oled: ['data-ov-oled', 'off'], icons: ['data-ov-icons', 'auto'], window: ['data-ov-window', 'glass'] };
375 // ── Motion (4.183.0; the owner, 2026-11-10: "still have full motion but actually just reduce it instead of getting
376 // rid of it"). auto follows the device, full plays everything, reduced is Calm (every effect, smaller and slower),
377 // off alone stops motion. <html data-ov-motion=full|reduced|off> is the one signal; --ov-move is what a moving
378 // thing multiplies its distance by (display.move() in script); under Calm an endless loop runs at CALM_RATE.
379 const CALM_MOVE = 0.35, CALM_RATE = 0.6;
380 let _rm = null;
381 function deviceCalm() { try { _rm = _rm || window.matchMedia('(prefers-reduced-motion: reduce)'); return !!_rm.matches; } catch (e) { return false; } }
382 function motionOf(d) { const m = (d || readDisplay()).motion; return m === 'off' ? 'off' : m === 'reduced' ? 'calm' : m === 'full' ? 'full' : deviceCalm() ? 'calm' : 'full'; }
383 function moveOf() { const m = motionOf(); return m === 'off' ? 0 : m === 'calm' ? CALM_MOVE : 1; }
384 const MOTION_CSS = ':where(html){--ov-move:1}html[data-ov-motion=reduced]{--ov-move:' + CALM_MOVE + ';scroll-behavior:auto!important}html[data-ov-motion=off]{--ov-move:0}'
385 + 'html[data-ov-motion=off] *:not([data-fx-demo] *),html[data-ov-motion=off] *:not([data-fx-demo] *)::before,html[data-ov-motion=off] *:not([data-fx-demo] *)::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}'
386 + 'html[data-ov-motion=off]::view-transition-group(*),html[data-ov-motion=off]::view-transition-old(*),html[data-ov-motion=off]::view-transition-new(*){animation-duration:.001ms!important}'
387 + 'html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}';
388 const _paced = typeof WeakSet === 'function' ? new WeakSet() : null;
389 function paceLoops(scope) {
390 if (!_paced || typeof document === 'undefined') return;
391 try {
392 const calm = document.documentElement.getAttribute('data-ov-motion') === 'reduced';
393 const list = scope && scope.getAnimations ? scope.getAnimations({ subtree: true }) : document.getAnimations();
394 for (let i = 0; i < list.length; i++) {
395 const a = list[i], fx = a.effect, t = fx && fx.getComputedTiming ? fx.getComputedTiming() : null;
396 if (!t || t.iterations !== Infinity) continue;
397 const el = fx.target, slow = calm && !(el && el.closest && el.closest('[data-fx-demo]'));
398 if (slow && !_paced.has(a)) { _paced.add(a); a.updatePlaybackRate(a.playbackRate * CALM_RATE); }
399 else if (!slow && _paced.has(a)) { _paced.delete(a); a.updatePlaybackRate(a.playbackRate / CALM_RATE); }
400 }
401 } catch (e) { /* no Web Animations here: loops keep their own speed */ }
402 }
403 if (typeof document !== 'undefined' && document.addEventListener) {
404 document.addEventListener('animationstart', function (e) { if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') paceLoops(e.target); }, true);
405 }
406 function readDisplay() {
407 const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off', icons: 'auto', window: 'glass' };
408 try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }
409 return out;
410 }
411 function applyDisplay(d) {
412 if (typeof document === 'undefined') return;
413 const el = document.documentElement;
414 const m = motionOf(d), attr = m === 'off' ? 'off' : m === 'calm' ? 'reduced' : d.motion === 'full' ? 'full' : '';
415 if (attr) el.setAttribute('data-ov-motion', attr); else el.removeAttribute('data-ov-motion');
416 if (d.text === '100') { el.style.removeProperty('--ov-text-scale'); el.removeAttribute('data-ov-text'); } else { el.style.setProperty('--ov-text-scale', String(parseInt(d.text, 10) / 100)); el.setAttribute('data-ov-text', d.text); }
417 for (const k in COMFORT_ATTRS) { const [attr, off] = COMFORT_ATTRS[k]; if (d[k] && d[k] !== off) el.setAttribute(attr, d[k]); else el.removeAttribute(attr); }
418 if (!document.getElementById('ov-display-css') && document.head) {
419 const st = document.createElement('style'); st.id = 'ov-display-css';
420 st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}' + MOTION_CSS + BARE_CSS + ALIAS_CSS + COMFORT_CSS;
421 document.head.appendChild(st);
422 }
423 paceLoops();
424 try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }
425 }
426 /** When the display settings in this browser were last changed (ms), or 0 for a copy older than 4.64.0. */
427 function displayAt() {
428 try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null'); return d && Number(d.at) > 0 ? Number(d.at) : 0; } catch (e) { return 0; }
429 }
430 function setDisplay(patch, opts) {
431 const d = readDisplay();
432 const was = d.seasonal + '|' + d.scheme;
433 for (const k in DISPLAY_OPTS) if (patch && DISPLAY_OPTS[k].indexOf(String(patch[k])) >= 0) d[k] = String(patch[k]);
434 // A change made here is dated now; one adopted from the account or another site keeps its own date.
435 const at = opts && Number(opts.at) > 0 ? Number(opts.at) : (opts && opts.local ? displayAt() : Date.now());
436 try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(Object.assign({}, d, { at: at }))); } catch (e) { /* */ }
437 applyDisplay(d);
438 if (d.seasonal + '|' + d.scheme !== was && !(opts && opts.noRepaint)) show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom);
439 if (!(opts && opts.local) && typeof fetch !== 'undefined') {
440 const token = authToken(); const headers = { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token;
441 if (token || /(?:^|; )ov_sso_hint=account/.test(document.cookie)) fetch(_config.apiBase + '/api/themes/me/display', { method: 'PUT', headers: headers, credentials: 'include', keepalive: true, body: JSON.stringify(Object.assign({}, d, { at: at })) }).catch(function () {});
442 }
443 return d;
444 }
445
446 // ── Try a theme (4.41.0): ?ov-try=<catalog name> paints it for this tab without saving (?ov-try=off ends it); a bar
447 // offers Use it (saves to the account) and Stop. Only a name is read from the URL, never colours.
448 const TRY_KEY = 'ov_try';
449 const TRY_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
450 const tryName = function (id) { return String(id).split('-').map(function (w) { return w ? w[0].toUpperCase() + w.slice(1) : w; }).join(' '); };
451 function paintTry() {
452 applyVars(_trying.vars);
453 try {
454 const el = document.documentElement;
455 el.setAttribute('data-theme', _trying.id);
456 el.setAttribute('data-ov-trying', _trying.id);
457 el.removeAttribute('data-ov-season');
458 } catch { /* no DOM */ }
459 syncBrowserUi();
460 return _trying.id;
461 }
462 function readTry() {
463 let id = '';
464 try {
465 const p = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('ov-try') : null;
466 if (p === 'off') { sessionStorage.removeItem(TRY_KEY); return null; }
467 if (p && TRY_ID.test(p)) sessionStorage.setItem(TRY_KEY, p);
468 id = sessionStorage.getItem(TRY_KEY) || '';
469 } catch { id = ''; }
470 return TRY_ID.test(id) ? id : null;
471 }
472 function startTry(id, name) {
473 if (!id || !TRY_ID.test(id)) return;
474 try { sessionStorage.setItem(TRY_KEY, id); } catch { /* private mode: this page only */ }
475 const begin = function (vars, label) {
476 if (!vars || !Object.keys(vars).length) { stopTry(false); return; }
477 _trying = { id: id, name: label || tryName(id), vars: vars };
478 fadeNext();
479 paintTry();
480 whenReady(tryBar);
481 };
482 if (THEMES[id]) return begin(THEMES[id], name);
483 if (typeof fetch === 'undefined') return;
484 // A gallery theme comes from the catalog, once the page has run init({ apiBase }) (the script tag after this one).
485 whenReady(function () {
486 fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(id))
487 .then(function (r) { return r.ok ? r.json() : null; })
488 .then(function (d) { begin(d && d.theme && d.theme.variables, d && d.theme && d.theme.name); })
489 .catch(function () { stopTry(false); });
490 });
491 }
492 function stopTry(repaint) {
493 _trying = null;
494 try { sessionStorage.removeItem(TRY_KEY); } catch { /* */ }
495 try {
496 document.documentElement.removeAttribute('data-ov-trying');
497 const bar = document.getElementById('ov-try-bar');
498 if (bar) bar.remove();
499 const u = new URL(location.href);
500 if (u.searchParams.has('ov-try')) { u.searchParams.delete('ov-try'); history.replaceState(history.state, '', u.pathname + (u.search || '') + u.hash); }
501 } catch { /* no DOM */ }
502 if (repaint !== false) { fadeNext(); resolveAndApply(); }
503 }
504 function whenReady(fn) {
505 if (typeof document === 'undefined') return;
506 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true }); else fn();
507 }
508 function tryBar() {
509 if (!_trying || document.getElementById('ov-try-bar') || !document.body) return;
510 const t = _trying;
511 const bar = document.createElement('div');
512 bar.id = 'ov-try-bar';
513 bar.setAttribute('role', 'status');
514 bar.style.cssText = 'position:fixed;left:0;right:0;margin:0 auto;width:max-content;bottom:calc(var(--ovnav-appbar-h,0px) + 12px);z-index:2147483000;display:flex;align-items:center;gap:6px;max-width:calc(100vw - 24px);box-sizing:border-box;padding:4px 4px 4px 16px;border-radius:999px;border:1px solid var(--border-light,var(--border,#2c3d5c));background:var(--bg-card,#131c2e);color:var(--text-primary,#e6edf7);box-shadow:0 10px 30px rgba(0,0,0,.35);font:600 14px/1.3 var(--ov-font,system-ui,sans-serif)';
515 const label = document.createElement('span');
516 label.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0';
517 label.textContent = 'Trying ' + t.name;
518 const btn = function (text, strong) {
519 const b = document.createElement('button');
520 b.type = 'button';
521 b.textContent = text;
522 b.style.cssText = 'flex:none;min-height:44px;padding:0 16px;border-radius:999px;cursor:pointer;font:inherit;' + (strong
523 ? 'border:0;background:var(--accent-strong,var(--accent,#3b82f6));color:var(--on-accent-strong,#fff)'
524 : 'border:1px solid var(--border-light,var(--border,#2c3d5c));background:transparent;color:inherit');
525 return b;
526 };
527 const use = btn('Use it', true);
528 const stop = btn('Stop', false);
529 use.addEventListener('click', function () {
530 stopTry(false);
531 save(t.id, t.vars);
532 const done = document.createElement('div');
533 done.setAttribute('role', 'status');
534 done.style.cssText = bar.style.cssText + ';padding:12px 18px';
535 done.textContent = t.name + ' is your theme now, on every OpenVibe site.';
536 document.body.appendChild(done);
537 setTimeout(function () { done.remove(); }, 3500);
538 });
539 stop.addEventListener('click', function () { stopTry(true); });
540 bar.append(label, use, stop);
541 document.body.appendChild(bar);
542 }
543
544 // ── The look travels (4.64.0): each domain keeps its own copy, so a click to another OpenVibe site carries ?ov_look=
545 // (what this page paints, the display and theme with their dates). Added at click time, read only after an OpenVibe
546 // page, only colours pass; the next site keeps it when newer and drops it from the address before first paint.
547 const OV_HOST = /(^|\.)(openvibe\.[a-z]{2,12}|openre\.stream)$/i;
548 const PAINT_KEY = 'ov_paint';
549 const PAINT_ATTRS = ['data-theme', 'data-theme-mode', 'data-ov-season', 'data-ov-season-mark', 'data-ov-motion', 'data-ov-text', 'data-ov-contrast', 'data-ov-font', 'data-ov-colors', 'data-ov-oled', 'data-ov-icons', 'data-ov-window'];
550 const SAFE_VALUE = /^(#[0-9a-f]{3,8}|rgba?\([0-9.,\s%]+\)|[0-9.,\s]+|light|dark|(-?[0-9.]+(px)?\s+){2,4}rgba?\([0-9.,\s%]+\))$/i;
551 const SAFE_ATTR = /^[a-z0-9-]{1,40}$/;
552 function seasonUntil(s) {
553 if (!s) return 0;
554 const d = new Date(), y = d.getFullYear(), to = s.to.split('-');
555 const end = new Date(y + (s.from > s.to && s.from <= String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') ? 1 : 0), Number(to[0]) - 1, Number(to[1]) + 1);
556 return end.getTime();
557 }
558 function paintRecord() {
559 const el = document.documentElement, a = {};
560 for (let i = 0; i < PAINT_ATTRS.length; i++) { const v = el.getAttribute(PAINT_ATTRS[i]); if (v) a[PAINT_ATTRS[i]] = v; }
561 return { k: THEME_KEYS.map(function (k) { return el.style.getPropertyValue(k).trim(); }), a: a, ts: el.style.getPropertyValue('--ov-text-scale').trim(), u: seasonUntil(_season), at: Date.now() };
562 }
563 let _paintTimer = 0;
564 function rememberPaint() {
565 if (_paintTimer || typeof setTimeout === 'undefined') return;
566 _paintTimer = setTimeout(function () { _paintTimer = 0; if (_trying) return; try { localStorage.setItem(PAINT_KEY, JSON.stringify(paintRecord())); } catch (e) { /* */ } }, 0);
567 }
568 function lookToken() {
569 const rec = { v: 1, d: readDisplay(), da: displayAt(), t: _shown.id, c: _shown.custom ? 1 : 0, ta: themeAt(), p: paintRecord() };
570 return btoa(JSON.stringify(rec)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
571 }
572 function readLook() {
573 try {
574 if (typeof location === 'undefined' || !/[?&]ov_look=/.test(location.search)) return null;
575 const u = new URL(location.href), raw = u.searchParams.get('ov_look') || '';
576 u.searchParams.delete('ov_look');
577 try { history.replaceState(history.state, '', u.pathname + (u.search || '') + u.hash); } catch (e) { /* */ }
578 const from = document.referrer ? new URL(document.referrer).hostname : '';
579 if (!OV_HOST.test(from) || raw.length > 4000) return null;
580 const L = JSON.parse(atob(raw.replace(/-/g, '+').replace(/_/g, '/')));
581 return L && L.v === 1 ? L : null;
582 } catch (e) { return null; }
583 }
584 function adoptLook(L) {
585 if (!L) return;
586 const now = Date.now(), clamp = function (n) { n = Number(n) || 0; return n > now + 60000 ? now : n; };
587 const da = clamp(L.da), ta = clamp(L.ta);
588 if (L.d && typeof L.d === 'object' && da > displayAt()) {
589 const d = readDisplay();
590 for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(L.d[k])) >= 0) d[k] = String(L.d[k]);
591 try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(Object.assign({}, d, { at: da }))); } catch (e) { /* */ }
592 }
593 const id = String(L.t || '');
594 if (TRY_ID.test(id) && ta > themeAt()) {
595 if (!L.c && THEMES[id]) cacheLocally(id, THEMES[id], false, ta);
596 else if (L.c && L.p && Array.isArray(L.p.k)) {
597 const vars = {};
598 for (let i = 0; i < THEME_KEYS.length; i++) { const v = String(L.p.k[i] || ''); if (v && SAFE_VALUE.test(v)) vars[THEME_KEYS[i]] = v; }
599 if (Object.keys(vars).length > 8) cacheLocally(id, vars, true, ta);
600 }
601 }
602 }
603 function decorateLink(e) {
604 const a = e.target && e.target.closest && e.target.closest('a[href]');
605 if (!a || a.hasAttribute('download')) return;
606 let u; try { u = new URL(a.href, location.href); } catch (err) { return; }
607 if (!/^https?:$/.test(u.protocol) || u.hostname === location.hostname || !OV_HOST.test(u.hostname)) return;
608 try { u.searchParams.set('ov_look', lookToken()); a.href = u.toString(); } catch (err) { /* */ }
609 }
610 const _arrived = readLook();
611 adoptLook(_arrived);
612 applyDisplay(readDisplay());
613 if (typeof document !== 'undefined' && document.addEventListener) {
614 ['pointerdown', 'keydown', 'click'].forEach(function (ev) { document.addEventListener(ev, function (e) { if (ev !== 'keydown' || e.key === 'Enter') decorateLink(e); }, true); });
615 window.addEventListener('ov:theme', rememberPaint);
616 window.addEventListener('ov:display', rememberPaint);
617 }
618
619 const activeId = resolveAndApply();
620 rememberPaint();
621 startTry(readTry());
622 // The icon link may be parsed after this script: repaint once the head is complete.
623 if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { _browserUiKey = ''; syncBrowserUi(); });
624 // A theme picked in another tab (or on another OpenVibe page of this site) applies here at once.
625 if (typeof window !== 'undefined') window.addEventListener('storage', function (e) { if (e.key === 'ov_theme') { try { fadeNext(); resolveAndApply(); } catch (err) { /* */ } } if (e.key === DISPLAY_KEY) { fadeNext(); applyDisplay(readDisplay()); show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom); } });
626
627 if (typeof window !== 'undefined' && window.matchMedia) {
628 try {
629 const mq = window.matchMedia('(prefers-color-scheme: dark)');
630 const onDevice = function () { if (readDisplay().scheme === 'auto') { fadeNext(); show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom); } };
631 if (mq.addEventListener) mq.addEventListener('change', onDevice); else if (mq.addListener) mq.addListener(onDevice);
632 } catch (e) { /* no media queries */ }
633 }
634
635 if (typeof window !== 'undefined' && window.matchMedia) {
636 try {
637 const rmq = window.matchMedia('(prefers-reduced-motion: reduce)');
638 const onMotion = function () { if (readDisplay().motion === 'auto') applyDisplay(readDisplay()); };
639 if (rmq.addEventListener) rmq.addEventListener('change', onMotion); else if (rmq.addListener) rmq.addListener(onMotion);
640 } catch (e) { /* no media queries */ }
641 }
642
643 // A theme not in the built-in map (community, custom) comes from the catalog, never falls back to the default.
644 function fetchThemeVars(themeId) {
645 return fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(themeId))
646 .then(function (r) { return r.ok ? r.json() : null; })
647 .then(function (d) { return (d && d.theme && d.theme.variables) || null; })
648 .catch(function () { return null; });
649 }
650
651 // ── The account's look from openvibe.network, at once (the cached look is already painted; newest copy wins). No
652 // token gate: the ov_token cookie rides along (credentials: 'include'), for sites whose script cannot read it.
653 let _lastSync = 0;
654 /** This site's id from <html data-ov-service> (openvibe-shared/shell writes it), or '' (4.43.0). */
655 function siteService() {
656 try { const s = document.documentElement.getAttribute('data-ov-service') || ''; return /^[a-z][a-z0-9-]{1,31}$/.test(s) ? s : ''; } catch { return ''; }
657 }
658 function syncFromServer() {
659 if (typeof fetch === 'undefined') return;
660 _lastSync = Date.now();
661 const token = authToken();
662 const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
663 // A per-site look set in OpenVibe.Design ("Games in Neon Tokyo") answers for ?service=<this site> (4.43.0).
664 const site = siteService();
665 fetch(_config.apiBase + '/api/themes/me/active' + (site ? '?service=' + encodeURIComponent(site) : ''), { headers: headers, credentials: 'include' })
666 .then(function (r) { return r.ok ? r.json() : null; })
667 .then(function (data) {
668 if (data && data.display) {
669 const cur = readDisplay(); const next = {}; for (const k in DISPLAY_OPTS) next[k] = data.display[k] || cur[k];
670 const serverAt = Number(data.display_at) || 0, localAt = displayAt();
671 const takeServer = serverAt ? serverAt > localAt : !(localAt && Date.now() - localAt < 60000);
672 if (takeServer && JSON.stringify(next) !== JSON.stringify(cur)) { fadeNext(); setDisplay(data.display, { local: true, at: serverAt || localAt }); }
673 else if (serverAt && localAt > serverAt && JSON.stringify(next) !== JSON.stringify(cur)) setDisplay(cur, { at: localAt }); // newer here: the account catches up
674 }
675 if (!data || !data.theme_id) return;
676 const serverThemeAt = Number(data.theme_at) || 0, localThemeAt = themeAt();
677 if (serverThemeAt ? serverThemeAt < localThemeAt : (localThemeAt && Date.now() - localThemeAt < 60000)) return; // a newer pick here
678 const serverId = data.theme_id;
679 const custom = (data.custom_variables && typeof data.custom_variables === 'object'
680 && Object.keys(data.custom_variables).length) ? data.custom_variables : null;
681 const builtin = THEMES[serverId];
682 const settle = function (base) {
683 const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
684 if (!vars || !Object.keys(vars).length) return;
685 cacheLocally(serverId, vars, !!custom, serverThemeAt || localThemeAt || 1); // cacheLocally, not save() — no write-back loop
686 if (_shown.id === serverId && !!_shown.custom === !!custom && JSON.stringify(_shown.vars) === JSON.stringify(vars)) return;
687 fadeNext();
688 show(serverId, vars, !!custom);
689 };
690 if (builtin) return settle(builtin);
691 fetchThemeVars(serverId).then(settle);
692 })
693 .catch(function () { /* offline / signed out — keep the cached theme */ });
694 }
695 syncFromServer();
696 // Asked again when the page comes back into view, and shortly after arriving from another site (a change saved there
697 // just before the link may land after this page first asked).
698 if (typeof document !== 'undefined' && document.addEventListener) {
699 const again = function () { if (Date.now() - _lastSync > 10000) syncFromServer(); };
700 document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') again(); });
701 if (typeof window !== 'undefined' && window.addEventListener) window.addEventListener('pageshow', function (e) { if (e && e.persisted) again(); });
702 let from = '';
703 try { from = document.referrer ? new URL(document.referrer).hostname : ''; } catch (e) { from = ''; }
704 if (from && typeof location !== 'undefined' && from !== location.hostname) setTimeout(syncFromServer, 2500);
705 }
706
707 // ── Public API ───────────────────────────────────────────
708 const OpenVibeThemeLoader = {
709 THEMES: THEMES,
710 display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS, motion: function () { return motionOf(); }, move: moveOf },
711 apply: applyById,
712 applyVars: applyVars,
713 // Seasons: SEASONS, now() → the season today (or null), showing() → the season whose theme is on screen (or null)
714 seasons: { SEASONS: SEASONS, now: seasonNow, showing: function () { return _season; } },
715 // Light / Dark / Auto: get() → 'light'|'dark'|'auto', set(mode); pairOf(id) → its other half; modeOf(id)
716 scheme: { get: getScheme, set: setScheme, device: deviceMode },
717 pairOf: function (id) { return PAIRS[id] || null; },
718 modeOf: function (id) { return MODES[id] || null; },
719 onColor: onColor,
720 save: save,
721 // Try a theme on this tab without saving it (?ov-try=): start(id), stop(), current() → the id being tried, or null.
722 try: { start: function (id) { startTry(String(id || '')); }, stop: function () { stopTry(true); }, current: function () { return _trying ? _trying.id : null; } },
723 getCurrent: getCurrent,
724 // This site's id (<html data-ov-service>), the key of its theme exception on the account (4.43.0).
725 site: function () { return siteService(); },
726 resolveAndApply: resolveAndApply,
727 init: function (opts) {
728 if (opts && typeof opts.apiBase === 'string' && opts.apiBase.trim()) {
729 _config.apiBase = opts.apiBase.trim();
730 }
731 return OpenVibeThemeLoader;
732 },
733 };
734
735 if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeThemeLoader;
736 else root.OpenVibeThemeLoader = OpenVibeThemeLoader;
737
738})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
739