openvibers/openvibe.shared theme-loader.js
theme-loader.js
| 1 | // OpenVibe — Theme Loader (browser): the person's look on <html> before first paint. Load it before navbar.js. |
| 2 | |
| 3 | (function (root) { |
| 4 | ; |
| 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 |