Enhance forest scene with mid bamboo layer, butterflies, click confetti, and demo scene polish

This commit is contained in:
Timo
2026-07-11 06:08:12 +02:00
parent 21383b8b9c
commit 04df35bbe6
12 changed files with 1060 additions and 182 deletions
+169 -8
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<title>KoalaSync</title>
<script src="theme-init.js"></script>
<style>
@font-face {
font-family: 'Twemoji Country Flags';
@@ -26,6 +27,7 @@
--accent-terracotta: oklch(0.62 0.14 45);
--danger: oklch(0.55 0.15 25);
--text-on-green: oklch(0.14 0.02 90);
--text-on-warm: oklch(0.16 0.025 45);
--bg: var(--surface-deep);
--card: var(--surface);
@@ -37,6 +39,25 @@
--error: var(--danger);
--radius: 12px;
--star: oklch(0.68 0.14 45);
color-scheme: dark;
}
html.theme-light {
--bg-base: oklch(0.965 0.012 105);
--surface: oklch(0.995 0.006 100);
--surface-deep: oklch(0.935 0.018 110);
--surface-alt: oklch(0.91 0.022 115);
--border-soft: oklch(0.28 0.035 140 / 0.10);
--border-strong: oklch(0.73 0.035 125);
--text-primary: oklch(0.21 0.03 140);
--text-secondary: oklch(0.43 0.03 135);
--accent-green: oklch(0.56 0.13 150);
--accent-green-hover: oklch(0.49 0.13 150);
--accent-terracotta: oklch(0.64 0.14 45);
--danger: oklch(0.57 0.17 25);
--text-on-green: oklch(0.16 0.03 140);
--text-on-warm: oklch(0.18 0.03 45);
color-scheme: light;
}
body {
@@ -47,6 +68,16 @@
color: var(--text);
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
background-image:
radial-gradient(circle at 12% -8%, oklch(0.68 0.13 150 / 0.12), transparent 42%),
radial-gradient(circle at 105% 12%, oklch(0.62 0.14 45 / 0.08), transparent 34%);
transition: background-color 0.25s ease, color 0.25s ease;
}
html.theme-light body {
background-image:
radial-gradient(circle at 8% -5%, oklch(0.72 0.11 145 / 0.24), transparent 40%),
radial-gradient(circle at 105% 5%, oklch(0.82 0.12 65 / 0.2), transparent 38%);
}
.header-row {
@@ -81,7 +112,7 @@
font-size: 10px;
color: var(--text-muted);
text-decoration: none;
opacity: 0.5;
opacity: 0.72;
transition: opacity 0.2s, color 0.2s;
}
.popup-version:hover {
@@ -113,7 +144,8 @@
cursor: pointer;
font-weight: 600;
border-radius: 8px;
transition: all 0.2s;
min-height: 36px;
transition: background-color 0.2s, color 0.2s, box-shadow 0.2s;
}
.tab-btn.active {
@@ -154,7 +186,7 @@
box-sizing: border-box;
background: var(--card);
border: 1px solid var(--border-strong);
color: white;
color: var(--text);
padding: 10px;
border-radius: 8px;
outline: none;
@@ -189,7 +221,7 @@
font-weight: 700;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
transition: background-color 0.2s, box-shadow 0.2s, transform 0.2s;
margin-top: 8px;
}
@@ -203,13 +235,44 @@
padding: 10px;
background: var(--border-strong);
border: none;
color: white;
color: var(--text);
font-weight: 600;
border-radius: 8px;
cursor: pointer;
margin-top: 8px;
}
button.secondary:hover {
background: oklch(0.38 0.035 125);
}
details > summary:focus-visible {
outline: 2px solid var(--accent-hover) !important;
outline-offset: 3px;
border-radius: 4px;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-hover);
outline-offset: 2px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.48;
transform: none !important;
}
.copy-success {
background: var(--success) !important;
border-color: var(--success) !important;
color: var(--text-on-green) !important;
opacity: 1 !important;
}
/* Info Cards */
.info-card {
background: var(--card);
@@ -289,7 +352,7 @@
.toast-success { background: var(--success); color: var(--text-on-green); }
.toast-error { background: var(--error); color: white; }
.toast-info { background: var(--accent); color: var(--text-on-green); }
.toast-warning { background: var(--accent-terracotta); color: white; }
.toast-warning { background: var(--accent-terracotta); color: var(--text-on-warm); }
@keyframes toastSlideIn {
from { opacity: 0; transform: translateY(-20px); }
@@ -300,6 +363,59 @@
to { opacity: 0; transform: translateY(-10px); }
}
/* Eucalyptus confetti: tiny leaves bursting from the copy-invite
button (spawned by popup.js, mirrors the website's effect). The
outer span owns the flight path — fast burst that brakes at its
peak, then sinks — while the inner leaf rocks around its stem. */
.click-leaf {
position: fixed;
z-index: 3000;
margin: -5px 0 0 -4px;
pointer-events: none;
opacity: 0;
animation: leafBurst var(--leaf-dur, 1s) forwards;
will-change: transform, opacity;
}
.click-leaf i {
display: block;
width: 7px;
height: 10px;
border-radius: 0 70% 0 70%;
background: linear-gradient(135deg, oklch(0.68 0.13 150 / 0.95), oklch(0.5 0.1 145 / 0.85));
transform: rotate(var(--leaf-rot, 0deg));
animation: leafRock var(--flutter-dur, 0.55s) ease-in-out infinite alternate;
}
.click-leaf-sage i {
background: linear-gradient(135deg, oklch(0.75 0.09 140 / 0.9), oklch(0.58 0.08 145 / 0.8));
}
.click-leaf-amber i {
background: linear-gradient(135deg, oklch(0.68 0.14 55 / 0.95), oklch(0.55 0.13 40 / 0.85));
}
@keyframes leafBurst {
0% {
opacity: 0;
transform: translate(0, 0) scale(var(--leaf-scale, 1));
animation-timing-function: cubic-bezier(0.16, 0.84, 0.44, 1);
}
10% { opacity: 1; }
45% {
opacity: 1;
transform: translate(var(--leaf-x, 0), var(--leaf-y, -14px)) scale(var(--leaf-scale, 1));
animation-timing-function: cubic-bezier(0.55, 0.06, 0.68, 0.19);
}
100% {
opacity: 0;
transform: translate(calc(var(--leaf-x, 0) * 1.4), calc(var(--leaf-y, -14px) + 30px)) scale(calc(var(--leaf-scale, 1) * 0.92));
}
}
@keyframes leafRock {
from { transform: rotate(calc(var(--leaf-rot, 0deg) - 24deg)); }
to { transform: rotate(calc(var(--leaf-rot, 0deg) + 24deg)); }
}
@media (prefers-reduced-motion: reduce) {
.click-leaf { display: none; }
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateX(5px); }
to { opacity: 1; transform: translateX(0); }
@@ -353,7 +469,7 @@
padding-top: 10px;
color: var(--text-muted);
font-size: 10px;
opacity: 0.6;
opacity: 0.78;
}
.popup-footer a {
color: var(--text-muted);
@@ -364,6 +480,42 @@
color: var(--accent);
opacity: 1;
}
/* Light theme polish: preserve the same depth hierarchy without
carrying dark-only inline colors into form controls and cards. */
html.theme-light .tabs,
html.theme-light .info-card,
html.theme-light details.form-group,
html.theme-light .form-group {
box-shadow: 0 8px 24px oklch(0.22 0.035 140 / 0.07);
}
html.theme-light input,
html.theme-light select,
html.theme-light option {
color: var(--text) !important;
background-color: var(--surface) !important;
}
html.theme-light button.secondary {
background: var(--surface-alt);
color: var(--text);
border: 1px solid var(--border-strong);
}
html.theme-light button.secondary:hover {
background: oklch(0.86 0.035 125);
}
html.theme-light .mock-card,
html.theme-light .peer-item {
border-color: var(--border-strong);
}
html.theme-light .slider:before {
background-color: oklch(0.62 0.025 125);
}
html.theme-light input:checked + .slider:before {
background-color: white;
}
html.theme-light .popup-footer {
border-top-color: var(--border-soft);
}
.feature-hint {
display: inline-block;
width: 8px;
@@ -466,7 +618,7 @@
<label title="Share this link with friends so they can join" data-i18n="LABEL_INVITE_LINK" data-i18n-title="LABEL_INVITE_LINK_TOOLTIP">Invite Link</label>
<div class="invite-box">
<input type="text" id="inviteLink" readonly>
<button id="copyInvite" class="secondary">📋</button>
<button id="copyInvite" class="secondary" title="Copy invite link" aria-label="Copy invite link">📋</button>
</div>
</div>
@@ -561,6 +713,15 @@
<!-- Settings Tab -->
<div id="tab-settings" class="tab-content">
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid var(--border-strong);">
<label id="themeLabel" style="margin-bottom: 0;" title="Follow the system theme or choose a fixed appearance">Appearance</label>
<select id="themeSelector" aria-label="Appearance" style="width: 165px; background: var(--bg); border: 1px solid var(--border-strong); color: var(--text); padding: 6px 10px; border-radius: 8px; font-size: 13px; cursor: pointer; outline: none; font-family: inherit;">
<option id="themeOptionSystem" value="system">💻 System</option>
<option id="themeOptionDark" value="dark">🌙 Dark</option>
<option id="themeOptionLight" value="light">☀️ Light</option>
</select>
</div>
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid var(--border-strong);">
<label style="margin-bottom: 0;" title="Username helps others identify you." data-i18n="LABEL_USERNAME" data-i18n-title="LABEL_USERNAME_TOOLTIP">Your Username</label>
<input type="text" id="username" data-i18n-placeholder="PLACEHOLDER_USERNAME" placeholder="Anonymous Koala" maxlength="20" style="width: 150px;">
+83 -12
View File
@@ -64,6 +64,7 @@ const elements = {
autoCopyInvite: document.getElementById('autoCopyInvite'),
cancelLobbyBtn: document.getElementById('cancelLobbyBtn'),
langSelector: document.getElementById('langSelector'),
themeSelector: document.getElementById('themeSelector'),
audioSettingsLink: document.getElementById('audioSettingsLink'),
settingsSupportLink: document.getElementById('settingsSupportLink'),
@@ -132,6 +133,29 @@ function openAudioSettingsPage() {
chrome.tabs.create({ url: chrome.runtime.getURL('audio-options.html') });
}
function localizeThemeSelector(locale) {
const labels = {
de: ['Darstellung', 'System', 'Dunkel', 'Hell'], fr: ['Apparence', 'Système', 'Sombre', 'Clair'],
es: ['Apariencia', 'Sistema', 'Oscuro', 'Claro'], it: ['Aspetto', 'Sistema', 'Scuro', 'Chiaro'],
nl: ['Weergave', 'Systeem', 'Donker', 'Licht'], pl: ['Wygląd', 'System', 'Ciemny', 'Jasny'],
pt: ['Aparência', 'Sistema', 'Escuro', 'Claro'], 'pt-BR': ['Aparência', 'Sistema', 'Escuro', 'Claro'],
tr: ['Görünüm', 'Sistem', 'Koyu', 'Açık'], ru: ['Оформление', 'Системная', 'Тёмная', 'Светлая'],
ja: ['外観', 'システム', 'ダーク', 'ライト'], ko: ['테마', '시스템', '다크', '라이트'],
zh: ['外观', '跟随系统', '深色', '浅色'], uk: ['Вигляд', 'Системна', 'Темна', 'Світла'],
en: ['Appearance', 'System', 'Dark', 'Light']
};
const [label, system, dark, light] = labels[locale] || labels.en;
const labelEl = document.getElementById('themeLabel');
const systemEl = document.getElementById('themeOptionSystem');
const darkEl = document.getElementById('themeOptionDark');
const lightEl = document.getElementById('themeOptionLight');
if (labelEl) labelEl.textContent = label;
if (systemEl) systemEl.textContent = `💻 ${system}`;
if (darkEl) darkEl.textContent = `🌙 ${dark}`;
if (lightEl) lightEl.textContent = `☀️ ${light}`;
if (elements.themeSelector) elements.themeSelector.setAttribute('aria-label', label);
}
async function updateFeatureHints() {
const data = await chrome.storage.sync.get(['dismissedHints']);
const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : [];
@@ -204,7 +228,7 @@ function setRoomRefreshCooldown() {
async function init() {
// Local-only by design — settings and room credentials never come from
// storage.sync (only onboardingComplete + dismissedHints live there).
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab']);
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode']);
let activeLang = localData.locale;
if (!activeLang) {
@@ -214,8 +238,10 @@ async function init() {
await loadLocale(activeLang);
translateDOM();
localizeThemeSelector(activeLang);
if (elements.langSelector) elements.langSelector.value = activeLang;
if (elements.themeSelector) elements.themeSelector.value = ['dark', 'light'].includes(localData.themeMode) ? localData.themeMode : 'system';
let username = localData.username;
if (!username) {
@@ -538,7 +564,7 @@ function updateLastActionUI(state, peers) {
peerItem.style.cssText = `display:flex; flex-direction:column; align-items:center; opacity: ${isAcked ? 1 : 0.6};`;
const dot = document.createElement('div');
dot.style.cssText = `width:18px; height:18px; border-radius:50%; background:${color}; color:white; display:flex; align-items:center; justify-content:center; font-size:9px; font-weight:bold; margin-bottom:1px;`;
dot.style.cssText = `width:18px; height:18px; border-radius:50%; background:${color}; color:${isAcked ? 'var(--text-on-green)' : 'var(--text-primary)'}; display:flex; align-items:center; justify-content:center; font-size:9px; font-weight:bold; margin-bottom:1px;`;
dot.textContent = icon;
const nameSpan = document.createElement('span');
@@ -630,7 +656,10 @@ function renderEmpty(container, type) {
copyBtn.title = getMessage('BTN_COPY_INVITE_TOOLTIP');
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(elements.inviteLink.value)
.then(() => showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000))
.then(() => {
showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000);
spawnLeafBurst(copyBtn);
})
.catch(() => showToast(getMessage('TOAST_COPY_FAILED'), 'error'));
});
wrapper.appendChild(copyBtn);
@@ -725,7 +754,7 @@ function updatePeerList(peers) {
// Host Control Mode: show "Solo" badge for peers watching on their own.
if (typeof p === 'object' && p.desynced) {
const solo = document.createElement('span');
solo.style.cssText = 'font-size:10px; color:#fff; background:#c96736; padding:2px 6px; border-radius:6px; font-weight:600;';
solo.style.cssText = 'font-size:10px; color:var(--text-on-warm); background:var(--accent-terracotta); padding:2px 6px; border-radius:6px; font-weight:700;';
const soloText = getMessage('BADGE_DESYNCED') || 'Solo';
solo.textContent = soloText;
solo.title = getMessage('TOOLTIP_PEER_DESYNCED') || soloText;
@@ -742,7 +771,7 @@ function updatePeerList(peers) {
const isController = !isOwner && hcmControllers.includes(pId);
if (isOwner || isController) {
const roleBadge = document.createElement('span');
roleBadge.style.cssText = 'font-size:10px; color:#fff; background:var(--accent); padding:2px 6px; border-radius:6px; font-weight:600;';
roleBadge.style.cssText = 'font-size:10px; color:var(--text-on-green); background:var(--accent); padding:2px 6px; border-radius:6px; font-weight:700;';
roleBadge.textContent = isOwner ? (getMessage('BADGE_HOST') || 'Host') : (getMessage('BADGE_CONTROLLER') || 'Controller');
rightGroup.appendChild(roleBadge);
}
@@ -755,7 +784,7 @@ function updatePeerList(peers) {
btn.title = revoke ? (getMessage('BTN_REVOKE_CONTROL') || 'Revoke') : (getMessage('BTN_GIVE_CONTROL') || 'Give control');
btn.addEventListener('mouseenter', () => {
btn.style.background = revoke ? 'var(--text-muted)' : 'var(--accent)';
btn.style.color = '#fff';
btn.style.color = 'var(--text-on-green)';
});
btn.addEventListener('mouseleave', () => {
btn.style.background = 'transparent';
@@ -1273,6 +1302,14 @@ if (elements.autoCopyInvite) {
});
}
if (elements.themeSelector) {
elements.themeSelector.addEventListener('change', () => {
const themeMode = elements.themeSelector.value;
window.koalaTheme?.setMode(themeMode);
chrome.storage.local.set({ themeMode });
});
}
if (elements.audioSettingsLink) {
elements.audioSettingsLink.addEventListener('click', async (event) => {
event.preventDefault();
@@ -1305,6 +1342,7 @@ if (elements.langSelector) {
await chrome.storage.local.set({ locale: selectedLang });
await loadLocale(selectedLang);
translateDOM();
localizeThemeSelector(selectedLang);
configureFooterLinks();
await updateFeatureHints();
@@ -1389,6 +1427,39 @@ function showToast(message, type = 'info', duration = 3000) {
setTimeout(() => toast.remove(), duration);
}
// Eucalyptus confetti: a few tiny leaves burst from the given element
// (matches the website's copy-invite moment). Subtle by design — few,
// tiny, short-lived — and skipped entirely under reduced motion.
function spawnLeafBurst(sourceEl) {
if (!sourceEl || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const rect = sourceEl.getBoundingClientRect();
if (!rect.width) return;
const originY = rect.top + rect.height / 2;
const count = 4 + Math.floor(Math.random() * 3);
for (let i = 0; i < count; i++) {
const leaf = document.createElement('span');
// Three tones of the palette: deep green, sage, a dash of amber
const tone = Math.random();
leaf.className = 'click-leaf' + (tone < 0.25 ? ' click-leaf-amber' : tone < 0.55 ? ' click-leaf-sage' : '');
// Fan out upward to a burst peak spread across the button's width;
// the keyframes brake there and let each leaf sink while it rocks.
const angle = (-90 + (Math.random() - 0.5) * 150) * Math.PI / 180;
const dist = 18 + Math.random() * 24;
const dur = 0.85 + Math.random() * 0.4;
leaf.style.left = (rect.left + 4 + Math.random() * Math.max(1, rect.width - 8)) + 'px';
leaf.style.top = (originY - rect.height * 0.2) + 'px';
leaf.style.setProperty('--leaf-x', (Math.cos(angle) * dist).toFixed(1) + 'px');
leaf.style.setProperty('--leaf-y', (Math.sin(angle) * dist).toFixed(1) + 'px');
leaf.style.setProperty('--leaf-rot', ((Math.random() - 0.5) * 160).toFixed(0) + 'deg');
leaf.style.setProperty('--leaf-scale', (0.55 + Math.random() * 0.55).toFixed(2));
leaf.style.setProperty('--leaf-dur', dur.toFixed(2) + 's');
leaf.style.setProperty('--flutter-dur', (0.35 + Math.random() * 0.3).toFixed(2) + 's');
leaf.appendChild(document.createElement('i'));
document.body.appendChild(leaf);
setTimeout(() => leaf.remove(), dur * 1000 + 150);
}
}
function showError(msg) {
if (!elements.roomError) return;
const currentToken = ++errorToken;
@@ -1775,13 +1846,12 @@ elements.copyInvite.addEventListener('click', () => {
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
const original = elements.copyInvite.textContent;
elements.copyInvite.textContent = '✓';
elements.copyInvite.style.background = 'var(--success)';
elements.copyInvite.style.color = 'white';
elements.copyInvite.classList.add('copy-success');
showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000);
spawnLeafBurst(elements.copyInvite);
setTimeout(() => {
elements.copyInvite.textContent = original;
elements.copyInvite.style.background = '';
elements.copyInvite.style.color = '';
elements.copyInvite.classList.remove('copy-success');
}, 2000);
}).catch(() => {
showToast(getMessage('TOAST_COPY_FAILED'), 'error');
@@ -1793,11 +1863,12 @@ if (elements.syncTabCopyInvite) {
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
const original = elements.syncTabCopyInvite.textContent;
elements.syncTabCopyInvite.textContent = '✓';
elements.syncTabCopyInvite.style.color = 'var(--success)';
elements.syncTabCopyInvite.classList.add('copy-success');
showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000);
spawnLeafBurst(elements.syncTabCopyInvite);
setTimeout(() => {
elements.syncTabCopyInvite.textContent = original;
elements.syncTabCopyInvite.style.color = '';
elements.syncTabCopyInvite.classList.remove('copy-success');
}, 2000);
});
});
+29
View File
@@ -0,0 +1,29 @@
(() => {
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
let mode = 'system';
const normalize = value => ['system', 'dark', 'light'].includes(value) ? value : 'system';
const apply = value => {
mode = normalize(value);
const light = mode === 'light' || (mode === 'system' && systemTheme.matches);
document.documentElement.classList.toggle('theme-light', light);
document.documentElement.dataset.theme = light ? 'light' : 'dark';
document.documentElement.style.colorScheme = light ? 'light' : 'dark';
};
apply('system');
window.koalaTheme = {
getMode: () => mode,
setMode: apply
};
systemTheme.addEventListener('change', () => {
if (mode === 'system') apply('system');
});
if (!globalThis.chrome?.storage?.local) return;
chrome.storage.local.get(['themeMode'], data => apply(data.themeMode));
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.themeMode) apply(changes.themeMode.newValue);
});
})();