mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Merge pull request #28 from Shik3i/redesign/koala-nature-theme
Redesign the website and extension with the Koala nature theme
This commit is contained in:
@@ -114,17 +114,13 @@ jobs:
|
||||
sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md
|
||||
echo " ✓ README.md -> v$VERSION"
|
||||
|
||||
# 7. website/sitemap.xml — lastmod dates
|
||||
sed -i "s/<lastmod>[0-9-]*<\/lastmod>/<lastmod>$(date +%Y-%m-%d)<\/lastmod>/g" website/sitemap.xml
|
||||
echo " ✓ website/sitemap.xml -> lastmod $(date +%Y-%m-%d)"
|
||||
|
||||
echo "Version injection complete."
|
||||
|
||||
- name: Commit and push version updates back to main
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md website/sitemap.xml
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md
|
||||
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
|
||||
git push origin HEAD:main
|
||||
env:
|
||||
|
||||
@@ -43,6 +43,7 @@ extension/shared/
|
||||
|
||||
# Auto-generated website build output
|
||||
website/www/
|
||||
website/.avif-cache.json
|
||||
|
||||
# Temporary scratch files
|
||||
scratch/
|
||||
|
||||
@@ -16,7 +16,7 @@ Ready-to-paste copy for product listings, launch pages, directory submissions, a
|
||||
|
||||
## 2. Three-Sentence Overview
|
||||
|
||||
> KoalaSync is a lightweight browser extension that keeps you and your friends perfectly in sync on YouTube, Netflix, Twitch, Prime Video, Jellyfin, Emby, and almost any other site with an HTML5 video player — press play once and everyone stays together. It's built privacy-first: no accounts, no telemetry, and the official relay server runs entirely in volatile RAM with zero persistence, so nothing about your sessions is ever stored. Open source under the MIT license and fully self-hostable with a single Docker command, KoalaSync is a transparent watch-party tool that works everywhere and respects your data sovereignty.
|
||||
> KoalaSync is a lightweight browser extension that keeps you and your friends perfectly in sync on YouTube, Netflix, Twitch, Prime Video, Jellyfin, Emby, and almost any other site with an HTML5 video player — press play once and everyone stays together. It's built privacy-first: no accounts, no telemetry, and the official relay server runs entirely in volatile RAM with zero persistence, so nothing about your sessions is ever stored. Open source under the MIT license and fully self-hostable with a single Docker Compose command, KoalaSync is a transparent watch-party tool that works everywhere and respects your data sovereignty.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +38,7 @@ Most watch-party tools fall into one of two traps: they only work on a short all
|
||||
|
||||
### Built for people who actually want to read the code
|
||||
|
||||
KoalaSync is MIT-licensed open source, built by a solo developer. Audit it, fork it, change it. The extension is dependency-free with a direct Socket.IO wire implementation — no opaque libraries, no framework bloat, no surprise third-party SDKs. Want full sovereignty? Self-host your own relay with a single Docker command and keep all watch-party coordination traffic inside your own infrastructure. The official public relay is there when you don't care, self-hosting is there when you do.
|
||||
KoalaSync is MIT-licensed open source, built by a solo developer. Audit it, fork it, change it. The extension is dependency-free with a direct Socket.IO wire implementation — no opaque libraries, no framework bloat, no surprise third-party SDKs. Want full sovereignty? Set a unique `SERVER_SALT` in the supplied Compose file, then start your own relay with a single Docker Compose command and keep all watch-party coordination traffic inside your own infrastructure. The official public relay is there when you don't care, self-hosting is there when you do.
|
||||
|
||||
### Little touches you'll notice
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
KoalaSync: Private Watch Parties for Emby, Jellyfin, Plex, Netflix & YouTube
|
||||
|
||||
Tired of counting down "3, 2, 1, Play" over voice chat? KoalaSync keeps you and your friends perfectly in sync. Whether you are streaming from your own self-hosted media server like Emby, Jellyfin or Plex, or watching on a major platform like Netflix, Prime Video or YouTube — KoalaSync is designed for smooth, browser-based watch parties.
|
||||
Tired of counting down "3, 2, 1, Play" over voice chat? KoalaSync keeps you and your friends perfectly in sync. Whether you are streaming from your own self-hosted media server like Emby, Jellyfin or Plex, or watching on a major platform like Netflix, Prime Video or YouTube, KoalaSync is designed for smooth, browser-based watch parties.
|
||||
|
||||
|
||||
✨ CORE FEATURES
|
||||
@@ -12,7 +12,7 @@ No account required. No tracking. Just create a room, invite your friends, and s
|
||||
• Smart Binge-Watching: When a new episode loads, KoalaSync automatically pauses the lobby until everyone is ready. No spoilers, no one left behind.
|
||||
• Smart Audio Compressor: Tired of quiet dialogue and suddenly loud action scenes? Balance whispering, explosions, and music with a single click while you watch.
|
||||
• One-Click Invites: Send a smart invite link to your friends. When they open it, KoalaSync automatically configures the room so they can join instantly.
|
||||
• 13 Languages: Enjoy a native experience with a fully translated user interface.
|
||||
• 15 Languages: Enjoy a native experience in English, German, French, Spanish, Brazilian and European Portuguese, Russian, Italian, Polish, Turkish, Dutch, Japanese, Korean, Simplified Chinese, and Ukrainian.
|
||||
|
||||
|
||||
|
||||
@@ -33,14 +33,14 @@ KoalaSync is built for private watch parties without unnecessary data collection
|
||||
3. Share the invite link with your friends.
|
||||
4. Open your favorite streaming site or media server.
|
||||
5. Select the active video tab.
|
||||
6. Press play — everyone stays perfectly in sync.
|
||||
6. Press play. Everyone stays perfectly in sync.
|
||||
|
||||
|
||||
|
||||
⚙️ UNDER THE HOOD
|
||||
KoalaSync is lightweight, transparent, and built with privacy in mind.
|
||||
|
||||
• On-Demand Relay: Playback state is synchronized through a custom WebSocket-based relay server. No persistent connection — the relay is only active while you're in a room. No background traffic, no idle connections.
|
||||
• On-Demand Relay: Playback state is synchronized through a custom WebSocket-based relay server. There is no permanent background connection. The relay connection is maintained only while you're in a room and closes when you leave or automatically after two hours without a selected-video heartbeat.
|
||||
• No Media Streaming: KoalaSync does not stream, proxy, upload, download, or redistribute any video content. Everyone watches from their own browser on the original website.
|
||||
• Temporary Room State Only: The relay server only coordinates room state such as play, pause, seek position, active target, nickname, and readiness status.
|
||||
• Docker Self-Hosting: The relay server can be self-hosted with Docker if you prefer to run your own private instance.
|
||||
|
||||
+7
-13
@@ -21,8 +21,8 @@ KoalaSync is a specialized tool for **synchronized video playback** across multi
|
||||
- `extension/`: Browser Extension (Chrome & Firefox, Manifest V3). Contains background service worker, content scripts, and popup UI.
|
||||
- `server/`: Node.js Relay Server using Socket.IO (WebSocket-only).
|
||||
- `website/`: **Landing Page** & Invitation Bridge (Marketing, Tutorials, and Downloads).
|
||||
- **`build.cjs`**: Zero-dependency static site compiler. Translates `template.html` + `locales/*.json` → `www/`. Also minifies CSS/JS automatically.
|
||||
- **`www/` is auto-generated**: Never edit files in `www/` directly. Always edit source files (`template.html`, `style.css`, `app.js`, `lang-init.js`, `locales/*.json`) and run `node website/build.cjs` to regenerate. CSS/JS are output as `.min.*` files — a built-in cleanup step removes stale artifacts on each build.
|
||||
- **`build.cjs`**: Static site compiler using the repository's build dependencies. Translates `template.html` + `locales/*.json` → `www/`, generates the sitemap, and minifies HTML/CSS/JS.
|
||||
- **`www/` is auto-generated**: Never edit files in `www/` directly. Always edit source files (`template.html`, `style.css`, `styles/*.css`, `app.js`, `lang-init.js`, `locales/*.json`) and run `node website/build.cjs` to regenerate. `style.css` is the development manifest; `style.legacy.css` is an unloaded, byte-identical migration reference. Production creates page-specific and render-priority CSS bundles from `styles/*.css`; CSS/JS are output as `.min.*` files and stale generated assets are removed on each build.
|
||||
- `shared/`: **Single Source of Truth** for protocol constants, event names, blacklist data, and generated usernames.
|
||||
- `scripts/`: Development utilities (e.g., `build-extension.cjs`).
|
||||
- `docker-compose.yml`: Root-level orchestration for the relay server.
|
||||
@@ -64,14 +64,8 @@ The popup UI follows a strict design system. Do not modify these variables or th
|
||||
- **Font**: System font stack. **MANDATORY**: No external CDNs or Google Fonts to ensure 100% privacy.
|
||||
- **Popup Width**: Fixed at `320px`.
|
||||
- **Tab Structure**: Must maintain the visible **Room**, **Sync**, **Settings**, and **Status** tabs. The hidden **Dev** tab is available only for developer diagnostics.
|
||||
- **CSS Variables**:
|
||||
| Variable | Value | Purpose |
|
||||
| :--- | :--- | :--- |
|
||||
| `--bg` | `#0f172a` | Main background |
|
||||
| `--card` | `#1e293b` | Form and info cards |
|
||||
| `--accent` | `#6366f1` | Primary actions and branding |
|
||||
| `--success` | `#22c55e` | Success states / Online dot |
|
||||
| `--error` | `#ef4444` | Errors / Offline dot |
|
||||
- **Appearance modes**: Preserve `system`, `light`, and `dark` behavior and the Eucalyptus, Cyber, and Graphite palettes.
|
||||
- **CSS variables**: Use the semantic tokens defined in `popup.html` (`--bg`, `--card`, `--accent`, `--text`, `--text-muted`, `--success`, `--warning`, and `--error`). Do not hard-code the retired indigo/slate palette or bypass palette-specific token overrides.
|
||||
|
||||
## 7. Non-Negotiables (Core Logic)
|
||||
The following features are critical and must not be removed or fundamentally altered:
|
||||
@@ -149,11 +143,11 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
|
||||
3. Implement the handler in `server/index.js` and `background.js`.
|
||||
|
||||
### Making Website Changes
|
||||
1. Edit source files in `website/` (`template.html`, `style.css`, `app.js`, `lang-init.js`, or `locales/*.json`).
|
||||
1. Edit source files in `website/` (`template.html`, `style.css`, `styles/*.css`, `app.js`, `lang-init.js`, or `locales/*.json`).
|
||||
2. Run the compiler: `node website/build.cjs`. This generates the multilingual pages in `www/` and minifies CSS/JS.
|
||||
3. Verify the output: `node --check website/www/app.min.js && node --check website/www/lang-init.min.js`.
|
||||
3. Verify the sources and generated contract: `node --check website/app.js`, `node --check website/lang-init.js`, `node scripts/test-website-locales.mjs`, and `node scripts/test-website-theme.mjs`.
|
||||
4. Test locally: `npx serve website/www` or `python3 -m http.server 8080 -d website/www`.
|
||||
5. Commit both source changes and the updated `www/` output.
|
||||
5. Commit the source changes only. `website/www/` is generated and gitignored.
|
||||
|
||||
### Testing Locally
|
||||
1. Run the build script: `node scripts/build-extension.cjs`.
|
||||
|
||||
+21
-2
@@ -1,10 +1,29 @@
|
||||
# KoalaSync Changelog
|
||||
|
||||
All notable changes to the KoalaSync browser extension and relay server.
|
||||
All notable changes to the KoalaSync browser extension, relay server, website, and release tooling.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
## [v2.6.0] — Unreleased
|
||||
|
||||
### Added
|
||||
- **Website: Expanded comparison guides** — Added fully localized comparison pages for Kosmi, Scener, and TwoSeven, plus improved existing alternatives pages and navigation.
|
||||
- **Website: AI-readable product profile** — Expanded `llms.txt` with fit guidance, limitations, architecture, privacy, installation, self-hosting, and support information, and linked it from every localized landing-page head.
|
||||
- **Website: Accessibility regression coverage** — Added automated checks for light-theme step numbers, legal-link cues, heading hierarchy, readable self-hosting surfaces, and reduced-motion behavior.
|
||||
- **Extension: Appearance controls** — Added localized system, light, and dark theme options with an early theme initializer to avoid flashes during popup startup.
|
||||
|
||||
### Changed
|
||||
- **Website: Koala nature redesign** — Rebuilt the landing page around a responsive day/night forest theme with richer motion, an improved interactive demo, clearer onboarding, polished legal/join/error pages, and stronger mobile behavior.
|
||||
- **Extension: Popup and settings redesign** — Unified controls, status surfaces, colors, icons, badges, and accessibility behavior; settings are now organized into mutually exclusive accordion groups.
|
||||
- **Website: Faster rendering and builds** — Split page CSS by responsibility, removed startup forced reflows, added responsive mascot assets, cached AVIF generation, subset the flag font, minified generated HTML, and added integrity metadata for scripts and styles.
|
||||
- **Website: SEO and localization** — Added richer structured metadata, AI-crawler guidance, dynamic sitemap generation, and complete translations for the expanded alternatives content.
|
||||
- **Website: Safer self-hosting example** — Added an explicit `SERVER_SALT` replacement value, optional admin-metrics configuration, loopback-only relay publishing, and consistent host-Caddy proxy targets without requiring a separate `.env` file.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Episode Lobby reliability** — Prevented the lobby from remaining stuck in a loading state and stopped episode transitions from triggering on non-episodic media.
|
||||
- **Extension: Tab-title normalization** — Notification counters such as `(14)`, `[7]`, and `(99+)` are removed reliably without stripping legitimate large numeric titles.
|
||||
- **Website and extension: Theme contrast and navigation** — Fixed light-theme contrast, language highlighting, mobile join navigation, and several control-label and tooltip inconsistencies.
|
||||
- **Website: Build and accessibility correctness** — Replaced the regex HTML minifier with conservative parser-based `htmlnano`, stopped tracking generated sitemap output, restored readable light-theme step numbers, fixed legal-page and comparison-card contrast, repaired heading order, and disabled all film motion when reduced motion is requested.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,8 +18,9 @@ KoalaSync is a **privacy-preserving watch-party sync tool**, not a security prod
|
||||
|
||||
- **Rooms are ephemeral.** They exist for a few hours and are auto-reaped. There are no
|
||||
accounts, no persistent storage, no money, and no sensitive content on the relay.
|
||||
- **The relay is a dumb, stateless message bus.** It forwards play/pause/seek between
|
||||
peers who *chose* to watch together and joined via an invite link shared out-of-band.
|
||||
- **The relay is a zero-persistence, in-memory message bus.** It temporarily holds room
|
||||
and peer state while forwarding play/pause/seek between peers who *chose* to watch
|
||||
together and joined via an invite link shared out-of-band.
|
||||
- **Participants are invited.** Anyone in a room was let in. The social contract is
|
||||
"we're watching a video together," not "mutually distrusting parties."
|
||||
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
# # Clean URLs support (resolves /join to join.html, etc.)
|
||||
# try_files {path} {path}.html {path}/
|
||||
# file_server
|
||||
#
|
||||
# # Serve the themed 404 page for unknown URLs (without this block,
|
||||
# # Caddy returns an empty 404 response)
|
||||
# handle_errors {
|
||||
# @notfound expression {err.status_code} == 404
|
||||
# rewrite @notfound /404.html
|
||||
# file_server
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# syncserver.koalastuff.net {
|
||||
@@ -57,6 +65,14 @@ sync.koalastuff.net {
|
||||
try_files {path} {path}.html {path}/
|
||||
file_server
|
||||
|
||||
# Serve the themed 404 page for unknown URLs (without this block,
|
||||
# Caddy returns an empty 404 response)
|
||||
handle_errors {
|
||||
@notfound expression {err.status_code} == 404
|
||||
rewrite @notfound /404.html
|
||||
file_server
|
||||
}
|
||||
|
||||
# Static Caching for high-performance PageSpeed (1 year with validation)
|
||||
@static {
|
||||
file
|
||||
|
||||
+54
-17
@@ -1,13 +1,46 @@
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
--panel: #172033;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--text: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--border: #334155;
|
||||
/* KoalaSync nature system: forest neutrals, eucalyptus brand, warm supporting accents. */
|
||||
--bg-base: oklch(0.23 0.028 155);
|
||||
--surface: oklch(0.30 0.035 150);
|
||||
--surface-deep: oklch(0.205 0.025 155);
|
||||
--surface-alt: oklch(0.265 0.032 150);
|
||||
--border-soft: oklch(0.88 0.025 145 / 0.09);
|
||||
--border-strong: oklch(0.39 0.038 145);
|
||||
--text-primary: oklch(0.96 0.01 90);
|
||||
--text-secondary: oklch(0.78 0.02 90);
|
||||
--accent-green: oklch(0.73 0.13 155);
|
||||
--accent-green-hover: oklch(0.79 0.12 155);
|
||||
--accent-terracotta: oklch(0.62 0.14 45);
|
||||
--success-green: oklch(0.72 0.14 135);
|
||||
--warning-amber: oklch(0.76 0.14 80);
|
||||
--danger: oklch(0.55 0.15 25);
|
||||
--text-on-green: oklch(0.14 0.02 90);
|
||||
|
||||
--bg: var(--bg-base);
|
||||
--card: var(--surface);
|
||||
--panel: var(--surface-alt);
|
||||
--accent: var(--accent-green);
|
||||
--accent-hover: var(--accent-green-hover);
|
||||
--text: var(--text-primary);
|
||||
--text-muted: var(--text-secondary);
|
||||
--border: var(--border-strong);
|
||||
--radius: 8px;
|
||||
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.52 0.13 155);
|
||||
--accent-green-hover: oklch(0.45 0.12 155);
|
||||
--text-on-green: oklch(0.98 0.005 100);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -55,7 +88,7 @@ body {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -83,6 +116,10 @@ h2 {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
html.theme-light .panel {
|
||||
box-shadow: 0 12px 30px oklch(0.22 0.035 140 / 0.09);
|
||||
}
|
||||
|
||||
.muted-panel {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -101,7 +138,7 @@ h2 {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.preset-group {
|
||||
@@ -125,12 +162,12 @@ h2 {
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.preset-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
border-color: color-mix(in oklch, var(--accent), transparent 60%);
|
||||
}
|
||||
|
||||
.preset-card:has(input:checked) {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.35);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent), transparent 65%);
|
||||
}
|
||||
|
||||
.preset-card input {
|
||||
@@ -141,8 +178,8 @@ h2 {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: var(--surface-deep);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
@@ -158,7 +195,7 @@ h2 {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
@@ -204,7 +241,7 @@ input[type="number"]:focus {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #334155;
|
||||
background-color: var(--border-strong);
|
||||
transition: .3s;
|
||||
border-radius: 22px;
|
||||
}
|
||||
@@ -216,7 +253,7 @@ input[type="number"]:focus {
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: #94a3b8;
|
||||
background-color: var(--text-secondary);
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>KoalaSync Audio Settings</title>
|
||||
<script src="theme-init.js"></script>
|
||||
<link rel="stylesheet" href="audio-options.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+26
-4
@@ -1,7 +1,7 @@
|
||||
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
|
||||
import { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode } from './episode-utils.js';
|
||||
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
||||
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
|
||||
import { initTabManager } from './modules/tab-manager.js';
|
||||
import './page-api-seek-overrides.js';
|
||||
@@ -790,13 +790,13 @@ function updateBadgeStatus() {
|
||||
|
||||
if (status === 'reconnecting') {
|
||||
chrome.action.setBadgeText({ text: '...' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#f59e0b' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#c96736' });
|
||||
} else if (status === 'connecting') {
|
||||
chrome.action.setBadgeText({ text: '...' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#fbbf24' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#de7949' });
|
||||
} else if (status === 'connected' && currentRoom && currentTabId) {
|
||||
chrome.action.setBadgeText({ text: 'ON' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#56ae6c' });
|
||||
} else {
|
||||
chrome.action.setBadgeText({ text: '' });
|
||||
}
|
||||
@@ -1325,6 +1325,9 @@ function handleServerEvent(event, data) {
|
||||
}
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
if (episodeLobby) {
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2476,6 +2479,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
|
||||
const newTitle = message.payload && message.payload.newTitle;
|
||||
if (newTitle && extractEpisodeId(newTitle) === null) {
|
||||
addLog(`Episode change detected ("${newTitle}") but no episode ID was found; ignoring.`, 'info');
|
||||
sendResponse({ status: 'not_an_episode' });
|
||||
return;
|
||||
}
|
||||
if (!newTitle) {
|
||||
sendResponse({ status: 'no_title' });
|
||||
return;
|
||||
@@ -2566,6 +2574,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
sendResponse({ status: 'lobby_created' });
|
||||
} else if (message.type === 'EPISODE_READY_LOCAL') {
|
||||
if (sender.tab) {
|
||||
const senderTabId = sender.tab.id;
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Content script confirmed it loaded the lobby episode
|
||||
if (episodeLobby && message.payload && sameEpisode(message.payload.title, episodeLobby.expectedTitle)) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
@@ -2604,6 +2619,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CONTENT_BOOT') {
|
||||
if (sender.tab) {
|
||||
const senderTabId = sender.tab.id;
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Content script re-injected, check if there's an active lobby
|
||||
if (episodeLobby) {
|
||||
sendResponse({ lobbyActive: true, expectedTitle: episodeLobby.expectedTitle });
|
||||
|
||||
+12
-7
@@ -424,13 +424,13 @@
|
||||
|
||||
if (video.seeking) return 'involuntary';
|
||||
|
||||
if (Date.now() < hcmBufferingUntil) return 'involuntary';
|
||||
if (Date.now() < hcmBufferingUntil) return 'involuntary';
|
||||
|
||||
if (Date.now() < visibilityGraceUntil) return 'involuntary';
|
||||
|
||||
|
||||
if (Date.now() - hcmLastUserGestureAt > HCM_USER_GESTURE_MS) return 'involuntary';
|
||||
|
||||
return 'deliberate';
|
||||
|
||||
return 'deliberate';
|
||||
|
||||
}
|
||||
|
||||
@@ -494,7 +494,7 @@
|
||||
|
||||
if (target && target.playbackState === 'paused') {
|
||||
|
||||
tryMediaAction(EVENTS.PAUSE);
|
||||
tryMediaAction(EVENTS.PAUSE);
|
||||
|
||||
} else if (target && target.playbackState === 'playing') {
|
||||
|
||||
@@ -758,7 +758,8 @@
|
||||
|
||||
let settled = false;
|
||||
|
||||
// Re-query the host's current position on click instead of using the
|
||||
// Re-query the host's current position on click instead of using the
|
||||
|
||||
// potentially stale target captured at HOST_BLOCKED time (M-1).
|
||||
|
||||
const stay = () => {
|
||||
@@ -798,7 +799,11 @@
|
||||
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: true }).catch(() => {});
|
||||
|
||||
hcmShowBadge();
|
||||
hcmShowBadge();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function hcmExitDesync() {
|
||||
|
||||
const wasDesynced = hcmDesynced;
|
||||
|
||||
@@ -12,6 +12,9 @@ let currentLanguage = null;
|
||||
*/
|
||||
export async function loadLocale(langCode) {
|
||||
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = resolvedLang;
|
||||
}
|
||||
|
||||
if (currentLanguage === resolvedLang && Object.keys(activeDictionary).length > 0) {
|
||||
return;
|
||||
@@ -90,6 +93,8 @@ export function getMessage(key, placeholders = null) {
|
||||
* Performs dynamic DOM replacements for elements carrying data-i18n attributes.
|
||||
*/
|
||||
export function translateDOM() {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
// 1. Text Content
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path d="M3 2.5h4v11H3zm6 0h4v11H9z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 109 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path d="M4 2.6v10.8L13 8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 100 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path d="M13.4 5.5A5.8 5.8 0 0 0 3 4.2L1.7 2.9v4.4h4.4L4.4 5.6a4 4 0 0 1 7.2.8zM2.6 10.5A5.8 5.8 0 0 0 13 11.8l1.3 1.3V8.7H9.9l1.7 1.7a4 4 0 0 1-7.2-.8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Fernsteuerung",
|
||||
"BTN_COPY_INVITE": "📋 Einladungslink",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Einladungslink kopieren",
|
||||
"BTN_PLAY": "▶ Abspielen",
|
||||
"BTN_PLAY": "Abspielen",
|
||||
"BTN_PLAY_TOOLTIP": "Sende einen Play-Befehl an alle",
|
||||
"BTN_PAUSE": "⏸ Pause",
|
||||
"BTN_PAUSE": "Pause",
|
||||
"BTN_PAUSE_TOOLTIP": "Sende einen Pause-Befehl an alle",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Erzwinge die Synchronisation aller Teilnehmer",
|
||||
"OPTION_JUMP_TO_OTHERS": "Zu anderen springen",
|
||||
"OPTION_JUMP_TO_ME": "Zu mir springen",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Wendet Audioeffekte wie Kompression auf die Videowiedergabe an",
|
||||
"AUDIO_OPEN_SETTINGS": "Öffnen",
|
||||
"NEW_FEATURE_AUDIO": "Neu: Audio-Verarbeitung — probiere den Kompressor aus!",
|
||||
"NEW_FEATURE_THEME": "Neu: Themes — wechsle zum klassischen Look oder minimalem Graphit.",
|
||||
"AUDIO_BACK": "← Zurück",
|
||||
"AUDIO_PAGE_TITLE": "Audio-Einstellungen",
|
||||
"AUDIO_MASTER_TOGGLE": "Audio-Verarbeitung",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Tab-Titel senden",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Sendet den Titel des ausgewählten Browser-Tabs an den Raum.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Medientitel",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Lege fest, wie Medientitel an den Raum gesendet werden."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Lege fest, wie Medientitel an den Raum gesendet werden.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profil & Aussehen",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Ändere deinen Benutzernamen, dein Design und deine Spracheinstellungen.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Wiedergabe & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Remote Control",
|
||||
"BTN_COPY_INVITE": "📋 Invite Link",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copy Invite Link",
|
||||
"BTN_PLAY": "▶ Play",
|
||||
"BTN_PLAY": "Play",
|
||||
"BTN_PLAY_TOOLTIP": "Send a Play command to everyone",
|
||||
"BTN_PAUSE": "⏸ Pause",
|
||||
"BTN_PAUSE": "Pause",
|
||||
"BTN_PAUSE_TOOLTIP": "Send a Pause command to everyone",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Force all users to sync up",
|
||||
"OPTION_JUMP_TO_OTHERS": "Jump to Others",
|
||||
"OPTION_JUMP_TO_ME": "Jump to Me",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
|
||||
"AUDIO_OPEN_SETTINGS": "Open",
|
||||
"NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
|
||||
"NEW_FEATURE_THEME": "New: Themes — switch to the classic look or minimal graphite.",
|
||||
"AUDIO_BACK": "← Back",
|
||||
"AUDIO_PAGE_TITLE": "Audio Settings",
|
||||
"AUDIO_MASTER_TOGGLE": "Audio Processing",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Send tab title",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Send the selected browser tab title to the room.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Media title",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choose how media titles are sent to the room."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choose how media titles are sent to the room.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profile & Appearance",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Change your username, theme, and language preferences.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Playback & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Control remoto",
|
||||
"BTN_COPY_INVITE": "📋 Enlace de invitación",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar enlace de invitación",
|
||||
"BTN_PLAY": "▶ Reproducir",
|
||||
"BTN_PLAY": "Reproducir",
|
||||
"BTN_PLAY_TOOLTIP": "Enviar un comando de reproducción a todos",
|
||||
"BTN_PAUSE": "⏸ Pausar",
|
||||
"BTN_PAUSE": "Pausar",
|
||||
"BTN_PAUSE_TOOLTIP": "Enviar un comando de pausa a todos",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Forzar la sincronización de todos los usuarios",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir a la posición de otros",
|
||||
"OPTION_JUMP_TO_ME": "Traerlos a mi posición",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efectos de audio como compresión a la reproducción de video",
|
||||
"AUDIO_OPEN_SETTINGS": "Abrir",
|
||||
"NEW_FEATURE_AUDIO": "Nuevo: Procesamiento de audio — ¡prueba el compresor!",
|
||||
"NEW_FEATURE_THEME": "Nuevo: Temas — cambia al aspecto clásico o al grafito minimalista.",
|
||||
"AUDIO_BACK": "← Volver",
|
||||
"AUDIO_PAGE_TITLE": "Configuración de audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Procesamiento de audio",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Enviar título de pestaña",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envía a la sala el título de la pestaña del navegador seleccionada.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Título multimedia",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Elige cómo se envían los títulos multimedia a la sala."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Elige cómo se envían los títulos multimedia a la sala.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Perfil y Apariencia",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Cambia tu nombre de usuario, tema y preferencias de idioma.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Reproducción y Sincronización",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Contrôle à distance",
|
||||
"BTN_COPY_INVITE": "📋 Lien d'invitation",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copier le lien d'invitation",
|
||||
"BTN_PLAY": "▶ Lecture",
|
||||
"BTN_PLAY": "Lecture",
|
||||
"BTN_PLAY_TOOLTIP": "Envoyer une commande Lecture à tout le monde",
|
||||
"BTN_PAUSE": "⏸ Pause",
|
||||
"BTN_PAUSE": "Pause",
|
||||
"BTN_PAUSE_TOOLTIP": "Envoyer une commande Pause à tout le monde",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Forcer tous les utilisateurs à se synchroniser",
|
||||
"OPTION_JUMP_TO_OTHERS": "Rejoindre les autres",
|
||||
"OPTION_JUMP_TO_ME": "Les amener à moi",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applique des effets audio comme la compression à la lecture vidéo",
|
||||
"AUDIO_OPEN_SETTINGS": "Ouvrir",
|
||||
"NEW_FEATURE_AUDIO": "Nouveau : Traitement audio — essayez le compresseur !",
|
||||
"NEW_FEATURE_THEME": "Nouveau : Thèmes — passez au look classique ou au graphite minimal.",
|
||||
"AUDIO_BACK": "← Retour",
|
||||
"AUDIO_PAGE_TITLE": "Paramètres audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Traitement audio",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Envoyer le titre de l’onglet",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envoie au salon le titre de l’onglet de navigateur sélectionné.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Titre du média",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choisissez comment les titres des médias sont envoyés au salon."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choisissez comment les titres des médias sont envoyés au salon.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profil & Apparence",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifiez votre nom d'utilisateur, votre thème et vos préférences linguistiques.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Lecture & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Telecomando",
|
||||
"BTN_COPY_INVITE": "📋 Link di Invito",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copia il link di invito",
|
||||
"BTN_PLAY": "▶ Riproduci",
|
||||
"BTN_PLAY": "Riproduci",
|
||||
"BTN_PLAY_TOOLTIP": "Avvia la riproduzione per tutti",
|
||||
"BTN_PAUSE": "⏸ Pausa",
|
||||
"BTN_PAUSE": "Pausa",
|
||||
"BTN_PAUSE_TOOLTIP": "Metti in pausa per tutti",
|
||||
"BTN_SYNC": "⚡ SINCRO",
|
||||
"BTN_SYNC": "Sincro",
|
||||
"BTN_SYNC_TOOLTIP": "Forza la sincronizzazione per tutti",
|
||||
"OPTION_JUMP_TO_OTHERS": "Vai alla posizione degli altri",
|
||||
"OPTION_JUMP_TO_ME": "Portali alla mia posizione",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applica effetti sonori (come il compressore) ai video",
|
||||
"AUDIO_OPEN_SETTINGS": "Apri",
|
||||
"NEW_FEATURE_AUDIO": "Novità: Elaborazione Audio — prova il compressore!",
|
||||
"NEW_FEATURE_THEME": "Nuovo: Temi — passa all'aspetto classico o al grafite minimale.",
|
||||
"AUDIO_BACK": "← Indietro",
|
||||
"AUDIO_PAGE_TITLE": "Impostazioni Audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Elaborazione Audio",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Invia titolo scheda",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Invia alla stanza il titolo della scheda del browser selezionata.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Titolo multimediale",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Scegli come inviare alla stanza i titoli multimediali."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Scegli come inviare alla stanza i titoli multimediali.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profilo & Aspetto",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifica il tuo nome utente, tema e preferenze di lingua.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Riproduzione & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "リモートコントロール",
|
||||
"BTN_COPY_INVITE": "📋 招待リンク",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "招待リンクをコピー",
|
||||
"BTN_PLAY": "▶ 再生",
|
||||
"BTN_PLAY": "再生",
|
||||
"BTN_PLAY_TOOLTIP": "全員に再生コマンドを送信",
|
||||
"BTN_PAUSE": "⏸ 一時停止",
|
||||
"BTN_PAUSE": "一時停止",
|
||||
"BTN_PAUSE_TOOLTIP": "全員に一時停止コマンドを送信",
|
||||
"BTN_SYNC": "⚡ 同期",
|
||||
"BTN_SYNC": "同期",
|
||||
"BTN_SYNC_TOOLTIP": "すべてのユーザーを強制的に同期",
|
||||
"OPTION_JUMP_TO_OTHERS": "他の人に合わせる",
|
||||
"OPTION_JUMP_TO_ME": "自分に合わせる",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "動画再生に圧縮などのオーディオエフェクトを適用します",
|
||||
"AUDIO_OPEN_SETTINGS": "開く",
|
||||
"NEW_FEATURE_AUDIO": "新機能: オーディオ処理 — コンプレッサーを試してみよう!",
|
||||
"NEW_FEATURE_THEME": "新機能:テーマ — クラシックな外観やミニマルなグラファイトに切り替え。",
|
||||
"AUDIO_BACK": "← 戻る",
|
||||
"AUDIO_PAGE_TITLE": "オーディオ設定",
|
||||
"AUDIO_MASTER_TOGGLE": "オーディオ処理",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "タブタイトルを送信",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "選択中のブラウザータブのタイトルをルームに送信します。",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "メディアタイトル",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "メディアタイトルをルームに送信する方法を選択します。"
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "メディアタイトルをルームに送信する方法を選択します。",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "プロフィールと外観",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "再生と同期",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。"
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "원격 제어",
|
||||
"BTN_COPY_INVITE": "📋 초대 링크",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "초대 링크 복사",
|
||||
"BTN_PLAY": "▶ 재생",
|
||||
"BTN_PLAY": "재생",
|
||||
"BTN_PLAY_TOOLTIP": "모든 사람에게 재생 명령 전송",
|
||||
"BTN_PAUSE": "⏸ 일시정지",
|
||||
"BTN_PAUSE": "일시정지",
|
||||
"BTN_PAUSE_TOOLTIP": "모든 사람에게 일시정지 명령 전송",
|
||||
"BTN_SYNC": "⚡ 동기화",
|
||||
"BTN_SYNC": "동기화",
|
||||
"BTN_SYNC_TOOLTIP": "모든 사용자의 강제 동기화",
|
||||
"OPTION_JUMP_TO_OTHERS": "다른 사람에게 이동",
|
||||
"OPTION_JUMP_TO_ME": "나에게 이동",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "비디오 재생에 압축과 같은 오디오 효과를 적용합니다",
|
||||
"AUDIO_OPEN_SETTINGS": "열기",
|
||||
"NEW_FEATURE_AUDIO": "신규: 오디오 처리 — 컴프레서를 사용해보세요!",
|
||||
"NEW_FEATURE_THEME": "신규: 테마 — 클래식 룩이나 미니멀 그래파이트로 전환하세요.",
|
||||
"AUDIO_BACK": "← 뒤로",
|
||||
"AUDIO_PAGE_TITLE": "오디오 설정",
|
||||
"AUDIO_MASTER_TOGGLE": "오디오 처리",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "탭 제목 보내기",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "선택한 브라우저 탭 제목을 방에 보냅니다.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "미디어 제목",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "미디어 제목을 방에 보내는 방식을 선택합니다."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "미디어 제목을 방에 보내는 방식을 선택합니다.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "프로필 및 테마",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Afstandsbediening",
|
||||
"BTN_COPY_INVITE": "📋 Uitnodigingslink",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Uitnodigingslink kopiëren",
|
||||
"BTN_PLAY": "▶ Afspelen",
|
||||
"BTN_PLAY": "Afspelen",
|
||||
"BTN_PLAY_TOOLTIP": "Stuur een afspeelopdracht naar iedereen",
|
||||
"BTN_PAUSE": "⏸ Pauzeren",
|
||||
"BTN_PAUSE": "Pauzeren",
|
||||
"BTN_PAUSE_TOOLTIP": "Stuur een pauzeeropdracht naar iedereen",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Dwing alle gebruikers om te synchroniseren",
|
||||
"OPTION_JUMP_TO_OTHERS": "Spring naar anderen",
|
||||
"OPTION_JUMP_TO_ME": "Spring naar mij",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Past audio-effecten zoals compressie toe op video-afspelen",
|
||||
"AUDIO_OPEN_SETTINGS": "Openen",
|
||||
"NEW_FEATURE_AUDIO": "Nieuw: Audioverwerking — probeer de compressor!",
|
||||
"NEW_FEATURE_THEME": "Nieuw: Thema's — schakel naar de klassieke look of minimaal grafiet.",
|
||||
"AUDIO_BACK": "← Terug",
|
||||
"AUDIO_PAGE_TITLE": "Audio-instellingen",
|
||||
"AUDIO_MASTER_TOGGLE": "Audioverwerking",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Tabtitel verzenden",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Stuurt de titel van het geselecteerde browsertabblad naar de kamer.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Mediatitel",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Kies hoe mediatitels naar de kamer worden gestuurd."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Kies hoe mediatitels naar de kamer worden gestuurd.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profiel & Weergave",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Wijzig uw gebruikersnaam, thema en taalvoorkeuren.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Afspelen & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Zdalne sterowanie",
|
||||
"BTN_COPY_INVITE": "📋 Link do zaproszenia",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Kopiuj link zaproszenia",
|
||||
"BTN_PLAY": "▶ Odtwórz",
|
||||
"BTN_PLAY": "Odtwórz",
|
||||
"BTN_PLAY_TOOLTIP": "Wyślij polecenie odtwarzania do wszystkich",
|
||||
"BTN_PAUSE": "⏸ Pauza",
|
||||
"BTN_PAUSE": "Pauza",
|
||||
"BTN_PAUSE_TOOLTIP": "Wyślij polecenie pauzy do wszystkich",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Wymuś synchronizację wszystkich użytkowników",
|
||||
"OPTION_JUMP_TO_OTHERS": "Skocz do innych",
|
||||
"OPTION_JUMP_TO_ME": "Skocz do mnie",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Stosuje efekty dźwiękowe, takie jak kompresja, do odtwarzania wideo",
|
||||
"AUDIO_OPEN_SETTINGS": "Otwórz",
|
||||
"NEW_FEATURE_AUDIO": "Nowość: Przetwarzanie dźwięku — wypróbuj kompresor!",
|
||||
"NEW_FEATURE_THEME": "Nowość: Motywy — przełącz na klasyczny wygląd lub minimalny grafit.",
|
||||
"AUDIO_BACK": "← Wstecz",
|
||||
"AUDIO_PAGE_TITLE": "Ustawienia dźwięku",
|
||||
"AUDIO_MASTER_TOGGLE": "Przetwarzanie dźwięku",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Wysyłaj tytuł karty",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Wysyła do pokoju tytuł wybranej karty przeglądarki.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Tytuł multimediów",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Wybierz, jak tytuły multimediów są wysyłane do pokoju."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Wybierz, jak tytuły multimediów są wysyłane do pokoju.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profil i Wygląd",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Zmień swoją nazwę użytkownika, motyw i preferencje językowe.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Odtwarzanie i Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Controle remoto",
|
||||
"BTN_COPY_INVITE": "📋 Link de convite",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar link de convite",
|
||||
"BTN_PLAY": "▶ Reproduzir",
|
||||
"BTN_PLAY": "Reproduzir",
|
||||
"BTN_PLAY_TOOLTIP": "Iniciar a reprodução para todos",
|
||||
"BTN_PAUSE": "⏸ Pausar",
|
||||
"BTN_PAUSE": "Pausar",
|
||||
"BTN_PAUSE_TOOLTIP": "Pausar o vídeo para todos",
|
||||
"BTN_SYNC": "⚡ SINCRO",
|
||||
"BTN_SYNC": "Sincro",
|
||||
"BTN_SYNC_TOOLTIP": "Forçar todos os usuários a sincronizar",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir para a posição dos outros",
|
||||
"OPTION_JUMP_TO_ME": "Trazer para a minha posição",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio (como compressão) aos vídeos",
|
||||
"AUDIO_OPEN_SETTINGS": "Abrir",
|
||||
"NEW_FEATURE_AUDIO": "Novo: Processamento de áudio — experimente o compressor!",
|
||||
"NEW_FEATURE_THEME": "Novo: Temas — mude para o visual clássico ou grafite minimalista.",
|
||||
"AUDIO_BACK": "← Voltar",
|
||||
"AUDIO_PAGE_TITLE": "Configurações de áudio",
|
||||
"AUDIO_MASTER_TOGGLE": "Processamento de áudio",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Enviar título da aba",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envia para a sala o título da aba selecionada do navegador.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Título da mídia",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolha como os títulos de mídia são enviados para a sala."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolha como os títulos de mídia são enviados para a sala.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Perfil e Aparência",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de usuário, tema e preferências de idioma.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Controlo Remoto",
|
||||
"BTN_COPY_INVITE": "📋 Link de Convite",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar Link de Convite",
|
||||
"BTN_PLAY": "▶ Reproduzir",
|
||||
"BTN_PLAY": "Reproduzir",
|
||||
"BTN_PLAY_TOOLTIP": "Iniciar a reprodução para todos",
|
||||
"BTN_PAUSE": "⏸ Pausa",
|
||||
"BTN_PAUSE": "Pausa",
|
||||
"BTN_PAUSE_TOOLTIP": "Pausar o vídeo para todos",
|
||||
"BTN_SYNC": "⚡ SINCRO",
|
||||
"BTN_SYNC": "Sincro",
|
||||
"BTN_SYNC_TOOLTIP": "Forçar sincronização de todos os utilizadores",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir para a posição dos outros",
|
||||
"OPTION_JUMP_TO_ME": "Trazer para a minha posição",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio (como compressão) aos vídeos",
|
||||
"AUDIO_OPEN_SETTINGS": "Abrir",
|
||||
"NEW_FEATURE_AUDIO": "Novo: Processamento de áudio — experimente o compressor!",
|
||||
"NEW_FEATURE_THEME": "Novo: Temas — mude para o visual clássico ou grafite minimalista.",
|
||||
"AUDIO_BACK": "← Voltar",
|
||||
"AUDIO_PAGE_TITLE": "Configurações de Áudio",
|
||||
"AUDIO_MASTER_TOGGLE": "Processamento de Áudio",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Enviar título do separador",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envia para a sala o título do separador do navegador selecionado.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Título multimédia",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolhe como os títulos multimédia são enviados para a sala."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolhe como os títulos multimédia são enviados para a sala.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Perfil e Aparência",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de utilizador, tema e preferências de idioma.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Дистанционное управление",
|
||||
"BTN_COPY_INVITE": "📋 Ссылка",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Копировать ссылку-приглашение",
|
||||
"BTN_PLAY": "▶ Запуск",
|
||||
"BTN_PLAY": "Запуск",
|
||||
"BTN_PLAY_TOOLTIP": "Отправить команду воспроизведения всем",
|
||||
"BTN_PAUSE": "⏸ Пауза",
|
||||
"BTN_PAUSE": "Пауза",
|
||||
"BTN_PAUSE_TOOLTIP": "Отправить команду паузы всем",
|
||||
"BTN_SYNC": "⚡ СИНХРО",
|
||||
"BTN_SYNC": "Синхро",
|
||||
"BTN_SYNC_TOOLTIP": "Принудительно синхронизировать всех участников",
|
||||
"OPTION_JUMP_TO_OTHERS": "Перейти к другим",
|
||||
"OPTION_JUMP_TO_ME": "Синхронизировать по мне",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Применяет аудиоэффекты, такие как сжатие, к воспроизведению видео",
|
||||
"AUDIO_OPEN_SETTINGS": "Открыть",
|
||||
"NEW_FEATURE_AUDIO": "Новое: Обработка звука — попробуйте компрессор!",
|
||||
"NEW_FEATURE_THEME": "Новое: Темы — переключитесь на классический вид или минимальный графит.",
|
||||
"AUDIO_BACK": "← Назад",
|
||||
"AUDIO_PAGE_TITLE": "Настройки звука",
|
||||
"AUDIO_MASTER_TOGGLE": "Обработка звука",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Отправлять название вкладки",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Отправляет в комнату название выбранной вкладки браузера.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Название медиа",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Выберите, как названия медиа отправляются в комнату."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Выберите, как названия медиа отправляются в комнату.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Профиль и Оформление",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Uzaktan Kumanda",
|
||||
"BTN_COPY_INVITE": "📋 Davet Linki",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Davet Linkini Kopyala",
|
||||
"BTN_PLAY": "▶ Oynat",
|
||||
"BTN_PLAY": "Oynat",
|
||||
"BTN_PLAY_TOOLTIP": "Herkese Oynat komutu gönder",
|
||||
"BTN_PAUSE": "⏸ Duraklat",
|
||||
"BTN_PAUSE": "Duraklat",
|
||||
"BTN_PAUSE_TOOLTIP": "Herkese Duraklat komutu gönder",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Tüm kullanıcıları senkronize etmeye zorla",
|
||||
"OPTION_JUMP_TO_OTHERS": "Diğerlerine Atla",
|
||||
"OPTION_JUMP_TO_ME": "Bana Atla",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Video oynatımına sıkıştırma gibi ses efektleri uygular",
|
||||
"AUDIO_OPEN_SETTINGS": "Aç",
|
||||
"NEW_FEATURE_AUDIO": "Yeni: Ses işleme — kompresörü deneyin!",
|
||||
"NEW_FEATURE_THEME": "Yeni: Temalar — klasik görünüme veya minimal grafite geçin.",
|
||||
"AUDIO_BACK": "← Geri",
|
||||
"AUDIO_PAGE_TITLE": "Ses ayarları",
|
||||
"AUDIO_MASTER_TOGGLE": "Ses işleme",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Sekme başlığını gönder",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Seçili tarayıcı sekmesinin başlığını odaya gönderir.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Medya başlığı",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Medya başlıklarının odaya nasıl gönderileceğini seçin."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Medya başlıklarının odaya nasıl gönderileceğini seçin.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profil ve Görünüm",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Kullanıcı adınızı, temanızı ve dil tercihlerinizi değiştirin.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Oynatma ve Eşitleme",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Пульт дистанційного керування",
|
||||
"BTN_COPY_INVITE": "📋 Посилання на запрошення",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Копіювати посилання для запрошення",
|
||||
"BTN_PLAY": "▶ Відтворити",
|
||||
"BTN_PLAY": "Відтворити",
|
||||
"BTN_PLAY_TOOLTIP": "Надішліть усім команду Play",
|
||||
"BTN_PAUSE": "⏸ Пауза",
|
||||
"BTN_PAUSE": "Пауза",
|
||||
"BTN_PAUSE_TOOLTIP": "Надішліть команду паузи всім",
|
||||
"BTN_SYNC": "⚡ СИНХР",
|
||||
"BTN_SYNC": "Синхр",
|
||||
"BTN_SYNC_TOOLTIP": "Змусити всіх користувачів синхронізуватися",
|
||||
"OPTION_JUMP_TO_OTHERS": "Перейти до інших",
|
||||
"OPTION_JUMP_TO_ME": "Перейти до мене",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Застосуйте звукові ефекти, такі як стиснення, до відтворення відео",
|
||||
"AUDIO_OPEN_SETTINGS": "Відкрити",
|
||||
"NEW_FEATURE_AUDIO": "Новинка: обробка звуку — спробуйте компресор!",
|
||||
"NEW_FEATURE_THEME": "Нове: Теми — перемкніться на класичний вигляд або мінімальний графіт.",
|
||||
"AUDIO_BACK": "← Назад",
|
||||
"AUDIO_PAGE_TITLE": "Параметри звуку",
|
||||
"AUDIO_MASTER_TOGGLE": "Обробка аудіо",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Надсилати назву вкладки",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Надсилає до кімнати назву вибраної вкладки браузера.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Назва медіа",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Виберіть, як назви медіа надсилати до кімнати."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Виберіть, як назви медіа надсилати до кімнати.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Профіль та Вигляд",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "遥控",
|
||||
"BTN_COPY_INVITE": "📋 邀请链接",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "复制邀请链接",
|
||||
"BTN_PLAY": "▶ 播放",
|
||||
"BTN_PLAY": "播放",
|
||||
"BTN_PLAY_TOOLTIP": "向所有人发送播放命令",
|
||||
"BTN_PAUSE": "⏸ 暂停",
|
||||
"BTN_PAUSE": "暂停",
|
||||
"BTN_PAUSE_TOOLTIP": "向所有人发送暂停命令",
|
||||
"BTN_SYNC": "⚡ 同步",
|
||||
"BTN_SYNC": "同步",
|
||||
"BTN_SYNC_TOOLTIP": "强制所有用户同步",
|
||||
"OPTION_JUMP_TO_OTHERS": "跳转到其他",
|
||||
"OPTION_JUMP_TO_ME": "跳到我这里",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "将压缩等音频效果应用于视频播放",
|
||||
"AUDIO_OPEN_SETTINGS": "打开",
|
||||
"NEW_FEATURE_AUDIO": "新功能:音频处理 - 尝试压缩器!",
|
||||
"NEW_FEATURE_THEME": "新功能:主题 — 切换到经典外观或极简石墨风格。",
|
||||
"AUDIO_BACK": "← 返回",
|
||||
"AUDIO_PAGE_TITLE": "音频设置",
|
||||
"AUDIO_MASTER_TOGGLE": "音频处理",
|
||||
@@ -238,5 +239,9 @@
|
||||
"LABEL_SEND_TAB_TITLE": "发送标签页标题",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "将所选浏览器标签页的标题发送到房间。",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "媒体标题",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "选择媒体标题发送到房间的方式。"
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "选择媒体标题发送到房间的方式。",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "个人资料与外观",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "播放与同步",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。"
|
||||
}
|
||||
|
||||
+1189
-196
File diff suppressed because it is too large
Load Diff
+266
-67
@@ -64,6 +64,8 @@ const elements = {
|
||||
autoCopyInvite: document.getElementById('autoCopyInvite'),
|
||||
cancelLobbyBtn: document.getElementById('cancelLobbyBtn'),
|
||||
langSelector: document.getElementById('langSelector'),
|
||||
themeSelector: document.getElementById('themeSelector'),
|
||||
paletteSelector: document.getElementById('paletteSelector'),
|
||||
|
||||
audioSettingsLink: document.getElementById('audioSettingsLink'),
|
||||
settingsSupportLink: document.getElementById('settingsSupportLink'),
|
||||
@@ -114,6 +116,12 @@ const FEATURE_HINTS = [
|
||||
selector: '#audioProcessingLabel',
|
||||
position: 'beforebegin',
|
||||
i18nTooltip: 'NEW_FEATURE_AUDIO'
|
||||
},
|
||||
{
|
||||
key: 'theme_picker',
|
||||
selector: '#paletteLabel',
|
||||
position: 'afterend',
|
||||
i18nTooltip: 'NEW_FEATURE_THEME'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -132,6 +140,118 @@ function openAudioSettingsPage() {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('audio-options.html') });
|
||||
}
|
||||
|
||||
function localizeThemeSelector(locale) {
|
||||
const labels = {
|
||||
de: ['Darstellung', 'System', 'Dunkel', 'Hell', 'Folge dem System-Design oder wähle ein festes Design'],
|
||||
fr: ['Apparence', 'Système', 'Sombre', 'Clair', 'Suivre le thème du système ou choisir une apparence fixe'],
|
||||
es: ['Apariencia', 'Sistema', 'Oscuro', 'Claro', 'Seguir el tema del sistema o elegir una apariencia fija'],
|
||||
it: ['Aspetto', 'Sistema', 'Scuro', 'Chiaro', 'Segui il tema di sistema o scegli un aspetto fisso'],
|
||||
nl: ['Weergave', 'Systeem', 'Donker', 'Licht', 'Volg het systeemthema of kies een vaste weergave'],
|
||||
pl: ['Wygląd', 'System', 'Ciemny', 'Jasny', 'Dostosuj do motywu systemowego lub wybierz stały wygląd'],
|
||||
pt: ['Aparência', 'Sistema', 'Escuro', 'Claro', 'Seguir o tema do sistema ou escolher uma aparência fixa'],
|
||||
'pt-BR': ['Aparência', 'Sistema', 'Escuro', 'Claro', 'Seguir o tema do sistema ou escolher uma aparência fixa'],
|
||||
tr: ['Görünüm', 'Sistem', 'Koyu', 'Açık', 'Sistem temasını takip et veya sabit bir görünüm seç'],
|
||||
ru: ['Оформление', 'Системная', 'Тёмная', 'Светлая', 'Следовать системной теме или выбрать фиксированное оформление'],
|
||||
ja: ['外観', 'システム', 'ダーク', 'ライト', 'システムテーマに従うか、固定の外観を選択します'],
|
||||
ko: ['테마', '시스템', '다크', '라이트', '시스템 테마를 따르거나 고정된 테마를 선택하세요'],
|
||||
zh: ['外观', '跟随系统', '深色', '浅色', '跟随系统主题或选择固定外观'],
|
||||
uk: ['Вигляд', 'Системна', 'Темна', 'Світла', 'Дотримуватися системної теми або вибрати фіксоване оформлення'],
|
||||
en: ['Appearance', 'System', 'Dark', 'Light', 'Follow the system theme or choose a fixed appearance']
|
||||
};
|
||||
const [label, system, dark, light, tooltip] = labels[locale] || labels.en;
|
||||
const labelEl = document.getElementById('themeLabel');
|
||||
const systemEl = document.getElementById('themeOptionSystem');
|
||||
const darkEl = document.getElementById('themeOptionDark');
|
||||
const lightEl = document.getElementById('themeOptionLight');
|
||||
const systemButton = document.getElementById('themeChoiceSystem');
|
||||
const darkButton = document.getElementById('themeChoiceDark');
|
||||
const lightButton = document.getElementById('themeChoiceLight');
|
||||
if (labelEl) {
|
||||
labelEl.textContent = label;
|
||||
if (tooltip) labelEl.setAttribute('title', tooltip);
|
||||
}
|
||||
if (systemEl) systemEl.textContent = system;
|
||||
if (darkEl) darkEl.textContent = dark;
|
||||
if (lightEl) lightEl.textContent = light;
|
||||
if (systemButton) systemButton.textContent = system;
|
||||
if (darkButton) darkButton.textContent = dark;
|
||||
if (lightButton) lightButton.textContent = light;
|
||||
if (elements.themeSelector) elements.themeSelector.setAttribute('aria-label', label);
|
||||
|
||||
// The palette scheme names (Eucalyptus / Cyber / Graphite) are brand
|
||||
// words kept verbatim; only the row label is localized.
|
||||
const paletteLabels = {
|
||||
de: ['Farbschema', 'Wähle ein kuratiertes Farbschema für die Erweiterung'],
|
||||
fr: ['Thème', 'Choisissez une palette de couleurs personnalisée pour l\'extension'],
|
||||
es: ['Tema', 'Elige una paleta de colores seleccionada para la extensión'],
|
||||
it: ['Tema', 'Scegli una combinazione di colori curata per l\'estensione'],
|
||||
nl: ['Thema', 'Kies een samengesteld kleurenschema voor de extensie'],
|
||||
pl: ['Motyw', 'Wybierz dopasowany schemat kolorów dla rozszerzenia'],
|
||||
pt: ['Tema', 'Escolha um esquema de cores personalizado para a extensão'],
|
||||
'pt-BR': ['Tema', 'Escolha um esquema de cores personalizado para a extensão'],
|
||||
tr: ['Tema', 'Uzantı için özel olarak seçilmiş bir renk şeması seçin'],
|
||||
ru: ['Тема', 'Выберите цветовую схему для расширения'],
|
||||
ja: ['テーマ', '拡張機能の厳選された配色を選択します'],
|
||||
ko: ['테마', '확장 프로그램에 맞는 특별한 색상 테마를 선택하세요'],
|
||||
zh: ['主题', '为扩展程序选择精心设计的配色方案'],
|
||||
uk: ['Тема', 'Виберіть колірну схему для розширення'],
|
||||
en: ['Theme', 'Pick a curated colour scheme for the extension']
|
||||
};
|
||||
const [paletteLabel, paletteTooltip] = paletteLabels[locale] || paletteLabels.en;
|
||||
const paletteLabelEl = document.getElementById('paletteLabel');
|
||||
if (paletteLabelEl) {
|
||||
paletteLabelEl.textContent = paletteLabel;
|
||||
if (paletteTooltip) paletteLabelEl.setAttribute('title', paletteTooltip);
|
||||
}
|
||||
if (elements.paletteSelector) elements.paletteSelector.setAttribute('aria-label', paletteLabel);
|
||||
|
||||
// Micro-descriptors under each swatch card: [nature, classic, minimal].
|
||||
const paletteDescs = {
|
||||
de: ['Natur', 'Klassik', 'Minimal'], fr: ['Nature', 'Classique', 'Minimal'],
|
||||
es: ['Naturaleza', 'Clásico', 'Minimal'], it: ['Natura', 'Classico', 'Minimal'],
|
||||
nl: ['Natuur', 'Klassiek', 'Minimaal'], pl: ['Natura', 'Klasyczny', 'Minimal'],
|
||||
pt: ['Natureza', 'Clássico', 'Minimal'], 'pt-BR': ['Natureza', 'Clássico', 'Minimal'],
|
||||
tr: ['Doğa', 'Klasik', 'Minimal'], ru: ['Природа', 'Классика', 'Минимал'],
|
||||
ja: ['ネイチャー', 'クラシック', 'ミニマル'], ko: ['자연', '클래식', '미니멀'],
|
||||
zh: ['自然', '经典', '极简'], uk: ['Природа', 'Класика', 'Мінімал'],
|
||||
en: ['Nature', 'Classic', 'Minimal']
|
||||
};
|
||||
const [natureDesc, classicDesc, minimalDesc] = paletteDescs[locale] || paletteDescs.en;
|
||||
const descMap = { eucalyptus: natureDesc, cyber: classicDesc, graphite: minimalDesc };
|
||||
document.querySelectorAll('.palette-choice').forEach(btn => {
|
||||
const desc = btn.querySelector('.palette-desc');
|
||||
if (desc) desc.textContent = descMap[btn.dataset.paletteValue] || desc.textContent;
|
||||
});
|
||||
}
|
||||
|
||||
function syncThemePicker() {
|
||||
const activeMode = elements.themeSelector?.value || 'system';
|
||||
document.querySelectorAll('.theme-choice').forEach(button => {
|
||||
const active = button.dataset.themeValue === activeMode;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
}
|
||||
|
||||
function syncPalettePicker() {
|
||||
const activePalette = elements.paletteSelector?.value || 'eucalyptus';
|
||||
document.querySelectorAll('.palette-choice').forEach(button => {
|
||||
const active = button.dataset.paletteValue === activePalette;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
}
|
||||
|
||||
// Briefly enable global colour transitions so a theme/appearance switch
|
||||
// cross-fades instead of snapping. Self-clearing; a no-op under reduced motion.
|
||||
let themeAnimTimer = null;
|
||||
function flashThemeTransition() {
|
||||
const root = document.documentElement;
|
||||
root.classList.add('theme-anim');
|
||||
if (themeAnimTimer) clearTimeout(themeAnimTimer);
|
||||
themeAnimTimer = setTimeout(() => root.classList.remove('theme-anim'), 300);
|
||||
}
|
||||
|
||||
async function updateFeatureHints() {
|
||||
const data = await chrome.storage.sync.get(['dismissedHints']);
|
||||
const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : [];
|
||||
@@ -204,7 +324,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', 'themePalette']);
|
||||
|
||||
let activeLang = localData.locale;
|
||||
if (!activeLang) {
|
||||
@@ -214,8 +334,13 @@ 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';
|
||||
if (elements.paletteSelector) elements.paletteSelector.value = ['cyber', 'graphite'].includes(localData.themePalette) ? localData.themePalette : 'eucalyptus';
|
||||
syncThemePicker();
|
||||
syncPalettePicker();
|
||||
|
||||
let username = localData.username;
|
||||
if (!username) {
|
||||
@@ -476,8 +601,8 @@ function updateLastActionUI(state, peers) {
|
||||
const safeAcks = state.acks || [];
|
||||
|
||||
const actionNames = {
|
||||
'play': getMessage('BTN_PLAY').replace('▶ ', '').toUpperCase(),
|
||||
'pause': getMessage('BTN_PAUSE').replace('⏸ ', '').toUpperCase(),
|
||||
'play': getMessage('BTN_PLAY'),
|
||||
'pause': getMessage('BTN_PAUSE'),
|
||||
'seek': getMessage('NOTIF_SEEK').toUpperCase(),
|
||||
'force_sync_prepare': getMessage('BTN_STATE_SYNCING').toUpperCase(),
|
||||
'force_sync_execute': getMessage('BTN_STATE_SYNCED').toUpperCase()
|
||||
@@ -500,7 +625,7 @@ function updateLastActionUI(state, peers) {
|
||||
actionSpan.textContent = actionNames[state.action] || state.action.toUpperCase();
|
||||
|
||||
const infoSpan = document.createElement('span');
|
||||
infoSpan.style.cssText = 'font-size:9px; color:var(--text-muted);';
|
||||
infoSpan.style.cssText = 'font-size:10px; color:var(--text-muted);';
|
||||
infoSpan.textContent = `${senderName} @ ${timeStr}`;
|
||||
|
||||
header.appendChild(actionSpan);
|
||||
@@ -509,14 +634,14 @@ function updateLastActionUI(state, peers) {
|
||||
|
||||
if (state.targetTime !== undefined && state.action === 'seek') {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.style.cssText = 'font-size:10px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Target: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
|
||||
if (state.targetTime !== undefined && state.action.includes('force_sync')) {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.style.cssText = 'font-size:10px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Sync to: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
@@ -529,7 +654,7 @@ function updateLastActionUI(state, peers) {
|
||||
if (pId === localPeerId) return;
|
||||
const pName = (typeof peer === 'object' && peer.username) ? peer.username : pId.substring(0, 4);
|
||||
const isAcked = safeAcks.includes(pId) || pId === state.senderId;
|
||||
const color = isAcked ? 'var(--success)' : '#475569';
|
||||
const color = isAcked ? 'var(--success)' : 'var(--border-strong)';
|
||||
const icon = isAcked ? '✓' : '...';
|
||||
const avatar = getAvatarForName(pName);
|
||||
|
||||
@@ -538,11 +663,11 @@ 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');
|
||||
nameSpan.style.cssText = 'font-size:7px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:36px;';
|
||||
nameSpan.style.cssText = 'font-size:9px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:42px;';
|
||||
nameSpan.textContent = `${avatar} ${pName}`;
|
||||
|
||||
peerItem.appendChild(dot);
|
||||
@@ -630,7 +755,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);
|
||||
@@ -676,7 +804,7 @@ function updatePeerList(peers) {
|
||||
|
||||
const peerItem = document.createElement('div');
|
||||
peerItem.className = 'peer-item';
|
||||
peerItem.style.cssText = 'position:relative; display:block; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05);';
|
||||
peerItem.style.cssText = 'position:relative; display:block; padding: 8px 0; border-bottom: 1px solid var(--border-soft);';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding-right: 24px;';
|
||||
@@ -686,7 +814,7 @@ function updatePeerList(peers) {
|
||||
const avatar = getAvatarForName(pUsername || pId);
|
||||
if (pUsername) {
|
||||
const u = document.createElement('span');
|
||||
u.style.cssText = 'font-weight:600; color:white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; display: inline-block;';
|
||||
u.style.cssText = 'font-weight:600; color:var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; display: inline-block;';
|
||||
u.textContent = `${avatar} ${pUsername}`;
|
||||
const i = document.createElement('span');
|
||||
i.style.cssText = 'font-size:10px; opacity:0.6; font-style:italic; white-space: nowrap; flex-shrink: 0;';
|
||||
@@ -725,7 +853,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:#b45309; 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 +870,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 +883,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';
|
||||
@@ -1011,25 +1139,8 @@ function applyConnectionStatus(status) {
|
||||
const idle = status === 'idle';
|
||||
|
||||
if (elements.connDot) {
|
||||
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : ((connecting || reconnecting) ? 'status-online' : 'status-offline'));
|
||||
|
||||
if (reconnecting) {
|
||||
elements.connDot.style.background = '#f59e0b';
|
||||
elements.connDot.style.boxShadow = '0 0 8px #f59e0b';
|
||||
} else if (connecting) {
|
||||
elements.connDot.style.background = '#fbbf24';
|
||||
elements.connDot.style.boxShadow = '0 0 8px #fbbf24';
|
||||
} else if (idle) {
|
||||
// Neutral grey — ready, not failed.
|
||||
elements.connDot.style.background = '#9ca3af';
|
||||
elements.connDot.style.boxShadow = 'none';
|
||||
} else if (!connected) {
|
||||
elements.connDot.style.background = '#ef4444';
|
||||
elements.connDot.style.boxShadow = 'none';
|
||||
} else {
|
||||
elements.connDot.style.background = '';
|
||||
elements.connDot.style.boxShadow = '';
|
||||
}
|
||||
const dotStatus = connected ? 'status-online' : ((connecting || reconnecting) ? 'status-connecting' : (idle ? 'status-idle' : 'status-offline'));
|
||||
elements.connDot.className = `status-dot ${dotStatus}`;
|
||||
}
|
||||
|
||||
if (elements.connText) {
|
||||
@@ -1066,16 +1177,16 @@ function updatePingDisplay(pingMs) {
|
||||
if (!elements.connPing) return;
|
||||
if (pingMs === null || pingMs === undefined || typeof pingMs !== 'number' || !Number.isFinite(pingMs)) {
|
||||
elements.connPing.textContent = '';
|
||||
elements.connPing.style.color = '';
|
||||
elements.connPing.className = 'conn-ping';
|
||||
return;
|
||||
}
|
||||
elements.connPing.textContent = `${Math.round(pingMs)}ms`;
|
||||
if (pingMs < 50) {
|
||||
elements.connPing.style.color = '#22c55e';
|
||||
if (pingMs < 75) {
|
||||
elements.connPing.className = 'conn-ping conn-ping-good';
|
||||
} else if (pingMs < 150) {
|
||||
elements.connPing.style.color = '#f59e0b';
|
||||
elements.connPing.className = 'conn-ping conn-ping-warning';
|
||||
} else {
|
||||
elements.connPing.style.color = '#ef4444';
|
||||
elements.connPing.className = 'conn-ping conn-ping-bad';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1093,10 +1204,10 @@ function updateHistory(history) {
|
||||
const actionLabel = item.action.toUpperCase().replace('FORCE_SYNC_', '');
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.style.cssText = 'margin-bottom: 4px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 2px;';
|
||||
entry.style.cssText = 'margin-bottom: 4px; border-bottom: 1px solid var(--border-soft); padding-bottom: 2px;';
|
||||
|
||||
const timeSpan = document.createElement('span');
|
||||
timeSpan.style.color = '#64748b';
|
||||
timeSpan.style.color = 'var(--text-muted)';
|
||||
timeSpan.textContent = `[${time}] `;
|
||||
|
||||
const actionBold = document.createElement('b');
|
||||
@@ -1139,7 +1250,7 @@ function updateRoomList(rooms) {
|
||||
rooms.forEach(r => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'room-item';
|
||||
item.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor:pointer;';
|
||||
item.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding: 8px; border-bottom: 1px solid var(--border-soft); cursor:pointer;';
|
||||
item.dataset.id = r.id;
|
||||
|
||||
const leftSide = document.createElement('div');
|
||||
@@ -1273,6 +1384,43 @@ if (elements.autoCopyInvite) {
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.themeSelector) {
|
||||
elements.themeSelector.addEventListener('change', () => {
|
||||
const themeMode = elements.themeSelector.value;
|
||||
flashThemeTransition();
|
||||
window.koalaTheme?.setMode(themeMode);
|
||||
chrome.storage.local.set({ themeMode });
|
||||
syncThemePicker();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.theme-choice').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
if (!elements.themeSelector) return;
|
||||
elements.themeSelector.value = button.dataset.themeValue;
|
||||
elements.themeSelector.dispatchEvent(new window.Event('change'));
|
||||
});
|
||||
});
|
||||
|
||||
if (elements.paletteSelector) {
|
||||
elements.paletteSelector.addEventListener('change', () => {
|
||||
const themePalette = elements.paletteSelector.value;
|
||||
flashThemeTransition();
|
||||
window.koalaTheme?.setPalette(themePalette);
|
||||
chrome.storage.local.set({ themePalette });
|
||||
syncPalettePicker();
|
||||
dismissFeatureHint('theme_picker');
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.palette-choice').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
if (!elements.paletteSelector) return;
|
||||
elements.paletteSelector.value = button.dataset.paletteValue;
|
||||
elements.paletteSelector.dispatchEvent(new window.Event('change'));
|
||||
});
|
||||
});
|
||||
|
||||
if (elements.audioSettingsLink) {
|
||||
elements.audioSettingsLink.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -1305,6 +1453,7 @@ if (elements.langSelector) {
|
||||
await chrome.storage.local.set({ locale: selectedLang });
|
||||
await loadLocale(selectedLang);
|
||||
translateDOM();
|
||||
localizeThemeSelector(selectedLang);
|
||||
configureFooterLinks();
|
||||
await updateFeatureHints();
|
||||
|
||||
@@ -1337,6 +1486,19 @@ if (elements.langSelector) {
|
||||
});
|
||||
}
|
||||
|
||||
// Accordion behavior for settings details categories (collapses others when one opens)
|
||||
document.querySelectorAll('#tab-settings details.form-group').forEach(det => {
|
||||
det.addEventListener('toggle', () => {
|
||||
if (det.open) {
|
||||
document.querySelectorAll('#tab-settings details.form-group').forEach(other => {
|
||||
if (other !== det && other.open) {
|
||||
other.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
elements.serverUrl.addEventListener('change', () => {
|
||||
let url = elements.serverUrl.value.trim();
|
||||
if (url && !url.includes('://')) {
|
||||
@@ -1389,6 +1551,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,14 +1970,13 @@ 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 = '';
|
||||
}, 2000);
|
||||
elements.copyInvite.classList.remove('copy-success');
|
||||
}, 1500);
|
||||
}).catch(() => {
|
||||
showToast(getMessage('TOAST_COPY_FAILED'), 'error');
|
||||
});
|
||||
@@ -1792,13 +1986,14 @@ if (elements.syncTabCopyInvite) {
|
||||
elements.syncTabCopyInvite.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
|
||||
const original = elements.syncTabCopyInvite.textContent;
|
||||
elements.syncTabCopyInvite.textContent = '✓';
|
||||
elements.syncTabCopyInvite.style.color = 'var(--success)';
|
||||
elements.syncTabCopyInvite.textContent = '✓ Copied';
|
||||
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 = '';
|
||||
}, 2000);
|
||||
elements.syncTabCopyInvite.classList.remove('copy-success');
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2259,7 +2454,7 @@ function refreshDebugInfo() {
|
||||
|
||||
const addSection = (title) => {
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'margin: 8px 0 4px 0; border-bottom: 1px solid #334155; padding-bottom: 2px; color: var(--accent); font-weight: bold; font-size: 10px; text-transform: uppercase;';
|
||||
div.style.cssText = 'margin: 8px 0 4px 0; border-bottom: 1px solid var(--border-soft); padding-bottom: 2px; color: var(--accent); font-weight: bold; font-size: 10px; text-transform: uppercase;';
|
||||
div.textContent = title;
|
||||
elements.videoDebug.appendChild(div);
|
||||
};
|
||||
@@ -2268,7 +2463,7 @@ function refreshDebugInfo() {
|
||||
// No video found — show diagnostic info
|
||||
addSection('Video Detection');
|
||||
const notFound = document.createElement('div');
|
||||
notFound.style.cssText = 'color: #ef4444; font-weight: 700; margin-bottom: 8px;';
|
||||
notFound.style.cssText = 'color: var(--status-error-text); font-weight: 700; margin-bottom: 8px;';
|
||||
notFound.textContent = 'NO VIDEO ELEMENT FOUND';
|
||||
elements.videoDebug.appendChild(notFound);
|
||||
|
||||
@@ -2286,7 +2481,7 @@ function refreshDebugInfo() {
|
||||
}
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText = 'margin-top: 12px; padding: 8px; background: rgba(251,191,36,0.1); border-left: 3px solid #fbbf24; border-radius: 4px; font-size: 10px; color: var(--text-muted);';
|
||||
hint.style.cssText = 'margin-top: 12px; padding: 8px; background: color-mix(in oklch, var(--warning), transparent 88%); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 10px; color: var(--text);';
|
||||
hint.textContent = 'Is a video currently playing? The extension only detects <video> elements. Ensure media is actively loaded on the page.';
|
||||
elements.videoDebug.appendChild(hint);
|
||||
return;
|
||||
@@ -2294,12 +2489,12 @@ function refreshDebugInfo() {
|
||||
|
||||
// Video found — full debug
|
||||
addSection('Playback');
|
||||
addField('State', state.paused ? 'PAUSED' : 'PLAYING', state.paused ? 'var(--text-muted)' : '#22c55e');
|
||||
addField('State', state.paused ? 'PAUSED' : 'PLAYING', state.paused ? 'var(--text-muted)' : 'var(--status-success-text)');
|
||||
addField('Time', `${state.currentTime.toFixed(2)}s / ${(state.duration || 0).toFixed(2)}s`);
|
||||
addField('ReadyState', `${state.readyState} (${state.readyStateLabel || '?'})`,
|
||||
state.readyState >= 3 ? '#22c55e' : '#fbbf24');
|
||||
state.readyState >= 3 ? 'var(--status-success-text)' : 'var(--status-warning-text)');
|
||||
addField('Network', `${state.networkState} (${state.networkStateLabel || '?'})`,
|
||||
state.networkState === 1 ? '#22c55e' : state.networkState === 3 ? '#ef4444' : 'var(--text-muted)');
|
||||
state.networkState === 1 ? 'var(--status-success-text)' : state.networkState === 3 ? 'var(--status-error-text)' : 'var(--text-muted)');
|
||||
addField('Buffered', state.buffered || '?');
|
||||
if (state.nativeCurrentTime != null || state.nativeDuration != null) {
|
||||
addField('Native Time', `${state.nativeCurrentTime ?? '?'}s / ${state.nativeDuration ?? '?'}s`);
|
||||
@@ -2323,18 +2518,18 @@ function refreshDebugInfo() {
|
||||
|
||||
addSection('Dimensions');
|
||||
const dimsOk = state.videoWidth > 0 && state.videoHeight > 0;
|
||||
addField('Resolution', `${state.videoWidth}x${state.videoHeight}`, dimsOk ? '#22c55e' : '#ef4444');
|
||||
addField('Resolution', `${state.videoWidth}x${state.videoHeight}`, dimsOk ? 'var(--status-success-text)' : 'var(--status-error-text)');
|
||||
if (!dimsOk) {
|
||||
const dimHint = document.createElement('div');
|
||||
dimHint.style.cssText = 'color: #fbbf24; font-size: 9px; margin: 2px 0 6px 12px;';
|
||||
dimHint.style.cssText = 'color: var(--status-warning-text); font-size: 10px; margin: 2px 0 6px 12px;';
|
||||
dimHint.textContent = '0x0 = video element hidden or not yet loaded';
|
||||
elements.videoDebug.appendChild(dimHint);
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
addSection('Media Error');
|
||||
addField('Code', String(state.error.code), '#ef4444');
|
||||
addField('Message', state.error.message || '?', '#ef4444');
|
||||
addField('Code', String(state.error.code), 'var(--status-error-text)');
|
||||
addField('Message', state.error.message || '?', 'var(--status-error-text)');
|
||||
}
|
||||
|
||||
addSection('Detection');
|
||||
@@ -2410,7 +2605,7 @@ function updateLobbyUI(lobby, peers) {
|
||||
|
||||
// Build peer readiness list
|
||||
const readySet = new Set(lobby.readyPeers || []);
|
||||
const peerLines = [];
|
||||
const peerHtmls = [];
|
||||
|
||||
if (peers && peers.length > 0) {
|
||||
peers.forEach(p => {
|
||||
@@ -2418,14 +2613,15 @@ function updateLobbyUI(lobby, peers) {
|
||||
const pName = (typeof p === 'object' && p.username) ? p.username : pId;
|
||||
const avatar = getAvatarForName(pName);
|
||||
const isReady = readySet.has(pId);
|
||||
const icon = isReady ? '\u2705' : '\u23f3';
|
||||
const icon = isReady ? '✅' : '⏳';
|
||||
const label = isReady ? getMessage('LABEL_LOBBY_PEER_READY') : getMessage('LABEL_LOBBY_PEER_LOADING');
|
||||
peerLines.push(`${icon} ${avatar} ${pName} \u2014 ${label}`);
|
||||
const badgeClass = isReady ? 'badge-ready' : 'badge-loading';
|
||||
peerHtmls.push(`<span class="lobby-peer-item">${icon} ${avatar} ${pName} <span class="badge ${badgeClass}">${label}</span></span>`);
|
||||
});
|
||||
}
|
||||
|
||||
if (peerLines.length > 0 && elements.lobbyPeerStatus) {
|
||||
elements.lobbyPeerStatus.textContent = peerLines.join(' | ');
|
||||
if (peerHtmls.length > 0 && elements.lobbyPeerStatus) {
|
||||
elements.lobbyPeerStatus.innerHTML = peerHtmls.join(' ');
|
||||
} else if (elements.lobbyPeerStatus) {
|
||||
elements.lobbyPeerStatus.textContent = getMessage('LOBBY_WAITING_PEERS');
|
||||
}
|
||||
@@ -2433,7 +2629,10 @@ function updateLobbyUI(lobby, peers) {
|
||||
// Show elapsed time
|
||||
if (lobby.createdAt && elements.lobbyPeerStatus) {
|
||||
const elapsed = Math.floor((Date.now() - lobby.createdAt) / 1000);
|
||||
elements.lobbyPeerStatus.textContent += ` (${elapsed}s)`;
|
||||
const timerSpan = document.createElement('span');
|
||||
timerSpan.style.cssText = 'font-size: 10px; color: var(--text-muted); margin-left: 6px; display: inline-block; vertical-align: middle;';
|
||||
timerSpan.textContent = `(${elapsed}s)`;
|
||||
elements.lobbyPeerStatus.appendChild(timerSpan);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
(() => {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
let mode = 'system';
|
||||
let palette = 'eucalyptus';
|
||||
|
||||
const normalize = value => ['system', 'dark', 'light'].includes(value) ? value : 'system';
|
||||
const normalizePalette = value => ['eucalyptus', 'cyber', 'graphite'].includes(value) ? value : 'eucalyptus';
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
const applyPalette = value => {
|
||||
palette = normalizePalette(value);
|
||||
document.documentElement.dataset.palette = palette;
|
||||
};
|
||||
|
||||
apply('system');
|
||||
applyPalette('eucalyptus');
|
||||
window.koalaTheme = {
|
||||
getMode: () => mode,
|
||||
setMode: apply,
|
||||
getPalette: () => palette,
|
||||
setPalette: applyPalette
|
||||
};
|
||||
|
||||
systemTheme.addEventListener('change', () => {
|
||||
if (mode === 'system') apply('system');
|
||||
});
|
||||
|
||||
if (!globalThis.chrome?.storage?.local) return;
|
||||
chrome.storage.local.get(['themeMode', 'themePalette'], data => {
|
||||
apply(data.themeMode);
|
||||
applyPalette(data.themePalette);
|
||||
});
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local') return;
|
||||
if (changes.themeMode) apply(changes.themeMode.newValue);
|
||||
if (changes.themePalette) applyPalette(changes.themePalette.newValue);
|
||||
});
|
||||
})();
|
||||
@@ -19,7 +19,7 @@ export function normalizeSendTabTitle(sendTabTitle, legacyMode = TITLE_PRIVACY_M
|
||||
|
||||
export function normalizeTabTitle(title) {
|
||||
if (typeof title !== 'string') return null;
|
||||
const normalized = title.replace(/^\s*(?:\(\d{1,2}\)|\[\d{1,2}\])\s+/, '').trim();
|
||||
const normalized = title.replace(/^\s*(?:\(\d+\+?\)|\[\d+\+?\])\s+/, '').trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
|
||||
Generated
+435
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.5.3",
|
||||
"version": "2.5.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "koalasync",
|
||||
"version": "2.5.3",
|
||||
"version": "2.5.4",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
@@ -14,11 +14,34 @@
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"htmlnano": "^3.4.0",
|
||||
"sharp": "^0.34.5",
|
||||
"svgo": "^4.0.1",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame/node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
@@ -80,9 +103,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -92,9 +115,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1582,6 +1605,29 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
|
||||
@@ -1680,12 +1726,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/relateurl": {
|
||||
"version": "0.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.33.tgz",
|
||||
"integrity": "sha512-bTQCKsVbIdzLqZhLkF5fcJQreE4y1ro4DIyVrlDNSCJRRwHhB8Z+4zXXa8jN6eDvc2HbRsEYgbvrnGvi54EpSw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
|
||||
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.9",
|
||||
@@ -1843,6 +1897,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1941,6 +1996,13 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1998,6 +2060,7 @@
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -2162,6 +2225,16 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -2233,6 +2306,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz",
|
||||
"integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"env-paths": "^2.2.1",
|
||||
"import-fresh": "^3.3.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"parse-json": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.9.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
@@ -2476,6 +2576,26 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
|
||||
@@ -2490,6 +2610,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -2544,6 +2665,7 @@
|
||||
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
@@ -2982,6 +3104,158 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/htmlnano": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-3.4.0.tgz",
|
||||
"integrity": "sha512-5rgW9c/830dlDiWLlsT3ZLBs52UAupymGwkwr4rn2yyuzrNY0RZwgr6F4q2AkUqrRuuHb3kPelqYsUgtzQDtQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/relateurl": "^0.2.33",
|
||||
"commander": "^15.0.0",
|
||||
"cosmiconfig": "^9.0.0",
|
||||
"posthtml": "^0.16.5",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"htmlnano": "dist/bin.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cssnano": "^7.0.0 || ^8.0.0",
|
||||
"postcss": "^8.3.11",
|
||||
"purgecss": "^8.0.0",
|
||||
"relateurl": "^0.2.7",
|
||||
"srcset": "^5.0.1",
|
||||
"svgo": "^4.0.0",
|
||||
"terser": "^5.21.0",
|
||||
"uncss": "^0.17.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"cssnano": {
|
||||
"optional": true
|
||||
},
|
||||
"postcss": {
|
||||
"optional": true
|
||||
},
|
||||
"purgecss": {
|
||||
"optional": true
|
||||
},
|
||||
"relateurl": {
|
||||
"optional": true
|
||||
},
|
||||
"srcset": {
|
||||
"optional": true
|
||||
},
|
||||
"svgo": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"uncss": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/htmlnano/node_modules/commander": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz",
|
||||
"integrity": "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"domhandler": "^4.2.2",
|
||||
"domutils": "^2.8.0",
|
||||
"entities": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/dom-serializer": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
|
||||
"integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"domhandler": "^4.2.0",
|
||||
"entities": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/domhandler": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
|
||||
"integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/domutils": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||
"integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^1.0.1",
|
||||
"domelementtype": "^2.2.0",
|
||||
"domhandler": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
|
||||
"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -3013,6 +3287,23 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parent-module": "^1.0.0",
|
||||
"resolve-from": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -3030,6 +3321,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -3063,6 +3361,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-json": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-json/-/is-json-2.0.1.tgz",
|
||||
"integrity": "sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
@@ -3152,6 +3457,29 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
@@ -3159,6 +3487,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-parse-even-better-errors": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -3517,6 +3852,13 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -3745,6 +4087,38 @@
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"callsites": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"error-ex": "^1.3.1",
|
||||
"json-parse-even-better-errors": "^2.3.0",
|
||||
"lines-and-columns": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -3885,6 +4259,46 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/posthtml": {
|
||||
"version": "0.16.7",
|
||||
"resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz",
|
||||
"integrity": "sha512-7Hc+IvlQ7hlaIfQFZnxlRl0jnpWq2qwibORBhQYIb0QbNtuicc5ZxvKkVT71HJ4Py1wSZ/3VR1r8LfkCtoCzhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"posthtml-parser": "^0.11.0",
|
||||
"posthtml-render": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/posthtml-parser": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.11.0.tgz",
|
||||
"integrity": "sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"htmlparser2": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/posthtml-render": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/posthtml-render/-/posthtml-render-3.0.0.tgz",
|
||||
"integrity": "sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-json": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -3962,6 +4376,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||
@@ -4297,6 +4721,7 @@
|
||||
"integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^11.1.0",
|
||||
"css-select": "^5.1.0",
|
||||
@@ -4448,6 +4873,7 @@
|
||||
"integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
@@ -4526,6 +4952,7 @@
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.9",
|
||||
"@vitest/mocker": "4.1.9",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"indexnow": "node website/submit-indexnow.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"subset-flags": "node website/tools/subset-flag-font.mjs",
|
||||
"test": "npm run verify",
|
||||
"test:unit": "vitest run",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
@@ -20,6 +21,7 @@
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"htmlnano": "^3.4.0",
|
||||
"sharp": "^0.34.5",
|
||||
"svgo": "^4.0.1",
|
||||
"vitest": "^4.1.9"
|
||||
|
||||
@@ -18,7 +18,12 @@ assert.equal(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN), true);
|
||||
assert.equal(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL), false);
|
||||
assert.equal(normalizeTabTitle('(12) Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('[7] Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('(500) Days of Summer'), '(500) Days of Summer');
|
||||
assert.equal(normalizeTabTitle('(99+) Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('(999+) Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('[999+] Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('(500) Days of Summer'), 'Days of Summer');
|
||||
assert.equal(normalizeTabTitle('(101) Days of Summer'), 'Days of Summer');
|
||||
assert.equal(normalizeTabTitle('[101] Days of Summer'), 'Days of Summer');
|
||||
assert.equal(normalizeTabTitle(' '), null);
|
||||
|
||||
assert.equal(sanitizeTabTitle('Private Tab', true), 'Private Tab');
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const template = fs.readFileSync(path.join(repoRoot, 'website', 'template.html'), 'utf8');
|
||||
const app = fs.readFileSync(path.join(repoRoot, 'website', 'app.js'), 'utf8');
|
||||
const langInit = fs.readFileSync(path.join(repoRoot, 'website', 'lang-init.js'), 'utf8');
|
||||
const demoCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'demo.css'), 'utf8');
|
||||
const landingPrimaryCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'landing-primary.css'), 'utf8');
|
||||
const legalCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'legal.css'), 'utf8');
|
||||
const joinPage = fs.readFileSync(path.join(repoRoot, 'website', 'join.html'), 'utf8');
|
||||
const imprintPage = fs.readFileSync(path.join(repoRoot, 'website', 'imprint.html'), 'utf8');
|
||||
const germanImprintPage = fs.readFileSync(path.join(repoRoot, 'website', 'impressum-de.html'), 'utf8');
|
||||
const alternativesIndex = fs.readFileSync(path.join(repoRoot, 'website', 'alternatives', 'index.html'), 'utf8');
|
||||
const alternativesCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'alternatives.css'), 'utf8');
|
||||
const llmsText = fs.readFileSync(path.join(repoRoot, 'website', 'llms.txt'), 'utf8');
|
||||
const mockupStart = template.indexOf('<div class="extension-mockup">');
|
||||
const mockupEnd = template.indexOf('<div class="demo-invite-fly"', mockupStart);
|
||||
|
||||
if (mockupStart === -1 || mockupEnd === -1) {
|
||||
throw new Error('Could not locate the extension mockup in website/template.html');
|
||||
}
|
||||
|
||||
const mockup = template.slice(mockupStart, mockupEnd);
|
||||
const themeSensitiveControls = [
|
||||
['video selector', /<select[^>]+id="demo-video-select"[^>]+>/],
|
||||
['sync target', /<div[^>]+class="mock-input"[^>]+title="Choose sync target"[^>]*>/],
|
||||
['WebSocket status', /<span style="[^"]*flex:1;[^"]*">\s*\{\{MOCK_27\}\}/]
|
||||
];
|
||||
|
||||
for (const [label, pattern] of themeSensitiveControls) {
|
||||
const match = mockup.match(pattern);
|
||||
if (!match) throw new Error(`Could not locate ${label} in the extension mockup`);
|
||||
if (!/color:\s*var\(--text\)/.test(match[0])) {
|
||||
throw new Error(`${label} must use var(--text) so it remains readable in both themes`);
|
||||
}
|
||||
}
|
||||
|
||||
const landingStylesheets = [...template.matchAll(/<link\b[^>]*\brel="stylesheet"[^>]*\bhref="\{\{ASSET_PATH\}\}landing[^">]*"[^>]*>/g)];
|
||||
if (landingStylesheets.length !== 1) {
|
||||
throw new Error(`Landing must load exactly one render-blocking stylesheet; found ${landingStylesheets.length}`);
|
||||
}
|
||||
|
||||
const llmsLinks = [...template.matchAll(/<link\b[^>]*\brel="alternate"[^>]*\btype="text\/markdown"[^>]*\bhref="https:\/\/sync\.koalastuff\.net\/llms\.txt"[^>]*>/g)];
|
||||
if (llmsLinks.length !== 1) {
|
||||
throw new Error(`Landing head must link exactly one canonical llms.txt document; found ${llmsLinks.length}`);
|
||||
}
|
||||
|
||||
const requiredLlmsSections = [
|
||||
'## Quick fit assessment',
|
||||
'## How it works',
|
||||
'## Privacy and security',
|
||||
'## Supported environments',
|
||||
'## Install KoalaSync',
|
||||
'## Technical information'
|
||||
];
|
||||
for (const section of requiredLlmsSections) {
|
||||
if (!llmsText.includes(section)) throw new Error(`llms.txt is missing ${section}`);
|
||||
}
|
||||
if (/\bWebRTC\b|\bport 54000\b|peer-to-peer by design/i.test(llmsText)) {
|
||||
throw new Error('llms.txt must describe the current WebSocket relay architecture without obsolete WebRTC or port claims');
|
||||
}
|
||||
const documentedRelayUrls = [...llmsText.matchAll(/`(wss:\/\/[^`\s]+)`/g)].map(([, value]) => new URL(value));
|
||||
const hasCanonicalPublicRelay = documentedRelayUrls.some((url) => (
|
||||
url.protocol === 'wss:' &&
|
||||
url.hostname === 'syncserver.koalastuff.net' &&
|
||||
url.port === '' &&
|
||||
url.pathname === '/' &&
|
||||
url.search === '' &&
|
||||
url.hash === ''
|
||||
));
|
||||
if (!hasCanonicalPublicRelay || !llmsText.includes('internally on port 3000')) {
|
||||
throw new Error('llms.txt must distinguish the public TLS relay URL from the internal self-hosting port');
|
||||
}
|
||||
|
||||
const selfHostingStart = template.indexOf('<div id="pane-docker"');
|
||||
const selfHostingEnd = template.indexOf('</section>', selfHostingStart);
|
||||
if (selfHostingStart === -1 || selfHostingEnd === -1) {
|
||||
throw new Error('Could not locate the self-hosting examples in website/template.html');
|
||||
}
|
||||
const selfHostingExamples = template.slice(selfHostingStart, selfHostingEnd);
|
||||
const requiredSelfHostingValues = [
|
||||
'SERVER_SALT:',
|
||||
'REPLACE_WITH_OPENSSL_RAND_BASE64_32_OUTPUT',
|
||||
'ADMIN_METRICS_TOKEN:',
|
||||
'DEBUG_LOGGING:',
|
||||
'127.0.0.1:3000:3000'
|
||||
];
|
||||
for (const value of requiredSelfHostingValues) {
|
||||
if (!selfHostingExamples.includes(value)) {
|
||||
throw new Error(`Website self-hosting compose example is missing ${value}`);
|
||||
}
|
||||
}
|
||||
if ((selfHostingExamples.match(/localhost:3000/g) || []).length !== 2 || selfHostingExamples.includes('KoalaSync:3000')) {
|
||||
throw new Error('Both Caddy examples must match the compose loopback port binding');
|
||||
}
|
||||
|
||||
const landingStylesheet = landingStylesheets[0][0];
|
||||
if (!/href="\{\{ASSET_PATH\}\}landing\.min\.css"/.test(landingStylesheet)) {
|
||||
throw new Error('Landing stylesheet must use the landing.min.css build placeholder');
|
||||
}
|
||||
if (/\bmedia=|data-(?:landing-)?deferred/.test(landingStylesheet)) {
|
||||
throw new Error('Landing stylesheet must not be activated after first paint');
|
||||
}
|
||||
|
||||
const runtimeCssActivation = [
|
||||
['app.js', app],
|
||||
['lang-init.js', langInit]
|
||||
];
|
||||
for (const [file, source] of runtimeCssActivation) {
|
||||
if (/createElement\(['"]link['"]\)|requestIdleCallback|data-landing-deferred|data-deferred-css|\.media\s*=\s*['"]all['"]/.test(source)) {
|
||||
throw new Error(`${file} must not dynamically load or activate structural CSS`);
|
||||
}
|
||||
}
|
||||
|
||||
// Film birds were removed entirely (commit 16cb138); guard against remnants
|
||||
if (/film-hero-bird|film-bird-wing|film-birds/.test(template) || /filmBirdGlide|filmWingFlap/.test(demoCss)) {
|
||||
throw new Error('Film birds were removed from the demo mockups; no bird markup or animations may remain');
|
||||
}
|
||||
|
||||
const gettingStartedLightSurfaces = [
|
||||
'step-illustration-1',
|
||||
'step-illustration-2',
|
||||
'step-illustration-3',
|
||||
'illus-popup-card',
|
||||
'illus-player-card',
|
||||
'popup-select-mock'
|
||||
];
|
||||
for (const className of gettingStartedLightSurfaces) {
|
||||
const pattern = new RegExp(`html\\.theme-light[^{]*\\.${className}[^}]*\\{`);
|
||||
if (!pattern.test(landingPrimaryCss)) {
|
||||
throw new Error(`Getting Started .${className} must define an explicit light-theme surface`);
|
||||
}
|
||||
}
|
||||
|
||||
const stepNumberLightRule = landingPrimaryCss.match(/html\.theme-light \.step-num\s*\{([^}]*)\}/)?.[1] || '';
|
||||
if (!/color:\s*oklch\(/.test(stepNumberLightRule)
|
||||
|| !/background:\s*none/.test(stepNumberLightRule)
|
||||
|| !/-webkit-text-fill-color:\s*currentColor/.test(stepNumberLightRule)) {
|
||||
throw new Error('Getting Started step numbers must use solid readable text in light mode');
|
||||
}
|
||||
|
||||
if (!/class="footer-disclaimer"/.test(joinPage) || /footer-disclaimer[^>]*opacity:/.test(joinPage)) {
|
||||
throw new Error('Join-page disclaimer must use the contrast-safe footer-disclaimer colors without opacity');
|
||||
}
|
||||
|
||||
if (!/\.legal-inline-link\s*\{[^}]*text-decoration:\s*underline/s.test(legalCss)) {
|
||||
throw new Error('Legal prose links must use a non-color underline cue');
|
||||
}
|
||||
for (const [name, page] of [['imprint.html', imprintPage], ['impressum-de.html', germanImprintPage]]) {
|
||||
if ((page.match(/class="legal-inline-link"/g) || []).length !== 3) {
|
||||
throw new Error(`${name} must mark all three prose links as legal-inline-link`);
|
||||
}
|
||||
if ((page.match(/class="legal-inline-label"/g) || []).length !== 2 || /opacity:\s*0\.6/.test(page)) {
|
||||
throw new Error(`${name} contact labels must use full-opacity theme text`);
|
||||
}
|
||||
}
|
||||
|
||||
if (/<h3\b/.test(alternativesIndex)
|
||||
|| (alternativesIndex.match(/<h2\b/g) || []).length !== 6
|
||||
|| (alternativesIndex.match(/<h2[^>]+color:\s*var\(--text\)/g) || []).length !== 6) {
|
||||
throw new Error('Alternatives overview cards must use h2 headings directly below the page h1');
|
||||
}
|
||||
if (!/html\.theme-light \.guide-card\s*\{[^}]*background-color:\s*var\(--card-surface\)\s*!important/s.test(alternativesCss)) {
|
||||
throw new Error('Alternatives cards must define a readable light-theme surface');
|
||||
}
|
||||
|
||||
const reducedMotionRule = demoCss.match(/@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.step-illustration-3 \.film-mtn-scroll,\s*\.step-illustration-3 \.film-mid-scroll,\s*\.step-illustration-3 \.film-fore-scroll\s*\{([^}]*)\}/)?.[1] || '';
|
||||
if (!/animation:\s*none\s*!important/.test(reducedMotionRule)) {
|
||||
throw new Error('Getting Started film layers must stop when reduced motion is requested');
|
||||
}
|
||||
|
||||
console.log('Extension mockup theme-sensitive text uses theme-aware colors');
|
||||
console.log('Landing CSS is render-blocking, single-request, and cascade-stable');
|
||||
console.log('Landing head discovers a complete and architecture-accurate llms.txt');
|
||||
console.log('Website self-hosting examples include production secrets and consistent networking');
|
||||
console.log('Foreground film birds use complete, always-on wing and glide animations');
|
||||
console.log('All Getting Started mockups define explicit light-theme surfaces');
|
||||
console.log('Getting Started step numbers stay readable in light mode');
|
||||
console.log('Accessibility regressions stay fixed across legal, alternatives, and reduced-motion views');
|
||||
@@ -28,6 +28,7 @@ const checks = [
|
||||
['background syntax', 'node', ['-c', 'extension/background.js']],
|
||||
['locale coverage', 'node', ['scripts/test-locales.cjs']],
|
||||
['website locale coverage', 'node', ['scripts/test-website-locales.mjs']],
|
||||
['website theme coverage', 'node', ['scripts/test-website-theme.mjs']],
|
||||
['lint', 'npm', ['run', 'lint']],
|
||||
['root production audit', 'npm', ['audit', '--omit=dev']],
|
||||
['server production audit', 'npm', ['audit', '--omit=dev'], { cwd: path.join(repoRoot, 'server') }],
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="lang-en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 – Page Not Found | KoalaSync</title>
|
||||
<!-- Absolute asset paths: Caddy rewrites any missing URL (also nested ones
|
||||
like /foo/bar) to this file, so relative paths would break. -->
|
||||
<link rel="preload" href="/style.min.css" as="style">
|
||||
<link rel="stylesheet" href="/style.min.css">
|
||||
<link rel="icon" type="image/webp" href="/assets/NewLogoIcon_64.webp">
|
||||
<meta name="description" content="This page could not be found.">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="/lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="/" class="logo-area" style="text-decoration: none;">
|
||||
<img src="/assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="/" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2.5rem 2rem; text-align: center;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="/assets/KoalaSearching-180.webp" srcset="/assets/KoalaSearching-180.webp 180w, /assets/KoalaSearching-360.webp 360w" sizes="180px" alt="A cute koala looking through a telescope, searching for the missing page" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1 style="margin-bottom: 0.5rem;">404</h1>
|
||||
<p style="text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 1.5rem;">
|
||||
Page not found
|
||||
</p>
|
||||
<p style="max-width: 34rem; margin: 0 auto;">
|
||||
This page wandered off into the bamboo forest. Our koala is still looking for it — in the meantime, head back to safety.
|
||||
</p>
|
||||
<div style="display: flex; justify-content: center; gap: 1rem; margin-top: 2rem; flex-wrap: wrap;">
|
||||
<a href="/" class="btn btn-primary">
|
||||
Back to home
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="btn btn-secondary">
|
||||
Report a broken link
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="/imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="/privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+15
-6
@@ -4,11 +4,11 @@ This directory contains the static KoalaSync website. It is both the public mark
|
||||
|
||||
## Where You Are
|
||||
|
||||
- `template.html`, `style.css`, `app.js`, `lang-init.js`, and `locales/*.json` are source files.
|
||||
- `template.html`, `style.css`, `styles/*.css`, `app.js`, `lang-init.js`, and `locales/*.json` are source files. `style.css` is the local-development manifest; production concatenates the modules in the order declared by `website/build.cjs`.
|
||||
- `build.cjs` compiles the static site into `website/www/`.
|
||||
- `website/www/` is generated output. Do not edit files there directly.
|
||||
- `join.html` handles room-invite links and communicates with the extension through `bridge.js`.
|
||||
- `llms.txt` gives crawlers and AI tools a compact project overview.
|
||||
- `llms.txt` gives AI tools a detailed product profile and is linked from every localized landing-page `<head>`.
|
||||
- `alternatives/` contains comparison pages for users evaluating KoalaSync against other watch-party approaches.
|
||||
|
||||
## Core Roles
|
||||
@@ -33,9 +33,10 @@ The website is 100% static HTML, CSS, and JavaScript.
|
||||
|
||||
- **Static i18n compiler**: `build.cjs` combines `template.html` with dictionaries in `locales/`.
|
||||
- **Build-time minification**: Source CSS/JS stays readable; generated output uses `.min.css` and `.min.js`.
|
||||
- **Strangler CSS architecture**: `styles/*.css` supplies page-specific production bundles; `style.legacy.css` is the byte-identical, unloaded reference monolith. The landing bundle preserves the legacy cascade while excluding Join- and Alternatives-only rules. Structural CSS is not activated after paint because that causes layout shifts.
|
||||
- **Zero backend**: The compiled site can be hosted by any static file server.
|
||||
- **Zero external assets**: Fonts, icons, scripts, and images must remain self-hosted.
|
||||
- **Generated SEO/runtime files**: `version.json`, sitemap, robots, clean URLs, localized pages, and minified assets are copied or generated into `www/`.
|
||||
- **Generated SEO/runtime files**: `version.json`, `robots.txt`, `llms.txt`, clean URLs, localized pages, and minified assets are copied into `www/`; `sitemap.xml` is generated there from the current route set.
|
||||
|
||||
## Local Development & Compilation
|
||||
|
||||
@@ -54,8 +55,9 @@ Then open:
|
||||
Focused verification:
|
||||
```bash
|
||||
node scripts/test-website-locales.mjs
|
||||
node --check website/www/app.min.js
|
||||
node --check website/www/lang-init.min.js
|
||||
node scripts/test-website-theme.mjs
|
||||
node --check website/app.js
|
||||
node --check website/lang-init.js
|
||||
```
|
||||
|
||||
Full verification:
|
||||
@@ -75,6 +77,13 @@ sync.koalastuff.net {
|
||||
file_server
|
||||
encode zstd gzip
|
||||
|
||||
# Themed 404 page (Caddy returns an empty 404 without this)
|
||||
handle_errors {
|
||||
@notfound expression {err.status_code} == 404
|
||||
rewrite @notfound /404.html
|
||||
file_server
|
||||
}
|
||||
|
||||
@static {
|
||||
file
|
||||
path *.ico *.css *.js *.png *.svg *.webp *.avif
|
||||
@@ -98,4 +107,4 @@ sync.koalastuff.net {
|
||||
- Do not add external CDNs, fonts, analytics, or third-party scripts.
|
||||
- Keep invite credentials in the URL hash, not query parameters.
|
||||
- Keep locale files synchronized with `website/build.cjs` and `scripts/test-website-locales.mjs`.
|
||||
- Commit source changes and regenerated `www/` output together when website output changes.
|
||||
- `website/www/` is gitignored build output. Commit the source changes only; rebuild `www/` locally or in CI for verification and deployment.
|
||||
|
||||
@@ -98,17 +98,80 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
@@ -169,7 +232,7 @@
|
||||
<!-- Teleparty Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/teleparty" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h3 style="margin-top: 0; margin-bottom: 0.75rem; color: #fff; font-size: 1.25rem;">{{ALT_TELEPARTY_TITLE}}</h3>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_TELEPARTY_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_TELEPARTY_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
@@ -180,7 +243,7 @@
|
||||
<!-- Watch2Gether Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/watch2gether" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h3 style="margin-top: 0; margin-bottom: 0.75rem; color: #fff; font-size: 1.25rem;">{{ALT_W2G_TITLE}}</h3>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_W2G_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_W2G_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
@@ -191,7 +254,7 @@
|
||||
<!-- Scener Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/scener" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h3 style="margin-top: 0; margin-bottom: 0.75rem; color: #fff; font-size: 1.25rem;">{{ALT_SCENER_TITLE}}</h3>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_SCENER_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_SCENER_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
@@ -202,7 +265,7 @@
|
||||
<!-- Kosmi Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/kosmi" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h3 style="margin-top: 0; margin-bottom: 0.75rem; color: #fff; font-size: 1.25rem;">{{ALT_KOSMI_TITLE}}</h3>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_KOSMI_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_KOSMI_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
@@ -213,7 +276,7 @@
|
||||
<!-- TwoSeven Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/twoseven" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h3 style="margin-top: 0; margin-bottom: 0.75rem; color: #fff; font-size: 1.25rem;">{{ALT_TWOSEVEN_TITLE}}</h3>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_TWOSEVEN_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_TWOSEVEN_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
@@ -224,7 +287,7 @@
|
||||
<!-- Screen Sharing Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/screen-sharing" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h3 style="margin-top: 0; margin-bottom: 0.75rem; color: #fff; font-size: 1.25rem;">{{ALT_SCREEN_TITLE}}</h3>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_SCREEN_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_SCREEN_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
|
||||
@@ -104,17 +104,80 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
|
||||
@@ -104,17 +104,80 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
|
||||
@@ -104,17 +104,80 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
|
||||
@@ -104,17 +104,80 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
|
||||
@@ -104,17 +104,80 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
|
||||
@@ -104,17 +104,80 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
|
||||
+216
-34
@@ -60,6 +60,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const restartAnimation = (el, className) => {
|
||||
if (!el) return;
|
||||
el.classList.remove(className);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
el.classList.add(className);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Populated by the forest renderer further down (no-op until then)
|
||||
let forestGreet = null;
|
||||
|
||||
// Theme toggle (saved preference wins; otherwise follows system preference,
|
||||
// pre-paint class is applied by lang-init.js to avoid a flash)
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
@@ -86,7 +99,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const syncThemeMeta = () => {
|
||||
if (themeMeta) {
|
||||
themeMeta.setAttribute('content', document.documentElement.classList.contains('theme-light') ? '#f1f5f9' : '#0f172a');
|
||||
themeMeta.setAttribute('content', document.documentElement.classList.contains('theme-light') ? '#f0efe7' : '#10190e');
|
||||
}
|
||||
};
|
||||
syncThemeMeta();
|
||||
@@ -108,6 +121,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
applyTheme(nextTheme);
|
||||
safeSetLocalStorage(themeStorageKey, nextTheme);
|
||||
syncThemeMeta();
|
||||
// Switching into light mode flares the horizon once (sunrise)
|
||||
const scene = document.querySelector('.bg-nature');
|
||||
if (nextTheme === 'light' && scene && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
restartAnimation(scene, 'sunrise');
|
||||
setTimeout(() => scene.classList.remove('sunrise'), 1700);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,6 +192,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Scroll Reveal Logic (IntersectionObserver for performance)
|
||||
const revealElements = document.querySelectorAll('[data-reveal]');
|
||||
|
||||
// Hash navigation must never land on an intentionally transparent state.
|
||||
// Reveal the destination before the smooth scroll begins; the observer
|
||||
// continues to animate sections reached through ordinary scrolling.
|
||||
const revealHashTarget = () => {
|
||||
const id = decodeURIComponent(window.location.hash.slice(1));
|
||||
const target = id ? document.getElementById(id) : null;
|
||||
if (!target) return;
|
||||
if (target.matches('[data-reveal]')) target.classList.add('revealed');
|
||||
target.querySelectorAll('[data-reveal]').forEach(el => el.classList.add('revealed'));
|
||||
};
|
||||
revealHashTarget();
|
||||
window.addEventListener('hashchange', revealHashTarget);
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
const revealObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
@@ -182,7 +214,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '0px 0px -30px 0px',
|
||||
rootMargin: '0px 0px 12% 0px',
|
||||
threshold: 0.05
|
||||
});
|
||||
|
||||
@@ -199,7 +231,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const sectionObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
history.replaceState(null, null, '#' + entry.target.id);
|
||||
if (entry.target.id === 'top') {
|
||||
history.replaceState(null, null, window.location.pathname + window.location.search);
|
||||
} else {
|
||||
history.replaceState(null, null, '#' + entry.target.id);
|
||||
}
|
||||
if (forestGreet) forestGreet();
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
@@ -212,6 +249,152 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
nav.classList.toggle('nav-scrolled', window.scrollY > 50);
|
||||
}, { passive: true });
|
||||
|
||||
// Bamboo forest: scroll journey + parallax (background layers only).
|
||||
// The vertical drift maps scroll PROGRESS (0..1 over the whole page) onto
|
||||
// the stalks' 20% overscan, so the parallax runs for the entire scroll
|
||||
// instead of saturating after a few hundred pixels. The same progress
|
||||
// value crossfades the scene from sunlit canopy into misty dusk, and on
|
||||
// pointer devices the layers tilt slightly toward the cursor.
|
||||
const bambooFar = document.getElementById('bamboo-far');
|
||||
const bambooMid = document.getElementById('bamboo-mid');
|
||||
const bambooNear = document.getElementById('bamboo-near');
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (bambooFar && bambooNear && !prefersReducedMotion) {
|
||||
const depthDay = document.querySelector('.bg-depth-day');
|
||||
const fireflies = Array.from(document.querySelectorAll('.firefly-wrap'), (el) => ({ el, x: 0, y: 0 }));
|
||||
const canHover = window.matchMedia('(hover: hover)').matches;
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
let pointerX = -9999;
|
||||
let pointerY = -9999;
|
||||
let framePending = false;
|
||||
|
||||
const renderForest = () => {
|
||||
framePending = false;
|
||||
bambooFar.style.transform = `translateX(${(-mouseX * 5).toFixed(1)}px)`;
|
||||
bambooNear.style.transform = `translateX(${(mouseX * 11).toFixed(1)}px)`;
|
||||
if (bambooMid) bambooMid.style.transform = `translateX(${(mouseX * 4).toFixed(1)}px)`;
|
||||
if (depthDay) depthDay.style.transform = `translate(${(-mouseX * 2.5).toFixed(1)}px, ${(mouseY * 1.5).toFixed(1)}px)`;
|
||||
|
||||
// Fireflies shy away from the cursor and drift back once it leaves
|
||||
let settling = false;
|
||||
for (const fly of fireflies) {
|
||||
const cx = fly.el.offsetLeft + 2;
|
||||
const cy = fly.el.offsetTop + 2;
|
||||
const dx = cx - pointerX;
|
||||
const dy = cy - pointerY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
let tx = 0;
|
||||
let ty = 0;
|
||||
if (dist < 130 && dist > 0.001) {
|
||||
const push = ((130 - dist) / 130) * 46;
|
||||
tx = (dx / dist) * push;
|
||||
ty = (dy / dist) * push;
|
||||
}
|
||||
fly.x += (tx - fly.x) * 0.14;
|
||||
fly.y += (ty - fly.y) * 0.14;
|
||||
if (Math.abs(tx - fly.x) > 0.4 || Math.abs(ty - fly.y) > 0.4) settling = true;
|
||||
fly.el.style.transform = `translate(${fly.x.toFixed(1)}px, ${fly.y.toFixed(1)}px)`;
|
||||
}
|
||||
if (settling) requestForestFrame();
|
||||
};
|
||||
|
||||
const requestForestFrame = () => {
|
||||
if (!framePending) {
|
||||
framePending = true;
|
||||
requestAnimationFrame(renderForest);
|
||||
}
|
||||
};
|
||||
// Let one firefly greet newly reached sections (used by the section
|
||||
// observer below; index rotates so it is not always the same one)
|
||||
let helloIndex = 0;
|
||||
forestGreet = () => {
|
||||
if (!fireflies.length) return;
|
||||
const wrap = fireflies[helloIndex % fireflies.length].el;
|
||||
helloIndex += 1;
|
||||
restartAnimation(wrap, 'firefly-hello');
|
||||
setTimeout(() => wrap.classList.remove('firefly-hello'), 1300);
|
||||
};
|
||||
window.addEventListener('resize', requestForestFrame, { passive: true });
|
||||
if (canHover) {
|
||||
window.addEventListener('pointermove', (e) => {
|
||||
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
|
||||
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
|
||||
pointerX = e.clientX;
|
||||
pointerY = e.clientY;
|
||||
requestForestFrame();
|
||||
}, { passive: true });
|
||||
window.addEventListener('pointerleave', () => {
|
||||
pointerX = -9999;
|
||||
pointerY = -9999;
|
||||
requestForestFrame();
|
||||
});
|
||||
}
|
||||
renderForest();
|
||||
}
|
||||
|
||||
// Eucalyptus click confetti: leaves burst from wherever the visitor
|
||||
// presses. Buttons and other controls shed leaves along their whole
|
||||
// width; anywhere else a small fan rises from the pointer itself. The
|
||||
// hero walkthrough's scripted cursor calls burstFromElement directly so
|
||||
// its fake clicks scatter leaves too. Rate-capped, pointer-events:none —
|
||||
// it can never block a click or flood the DOM.
|
||||
let burstFromElement = () => {};
|
||||
if (!prefersReducedMotion) {
|
||||
const spawnLeaf = (x, y) => {
|
||||
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; the keyframes brake there and
|
||||
// let the leaf sink past it while the inner element rocks.
|
||||
const angle = (-90 + (Math.random() - 0.5) * 150) * Math.PI / 180;
|
||||
const dist = 26 + Math.random() * 38;
|
||||
const dur = 0.9 + Math.random() * 0.5;
|
||||
leaf.style.left = x + 'px';
|
||||
leaf.style.top = y + '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.6).toFixed(2));
|
||||
leaf.style.setProperty('--leaf-dur', dur.toFixed(2) + 's');
|
||||
leaf.style.setProperty('--flutter-dur', (0.4 + Math.random() * 0.35).toFixed(2) + 's');
|
||||
leaf.appendChild(document.createElement('i'));
|
||||
document.body.appendChild(leaf);
|
||||
setTimeout(() => leaf.remove(), dur * 1000 + 150);
|
||||
};
|
||||
|
||||
burstFromElement = (el) => {
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!r.width || r.bottom < 0 || r.top > window.innerHeight) return;
|
||||
// Leaf count scales with the control's width so a wide demo card
|
||||
// sheds a few more than a compact icon button, capped for subtlety.
|
||||
const count = Math.max(4, Math.min(9, Math.round(r.width / 26)));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = r.left + 4 + Math.random() * Math.max(1, r.width - 8);
|
||||
// Spawn along the upper edge, like leaves knocked off the top
|
||||
const y = r.top + r.height * (0.1 + Math.random() * 0.4);
|
||||
spawnLeaf(x, y);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for `click`, not `pointerdown`: a click never fires while the
|
||||
// page is being scrolled, so touch users no longer trigger a shower of
|
||||
// leaves every time they swipe. Leaves are also only shed by actual
|
||||
// interactive controls (links/buttons and the demo widgets), so a stray
|
||||
// tap on plain text or an illustration stays quiet.
|
||||
let lastBurst = 0;
|
||||
document.addEventListener('click', (e) => {
|
||||
const control = e.target.closest('a, button, [role="button"], .btn, .mock-tab, select, .demo-progress');
|
||||
if (!control) return;
|
||||
const now = Date.now();
|
||||
if (now - lastBurst < 180) return;
|
||||
lastBurst = now;
|
||||
burstFromElement(control);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// Invite Detection & Bridge
|
||||
const checkInvite = () => {
|
||||
const isJoinPage = window.location.pathname.includes('join');
|
||||
@@ -602,16 +785,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const midOffset = (t * -24) % 160;
|
||||
const foreOffset = (t * -48) % 160;
|
||||
|
||||
// Bouncing ball logic (bounce Y is computed as parabolic arc)
|
||||
const bouncePeriod = 0.8;
|
||||
const bounceProgress = (t % bouncePeriod) / bouncePeriod;
|
||||
const bounceHeight = 16;
|
||||
const bounceY = 4 * bounceProgress * (1 - bounceProgress) * -bounceHeight;
|
||||
|
||||
// Floating lantern: slow vertical bob + softer horizontal sway on
|
||||
// detuned periods, so the drift never repeats exactly in sync
|
||||
const bounceY = Math.sin((t % 5.2) / 5.2 * Math.PI * 2) * 3.5;
|
||||
const lanternX = Math.cos((t % 8.6) / 8.6 * Math.PI * 2) * 4;
|
||||
|
||||
tab.root.style.setProperty('--scroll-back', backOffset.toFixed(2) + 'px');
|
||||
tab.root.style.setProperty('--scroll-mid', midOffset.toFixed(2) + 'px');
|
||||
tab.root.style.setProperty('--scroll-fore', foreOffset.toFixed(2) + 'px');
|
||||
tab.root.style.setProperty('--bounce-y', bounceY.toFixed(2) + 'px');
|
||||
tab.root.style.setProperty('--lantern-x', lanternX.toFixed(2) + 'px');
|
||||
};
|
||||
|
||||
const updateSyncUI = () => {
|
||||
@@ -671,10 +854,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
const pulse = (key) => {
|
||||
const el = tabs[key].root;
|
||||
el.classList.remove('sync-pulse');
|
||||
void el.offsetWidth; // restart the CSS animation
|
||||
el.classList.add('sync-pulse');
|
||||
restartAnimation(tabs[key].root, 'sync-pulse');
|
||||
};
|
||||
|
||||
const NAMES = { a: '🐱 ChillCat', b: '🐶 HappyDog' };
|
||||
@@ -732,16 +912,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
let seekFlashTimers = [];
|
||||
const flashSeek = (root) => {
|
||||
if (!root) return;
|
||||
root.classList.remove('demo-seeking');
|
||||
// Restart every animated layer from frame zero so the jump is visible
|
||||
const film = root.querySelector('.demo-film');
|
||||
if (film) {
|
||||
film.classList.add('demo-reset');
|
||||
void film.offsetWidth; // force reflow so the browser commits the reset
|
||||
film.classList.remove('demo-reset');
|
||||
requestAnimationFrame(() => {
|
||||
film.classList.remove('demo-reset');
|
||||
});
|
||||
}
|
||||
void root.offsetWidth;
|
||||
root.classList.add('demo-seeking');
|
||||
restartAnimation(root, 'demo-seeking');
|
||||
const t = setTimeout(() => root.classList.remove('demo-seeking'), 360);
|
||||
seekFlashTimers.push(t);
|
||||
};
|
||||
@@ -834,10 +1013,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
inviteFly.style.left = fromPoint.x + 'px';
|
||||
inviteFly.style.top = fromPoint.y + 'px';
|
||||
inviteFly.style.opacity = '1';
|
||||
void inviteFly.offsetWidth;
|
||||
inviteFly.style.transition = '';
|
||||
inviteFly.style.left = (toPoint.x - 40) + 'px';
|
||||
inviteFly.style.top = (toPoint.y + 2) + 'px';
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
inviteFly.style.transition = '';
|
||||
inviteFly.style.left = (toPoint.x - 40) + 'px';
|
||||
inviteFly.style.top = (toPoint.y + 2) + 'px';
|
||||
});
|
||||
});
|
||||
setTimeout(() => {
|
||||
inviteFly.style.opacity = '0';
|
||||
resolve();
|
||||
@@ -850,9 +1032,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (videoSelect.options.length > 1) {
|
||||
videoSelect.selectedIndex = 1;
|
||||
}
|
||||
videoSelect.classList.remove('demo-attn');
|
||||
void videoSelect.offsetWidth;
|
||||
videoSelect.classList.add('demo-attn');
|
||||
restartAnimation(videoSelect, 'demo-attn');
|
||||
};
|
||||
|
||||
if (createRoomBtn) createRoomBtn.addEventListener('click', () => setRoomJoined(true));
|
||||
@@ -870,8 +1050,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setConnected(false);
|
||||
setRoomJoined(false);
|
||||
}
|
||||
void scene.offsetWidth;
|
||||
scene.classList.remove('demo-no-anim');
|
||||
requestAnimationFrame(() => {
|
||||
scene.classList.remove('demo-no-anim');
|
||||
});
|
||||
|
||||
const showHint = () => {
|
||||
if (hint) hint.classList.add('show');
|
||||
@@ -916,9 +1097,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
seekFlashTimers.forEach(clearTimeout);
|
||||
seekFlashTimers = [];
|
||||
|
||||
// Force reflow and remove demo-no-anim:
|
||||
void scene.offsetWidth;
|
||||
scene.classList.remove('demo-no-anim');
|
||||
// Remove demo-no-anim on next frame:
|
||||
requestAnimationFrame(() => {
|
||||
scene.classList.remove('demo-no-anim');
|
||||
});
|
||||
};
|
||||
const finishDemo = () => {
|
||||
if (demoFinished) return;
|
||||
@@ -997,12 +1179,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (userTookOver || demoRunId !== myRunId || !el) return false;
|
||||
await moveTo(el, fx);
|
||||
if (userTookOver || demoRunId !== myRunId) return false;
|
||||
cursor.classList.remove('clicking');
|
||||
void cursor.offsetWidth;
|
||||
cursor.classList.add('clicking');
|
||||
restartAnimation(cursor, 'clicking');
|
||||
await wait(180);
|
||||
if (userTookOver || demoRunId !== myRunId) return false;
|
||||
if (action) action(); else el.click();
|
||||
// The scripted cursor's clicks scatter leaves like real ones
|
||||
burstFromElement(el);
|
||||
await wait(320 + (pause || 0));
|
||||
return !userTookOver && demoRunId === myRunId;
|
||||
};
|
||||
@@ -1499,7 +1681,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (heroBtns && heroBtns.length > 0) {
|
||||
heroBtns.forEach(btn => {
|
||||
const isFF = btn.classList.contains('btn-firefox');
|
||||
const glowColor = isFF ? 'rgba(249, 115, 22, ' : 'rgba(99, 102, 241, ';
|
||||
const glowColor = isFF ? 'rgba(201, 103, 54, ' : 'rgba(86, 174, 108, ';
|
||||
btn.animate([
|
||||
{ transform: 'scale(1)', boxShadow: `0 0 15px ${glowColor}0.2)` },
|
||||
{ transform: 'scale(1.05)', boxShadow: `0 0 25px ${glowColor}0.5)` },
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"comment": "Generated by tools/subset-flag-font.mjs — do not edit. build.cjs fails when site flags and this manifest diverge.",
|
||||
"flags": [
|
||||
"BR",
|
||||
"CN",
|
||||
"DE",
|
||||
"ES",
|
||||
"FR",
|
||||
"GB",
|
||||
"IT",
|
||||
"JP",
|
||||
"KR",
|
||||
"NL",
|
||||
"PL",
|
||||
"PT",
|
||||
"RU",
|
||||
"TR",
|
||||
"UA"
|
||||
],
|
||||
"sourceFontSha256": "9f04f14429bb6a9f415c7a4dd902a918d7e81a4f7526c415496fdb063954e3b8",
|
||||
"subsetSha256": "d6683cd924e343443effaa96008656e9914c168afad6cd8ac168af79e2b65811"
|
||||
}
|
||||
Binary file not shown.
+239
-35
@@ -7,18 +7,103 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
const esbuild = require('esbuild');
|
||||
const htmlnano = require('htmlnano');
|
||||
const { optimize: svgoOptimize } = require('svgo');
|
||||
|
||||
// CSS minifier: simple regex-based (proven, 27% reduction, no deps)
|
||||
function minifyCSS(code) {
|
||||
return code
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\s*([{}:;,])\s*/g, '$1')
|
||||
// Never strip whitespace before `:`: `.parent :is(...)` is a
|
||||
// descendant selector, while `.parent:is(...)` targets the parent.
|
||||
.replace(/\s*([{},;])\s*/g, '$1')
|
||||
.replace(/:\s+/g, ':')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/;\}/g, '}')
|
||||
.trim();
|
||||
}
|
||||
const MIN_AVIF_KB = 0;
|
||||
if (minifyCSS('.parent :is(.a, .b) { color: red; }') !== '.parent :is(.a,.b){color:red}') {
|
||||
throw new Error('CSS minifier must preserve descendant combinators before functional pseudo-classes');
|
||||
}
|
||||
// HTML only: parser-based comment removal and conservative whitespace collapse.
|
||||
// CSS, JS, JSON, and SVG keep their dedicated build steps below.
|
||||
const HTML_MINIFY_OPTIONS = Object.freeze({
|
||||
skipConfigLoading: true,
|
||||
removeComments: 'safe',
|
||||
collapseWhitespace: 'conservative'
|
||||
});
|
||||
const HTML_MINIFY_PRESET = Object.freeze({});
|
||||
|
||||
async function minifyHTML(html) {
|
||||
return (await htmlnano.process(html, HTML_MINIFY_OPTIONS, HTML_MINIFY_PRESET)).html;
|
||||
}
|
||||
|
||||
async function verifyHTMLMinifier() {
|
||||
const pre = '<pre> keep\n exactly </pre>';
|
||||
const jsonLd = '<script type="application/ld+json">{"name":"KoalaSync"}</script>';
|
||||
const conditional = '<!--[if IE]>legacy<![endif]-->';
|
||||
const result = await minifyHTML(`<!<!--nested-->--unsafe-->${pre}${jsonLd}${conditional}`);
|
||||
const ordinaryComments = result.replace(conditional, '');
|
||||
if (ordinaryComments.includes('<!--') || !result.includes(pre) || !result.includes(conditional)) {
|
||||
throw new Error('HTML minifier security/preservation invariant failed');
|
||||
}
|
||||
JSON.parse(result.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
|
||||
}
|
||||
|
||||
// ── Country-flag font subset (committed artifact, validated here) ────────
|
||||
// The subset itself is generated by tools/subset-flag-font.mjs (Python
|
||||
// fontTools — the harfbuzz-based JS subsetters drop the COLR/CPAL color
|
||||
// tables and produce invisible flags, so the build never subsets itself).
|
||||
// This step verifies the committed subset still matches the site and ships
|
||||
// it under a content-hashed name.
|
||||
function stageFlagFontSubset(websiteDir, wwwDir) {
|
||||
const { collectFlagSourceFiles, extractUsedFlags, flagToCountryCode } = require('./flag-font-utils.cjs');
|
||||
const assetsDir = path.join(websiteDir, 'assets');
|
||||
const subsetPath = path.join(assetsDir, 'TwemojiCountryFlags.subset.woff2');
|
||||
const manifestPath = path.join(assetsDir, 'TwemojiCountryFlags.subset.json');
|
||||
const regenerate = 'run "npm run subset-flags" and commit the result';
|
||||
|
||||
if (!fs.existsSync(subsetPath) || !fs.existsSync(manifestPath)) {
|
||||
throw new Error(`Flag font subset or manifest missing – ${regenerate}`);
|
||||
}
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const subset = fs.readFileSync(subsetPath);
|
||||
const sha256 = buf => crypto.createHash('sha256').update(buf).digest('hex');
|
||||
if (sha256(subset) !== manifest.subsetSha256) {
|
||||
throw new Error(`TwemojiCountryFlags.subset.woff2 does not match its manifest – ${regenerate}`);
|
||||
}
|
||||
const sourceFont = fs.readFileSync(path.join(assetsDir, 'TwemojiCountryFlags.woff2'));
|
||||
if (sha256(sourceFont) !== manifest.sourceFontSha256) {
|
||||
throw new Error(`TwemojiCountryFlags.woff2 changed since the subset was generated – ${regenerate}`);
|
||||
}
|
||||
|
||||
const usedFlags = extractUsedFlags(collectFlagSourceFiles(websiteDir));
|
||||
if (usedFlags.length === 0) {
|
||||
throw new Error('Flag extraction found no flag emoji – extraction is broken');
|
||||
}
|
||||
const usedCodes = usedFlags.map(flagToCountryCode);
|
||||
const missing = usedCodes.filter(c => !manifest.flags.includes(c));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Flags used on the site but missing from the font subset: ${missing.join(', ')} – ${regenerate}`);
|
||||
}
|
||||
const extra = manifest.flags.filter(c => !usedCodes.includes(c));
|
||||
if (extra.length > 0) {
|
||||
console.warn(` ⚠️ Subset contains unused flags (${extra.join(', ')}) – "npm run subset-flags" would shrink it.`);
|
||||
}
|
||||
|
||||
const name = `TwemojiCountryFlags.${sha8(subset)}.woff2`;
|
||||
const destAssets = path.join(wwwDir, 'assets');
|
||||
fs.mkdirSync(destAssets, { recursive: true });
|
||||
for (const f of fs.readdirSync(destAssets)) {
|
||||
if (/^TwemojiCountryFlags\.[0-9a-f]{8}\.woff2$/.test(f) && f !== name) {
|
||||
fs.unlinkSync(path.join(destAssets, f));
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(destAssets, name), subset);
|
||||
const pct = ((1 - subset.length / sourceFont.length) * 100).toFixed(0);
|
||||
console.log(` Flag font: ${name} (${(subset.length / 1024).toFixed(1)} KB, ${usedCodes.length} flags, -${pct}% vs unsubset)`);
|
||||
return name;
|
||||
}
|
||||
|
||||
function copyDirSync(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
@@ -36,7 +121,7 @@ async function minifyJS(raw) {
|
||||
const result = await esbuild.transform(raw, {
|
||||
loader: 'js',
|
||||
minify: true,
|
||||
target: 'es2020'
|
||||
target: 'es2022'
|
||||
});
|
||||
return result.code;
|
||||
}
|
||||
@@ -51,7 +136,12 @@ function injectAvifPictures(html) {
|
||||
const srcsetMatch = attrs.match(/\bsrcset="([^"]*)"/i);
|
||||
if (srcsetMatch) {
|
||||
const avifSrcset = srcsetMatch[1].replace(/\.webp/gi, '.avif');
|
||||
return `<picture><source srcset="${avifSrcset}" type="image/avif"><img${attrs}></picture>`;
|
||||
// A <source> with width descriptors requires its own sizes
|
||||
// attribute; without it browsers may pick and even reject
|
||||
// candidates (observed as broken images in Chrome).
|
||||
const sizesMatch = attrs.match(/\bsizes="([^"]*)"/i);
|
||||
const sizesAttr = sizesMatch ? ` sizes="${sizesMatch[1]}"` : '';
|
||||
return `<picture><source srcset="${avifSrcset}"${sizesAttr} type="image/avif"><img${attrs}></picture>`;
|
||||
}
|
||||
return `<picture><source srcset="${avifSrc}" type="image/avif"><img${attrs}></picture>`;
|
||||
});
|
||||
@@ -68,6 +158,7 @@ function minifyInlineSvgs(html) {
|
||||
}
|
||||
|
||||
async function compile() {
|
||||
await verifyHTMLMinifier();
|
||||
console.log('Starting KoalaSync i18n compilation...');
|
||||
const websiteDir = __dirname;
|
||||
const wwwDir = path.join(websiteDir, 'www');
|
||||
@@ -130,13 +221,62 @@ async function compile() {
|
||||
console.warn(` ⚠️ Warning: ${ogSrc} not found. Skipping og-image generation.`);
|
||||
}
|
||||
|
||||
// ── 0.7 Stage the committed flag-font subset (~45% smaller) ──
|
||||
console.log('Staging flag font subset...');
|
||||
const flagFontName = stageFlagFontSubset(websiteDir, wwwDir);
|
||||
|
||||
// ── 1. Minify CSS/JS (must happen first so hashes go into HTML) ──
|
||||
console.log('Minifying CSS/JS...');
|
||||
const styleRaw = fs.readFileSync(path.join(websiteDir, 'style.css'), 'utf8');
|
||||
// CSS references the hashed subset instead of the raw font file
|
||||
const readStyle = relativePath => fs.readFileSync(path.join(websiteDir, relativePath), 'utf8')
|
||||
.replaceAll('TwemojiCountryFlags.woff2', flagFontName);
|
||||
const styleModules = [
|
||||
'styles/foundation.css',
|
||||
'styles/hero.css',
|
||||
'styles/landing-primary.css',
|
||||
'styles/legal.css',
|
||||
'styles/landing-controls.css',
|
||||
'styles/join-spinner.css',
|
||||
'styles/landing-sections.css',
|
||||
'styles/join.css',
|
||||
'styles/landing-faq.css',
|
||||
'styles/alternatives.css',
|
||||
'styles/demo.css'
|
||||
];
|
||||
// Keep the landing cascade equivalent to styleModules while excluding
|
||||
// utility-page-only rules. A single blocking file avoids both request
|
||||
// fan-out and layout shifts from activating structural CSS after paint.
|
||||
const landingBundles = [
|
||||
{
|
||||
key: 'landing',
|
||||
modules: [
|
||||
'styles/foundation.css',
|
||||
'styles/hero.css',
|
||||
'styles/landing-primary.css',
|
||||
'styles/legal.css',
|
||||
'styles/landing-controls.css',
|
||||
'styles/landing-sections.css',
|
||||
'styles/landing-selfhost.css',
|
||||
'styles/landing-faq.css',
|
||||
'styles/demo.css'
|
||||
]
|
||||
}
|
||||
];
|
||||
const styleRaw = styleModules.map(readStyle).join('');
|
||||
const styleMin = minifyCSS(styleRaw);
|
||||
const styleHash = sha8(styleMin);
|
||||
const styleName = `style.${styleHash}.min.css`;
|
||||
const styleSRI = sha384(styleMin);
|
||||
const compiledLandingBundles = landingBundles.map(bundle => {
|
||||
const raw = bundle.modules.map(readStyle).join('');
|
||||
const min = minifyCSS(raw);
|
||||
return {
|
||||
...bundle,
|
||||
min,
|
||||
name: `${bundle.key === 'landing' ? 'landing' : `landing-${bundle.key}`}.${sha8(min)}.min.css`,
|
||||
sri: sha384(min)
|
||||
};
|
||||
});
|
||||
|
||||
const appRaw = fs.readFileSync(path.join(websiteDir, 'app.js'), 'utf8');
|
||||
const appMin = await minifyJS(appRaw);
|
||||
@@ -154,6 +294,9 @@ async function compile() {
|
||||
const appPct = ((1 - appMin.length / appRaw.length) * 100).toFixed(0);
|
||||
const langPct = ((1 - langMin.length / langRaw.length) * 100).toFixed(0);
|
||||
console.log(` CSS: ${styleName} (${(styleMin.length/1024).toFixed(1)} KB, -${stylePct}%)`);
|
||||
for (const bundle of compiledLandingBundles) {
|
||||
console.log(` Landing ${bundle.key}: ${bundle.name} (${(bundle.min.length/1024).toFixed(1)} KB)`);
|
||||
}
|
||||
console.log(` App: ${appName} (${(appMin.length/1024).toFixed(1)} KB, -${appPct}%)`);
|
||||
console.log(` Lang: ${langName} (${(langMin.length/1024).toFixed(1)} KB, -${langPct}%)`);
|
||||
|
||||
@@ -167,6 +310,9 @@ async function compile() {
|
||||
|
||||
// Write minified files
|
||||
fs.writeFileSync(path.join(wwwDir, styleName), styleMin);
|
||||
for (const bundle of compiledLandingBundles) {
|
||||
fs.writeFileSync(path.join(wwwDir, bundle.name), bundle.min);
|
||||
}
|
||||
fs.writeFileSync(path.join(wwwDir, appName), appMin);
|
||||
fs.writeFileSync(path.join(wwwDir, langName), langMin);
|
||||
|
||||
@@ -371,7 +517,8 @@ async function compile() {
|
||||
{ src: 'impressum-de.html', dest: 'de/impressum.html' },
|
||||
{ src: 'datenschutz-de.html', dest: 'de/datenschutz.html' },
|
||||
{ src: 'impressum.html', dest: 'impressum.html' },
|
||||
{ src: 'datenschutz.html', dest: 'datenschutz.html' }
|
||||
{ src: 'datenschutz.html', dest: 'datenschutz.html' },
|
||||
{ src: '404.html', dest: '404.html' }
|
||||
];
|
||||
for (const mapping of staticMappings) {
|
||||
const src = path.join(websiteDir, mapping.src);
|
||||
@@ -392,7 +539,7 @@ async function compile() {
|
||||
}
|
||||
|
||||
// ── 5.5 Generate dynamic sitemap ──
|
||||
generateSitemap(wwwDir, websiteDir);
|
||||
generateSitemap(wwwDir);
|
||||
|
||||
// Auto-copy Google verification files and IndexNow/txt key files from website source to www root
|
||||
const websiteFiles = fs.readdirSync(websiteDir);
|
||||
@@ -417,6 +564,11 @@ async function compile() {
|
||||
const destAssets = path.join(wwwDir, 'assets');
|
||||
if (fs.existsSync(srcAssets)) {
|
||||
copyDirSync(srcAssets, destAssets);
|
||||
// Pages use the hashed subset staged in step 0.7; ship neither the raw
|
||||
// font nor the unhashed subset artifacts
|
||||
fs.rmSync(path.join(destAssets, 'TwemojiCountryFlags.woff2'), { force: true });
|
||||
fs.rmSync(path.join(destAssets, 'TwemojiCountryFlags.subset.woff2'), { force: true });
|
||||
fs.rmSync(path.join(destAssets, 'TwemojiCountryFlags.subset.json'), { force: true });
|
||||
console.log(' Assets copied.');
|
||||
}
|
||||
|
||||
@@ -428,42 +580,79 @@ async function compile() {
|
||||
console.log(' ✓ Apple touch icons copied to www root.');
|
||||
}
|
||||
|
||||
// ── 7. Convert all WebP to AVIF (quality 65) ──
|
||||
console.log('Converting WebP → AVIF...');
|
||||
let avifCount = 0;
|
||||
const webpFiles = fs.readdirSync(destAssets).filter(f => f.endsWith('.webp'));
|
||||
for (const f of webpFiles) {
|
||||
const src = path.join(destAssets, f);
|
||||
const stat = fs.statSync(src);
|
||||
if (stat.size < MIN_AVIF_KB * 1024) continue;
|
||||
const dest = path.join(destAssets, f.replace(/\.webp$/, '.avif'));
|
||||
if (fs.existsSync(dest) && fs.statSync(dest).mtimeMs >= stat.mtimeMs) continue;
|
||||
await sharp(src).avif({ quality: 65, speed: 4 }).toFile(dest);
|
||||
avifCount++;
|
||||
}
|
||||
console.log(` ${avifCount} AVIF files generated.`);
|
||||
|
||||
// ── 8. Post-process ALL HTML files ──
|
||||
console.log('Post-processing HTML...');
|
||||
function walkHtml(dir, fn) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { walkHtml(p, fn); }
|
||||
else if (entry.name.endsWith('.html')) { fn(p); }
|
||||
// ── 6.5 Responsive variants for the static-page mascots ──
|
||||
// These pages showed 434-500px sources at 180px CSS (2x need: 360px).
|
||||
// Variants feed the AVIF step below and the srcset attributes in the
|
||||
// static HTML files.
|
||||
console.log('Generating responsive mascot variants...');
|
||||
const RESPONSIVE_VARIANTS = [
|
||||
{ src: 'KoalaImprintl.webp', widths: [180, 360] },
|
||||
{ src: 'KoalaPrivacy.webp', widths: [180, 360] },
|
||||
{ src: 'KoalaSearching.webp', widths: [180, 360] }
|
||||
];
|
||||
for (const variant of RESPONSIVE_VARIANTS) {
|
||||
const variantSrc = path.join(destAssets, variant.src);
|
||||
if (!fs.existsSync(variantSrc)) {
|
||||
throw new Error(`Responsive variant source ${variant.src} not found in assets`);
|
||||
}
|
||||
for (const width of variant.widths) {
|
||||
const out = variantSrc.replace(/\.webp$/, `-${width}.webp`);
|
||||
await sharp(variantSrc).resize({ width }).toFile(out);
|
||||
}
|
||||
}
|
||||
|
||||
walkHtml(wwwDir, (filePath) => {
|
||||
// ── 7. Convert all WebP to AVIF (quality 65) ──
|
||||
// Content-hash cache: mtime comparison never hit because copyDirSync
|
||||
// refreshes mtimes, so every verify re-encoded all AVIFs.
|
||||
console.log('Converting WebP → AVIF...');
|
||||
const AVIF_OPTS = { quality: 65, speed: 4 };
|
||||
const avifCachePath = path.join(websiteDir, '.avif-cache.json');
|
||||
let avifCache = {};
|
||||
try { avifCache = JSON.parse(fs.readFileSync(avifCachePath, 'utf8')); } catch { /* cold cache */ }
|
||||
const avifCacheNext = {};
|
||||
let avifCount = 0, avifCachedCount = 0;
|
||||
const webpFiles = fs.readdirSync(destAssets).filter(f => f.endsWith('.webp'));
|
||||
for (const f of webpFiles) {
|
||||
const src = path.join(destAssets, f);
|
||||
const dest = path.join(destAssets, f.replace(/\.webp$/, '.avif'));
|
||||
const key = `${sha8(fs.readFileSync(src))}:q${AVIF_OPTS.quality}s${AVIF_OPTS.speed}`;
|
||||
avifCacheNext[f] = key;
|
||||
if (avifCache[f] === key && fs.existsSync(dest)) { avifCachedCount++; continue; }
|
||||
await sharp(src).avif(AVIF_OPTS).toFile(dest);
|
||||
avifCount++;
|
||||
}
|
||||
fs.writeFileSync(avifCachePath, JSON.stringify(avifCacheNext, null, 2) + '\n');
|
||||
console.log(` ${avifCount} AVIF files generated, ${avifCachedCount} unchanged (cache).`);
|
||||
|
||||
// ── 8. Post-process ALL HTML files ──
|
||||
console.log('Post-processing HTML...');
|
||||
async function walkHtml(dir, fn) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { await walkHtml(p, fn); }
|
||||
else if (entry.name.endsWith('.html')) { await fn(p); }
|
||||
}
|
||||
}
|
||||
|
||||
await walkHtml(wwwDir, async (filePath) => {
|
||||
let html = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// 8a. Replace hashed asset refs and inject SRI (Subresource Integrity)
|
||||
html = html.replace(/(<link\b[^>]*?\bhref=")((?:\.\.\/)*)style\.min\.css"/g, (m, before, prefix) => {
|
||||
// The prefix is either any number of "../" segments or a single "/"
|
||||
// (404.html must use absolute paths: it is served for arbitrary URLs).
|
||||
html = html.replace(/(<link\b[^>]*?\bhref=")((?:\.\.\/)*\/?)style\.min\.css"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${styleName}" integrity="${styleSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*)app\.min\.js"/g, (m, before, prefix) => {
|
||||
for (const bundle of compiledLandingBundles) {
|
||||
const placeholder = bundle.key === 'landing' ? 'landing.min.css' : `landing-${bundle.key}.min.css`;
|
||||
html = html.replace(new RegExp(`(<link\\b[^>]*?\\bhref=")((?:\\.\\.\\/)*\\/?)${placeholder.replaceAll('.', '\\.') }"`, 'g'), (m, before, prefix) => {
|
||||
return `${before}${prefix}${bundle.name}" integrity="${bundle.sri}" crossorigin="anonymous"`;
|
||||
});
|
||||
}
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)app\.min\.js"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${appName}" integrity="${appSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*)lang-init\.min\.js"/g, (m, before, prefix) => {
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)lang-init\.min\.js"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${langName}" integrity="${langSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
|
||||
@@ -473,13 +662,29 @@ async function compile() {
|
||||
// 8c. Minify inline SVGs
|
||||
html = minifyInlineSvgs(html);
|
||||
|
||||
// 8d. Preload the subset flag font on pages that show flags. This
|
||||
// cuts the HTML→CSS→font chain; unicode-range alone would delay the
|
||||
// request until after CSS parse + layout.
|
||||
if (/[\u{1F1E6}-\u{1F1FF}]{2}/u.test(html)) {
|
||||
const relDir = path.relative(wwwDir, path.dirname(filePath));
|
||||
// 404.html is served for arbitrary URLs and must use absolute paths
|
||||
const prefix = path.basename(filePath) === '404.html'
|
||||
? '/'
|
||||
: (relDir ? '../'.repeat(relDir.split(path.sep).length) : '');
|
||||
const preload = `<link rel="preload" as="font" type="font/woff2" href="${prefix}assets/${flagFontName}" crossorigin>`;
|
||||
html = html.replace('</head>', `${preload}\n</head>`);
|
||||
}
|
||||
|
||||
// 8e. Minify HTML (comments + indentation)
|
||||
html = await minifyHTML(html);
|
||||
|
||||
fs.writeFileSync(filePath, html);
|
||||
});
|
||||
|
||||
console.log('KoalaSync build finished successfully! Output: website/www/');
|
||||
}
|
||||
|
||||
function generateSitemap(wwwDir, websiteDir) {
|
||||
function generateSitemap(wwwDir) {
|
||||
const languages = [
|
||||
{ code: 'en', prefix: '', hreflang: 'en' },
|
||||
{ code: 'de', prefix: 'de/', hreflang: 'de' },
|
||||
@@ -569,7 +774,6 @@ function generateSitemap(wwwDir, websiteDir) {
|
||||
|
||||
xml += `\n</urlset>\n`;
|
||||
|
||||
fs.writeFileSync(path.join(websiteDir, 'sitemap.xml'), xml.trim() + '\n', 'utf8');
|
||||
fs.writeFileSync(path.join(wwwDir, 'sitemap.xml'), xml.trim() + '\n', 'utf8');
|
||||
console.log(' ✓ Dynamically generated sitemap.xml with current date');
|
||||
}
|
||||
|
||||
@@ -42,23 +42,86 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="../lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<img src="../assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
@@ -98,7 +161,7 @@
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="../assets/KoalaPrivacy.webp" alt="Niedlicher Koala, der den Datenschutz repräsentiert" class="legal-mascot" width="180" height="180">
|
||||
<img src="../assets/KoalaPrivacy-180.webp" srcset="../assets/KoalaPrivacy-180.webp 180w, ../assets/KoalaPrivacy-360.webp 360w" sizes="180px" alt="Niedlicher Koala, der den Datenschutz repräsentiert" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1>Datenschutz</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared helpers for the country-flag font subset.
|
||||
* Used by build.cjs (validation) and tools/subset-flag-font.mjs (generation).
|
||||
*
|
||||
* Flags are regional-indicator pairs; tag-sequence flags (🏴 England etc.)
|
||||
* are NOT extracted or subset — if one is ever added it will render via the
|
||||
* system emoji font.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FLAG_PAIR_RE = /[\u{1F1E6}-\u{1F1FF}]{2}/gu;
|
||||
|
||||
function collectFlagSourceFiles(websiteDir) {
|
||||
const files = [];
|
||||
for (const entry of fs.readdirSync(websiteDir)) {
|
||||
if (entry.endsWith('.html')) files.push(path.join(websiteDir, entry));
|
||||
}
|
||||
for (const dir of ['alternatives', 'locales']) {
|
||||
const p = path.join(websiteDir, dir);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
for (const entry of fs.readdirSync(p)) {
|
||||
if (/\.(html|json)$/.test(entry)) files.push(path.join(p, entry));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractUsedFlags(files) {
|
||||
const flags = new Set();
|
||||
for (const file of files) {
|
||||
for (const m of fs.readFileSync(file, 'utf8').matchAll(FLAG_PAIR_RE)) {
|
||||
flags.add(m[0]);
|
||||
}
|
||||
}
|
||||
return [...flags].sort();
|
||||
}
|
||||
|
||||
// 🇩🇪 → "DE" (for readable manifests and tool output)
|
||||
function flagToCountryCode(flag) {
|
||||
return [...flag].map(c => String.fromCharCode(c.codePointAt(0) - 0x1F1E6 + 65)).join('');
|
||||
}
|
||||
|
||||
function countryCodeToFlag(code) {
|
||||
return [...code].map(c => String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65)).join('');
|
||||
}
|
||||
|
||||
module.exports = { FLAG_PAIR_RE, collectFlagSourceFiles, extractUsedFlags, flagToCountryCode, countryCodeToFlag };
|
||||
+75
-12
@@ -42,23 +42,86 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="../lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<img src="../assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
@@ -98,7 +161,7 @@
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="../assets/KoalaImprintl.webp" alt="Niedlicher Koala, der das Impressum repräsentiert" class="legal-mascot" width="180" height="180">
|
||||
<img src="../assets/KoalaImprintl-180.webp" srcset="../assets/KoalaImprintl-180.webp 180w, ../assets/KoalaImprintl-360.webp 360w" sizes="180px" alt="Niedlicher Koala, der das Impressum repräsentiert" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1>Impressum / Anbieterinformationen</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
@@ -112,12 +175,12 @@
|
||||
E-Mail: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[E-Mail anzeigen]</span>
|
||||
</p>
|
||||
<p style="margin-top: 0.75rem;">
|
||||
<span style="opacity: 0.6;">🔒 Private Nachricht:</span>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--accent); text-decoration: none;">@koalastuff auf Mastodon</a>
|
||||
<span class="legal-inline-label">🔒 Private Nachricht:</span>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" class="legal-inline-link">@koalastuff auf Mastodon</a>
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
<span style="opacity: 0.6;">🐛 Fehlerberichte und Sicherheitsmeldungen:</span>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" style="color: var(--accent); text-decoration: none;">GitHub Issues</a>
|
||||
<span class="legal-inline-label">🐛 Fehlerberichte und Sicherheitsmeldungen:</span>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="legal-inline-link">GitHub Issues</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -127,7 +190,7 @@
|
||||
KoalaSync ist eine kostenlose und quelloffene Browser-Erweiterung. Der Quellcode ist auf GitHub unter der MIT-Lizenz verfügbar.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Informationen zum Datenschutz, einschließlich Hosting, Access Logs, Relay-Verhalten und Betroffenenrechten, finden Sie in der <a href="datenschutz" style="color: var(--accent); text-decoration: none;">Datenschutzerklärung</a>.
|
||||
Informationen zum Datenschutz, einschließlich Hosting, Access Logs, Relay-Verhalten und Betroffenenrechten, finden Sie in der <a href="datenschutz" class="legal-inline-link">Datenschutzerklärung</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
+75
-12
@@ -42,23 +42,86 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<img src="assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
@@ -98,7 +161,7 @@
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="assets/KoalaImprintl.webp" alt="Cute Koala representing the legal notice page" class="legal-mascot" width="180" height="180">
|
||||
<img src="assets/KoalaImprintl-180.webp" srcset="assets/KoalaImprintl-180.webp 180w, assets/KoalaImprintl-360.webp 360w" sizes="180px" alt="Cute Koala representing the legal notice page" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1>Legal Notice</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
@@ -112,12 +175,12 @@
|
||||
E-Mail: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[Show Email]</span>
|
||||
</p>
|
||||
<p style="margin-top: 0.75rem;">
|
||||
<span style="opacity: 0.6;">🔒 Private message:</span>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--accent); text-decoration: none;">@koalastuff on Mastodon</a>
|
||||
<span class="legal-inline-label">🔒 Private message:</span>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" class="legal-inline-link">@koalastuff on Mastodon</a>
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
<span style="opacity: 0.6;">🐛 Bug reports and security concerns:</span>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" style="color: var(--accent); text-decoration: none;">GitHub Issues</a>
|
||||
<span class="legal-inline-label">🐛 Bug reports and security concerns:</span>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="legal-inline-link">GitHub Issues</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -127,7 +190,7 @@
|
||||
KoalaSync is a free and open-source browser extension. The source code is available on GitHub under the MIT License.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
For privacy-related information, including hosting, access logs, relay behavior, and data subject rights, please see the <a href="privacy" style="color: var(--accent); text-decoration: none;">Privacy Policy</a>.
|
||||
For privacy-related information, including hosting, access logs, relay behavior, and data subject rights, please see the <a href="privacy" class="legal-inline-link">Privacy Policy</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
+73
-10
@@ -27,23 +27,86 @@
|
||||
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/og-image.png">
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="64" height="64">
|
||||
<img src="assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="64" height="64">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
|
||||
@@ -92,8 +155,8 @@
|
||||
<div class="join-status-visual">
|
||||
<div class="status-ring active-pulse" id="status-ring"></div>
|
||||
<div id="join-status-icon" style="z-index: 2; transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); display: flex; align-items: center; justify-content: center;">
|
||||
<img src="assets/KoalaSearching.webp" alt="A cute koala looking through a telescope searching for your extension" class="join-status-mascot searching-mascot join-status-pulse" lang="en">
|
||||
<img src="assets/KoalaSearching.webp" alt="Ein niedlicher Koala schaut durch ein Fernrohr und sucht nach deiner Erweiterung" class="join-status-mascot searching-mascot join-status-pulse" lang="de">
|
||||
<img src="assets/KoalaSearching-180.webp" srcset="assets/KoalaSearching-180.webp 180w, assets/KoalaSearching-360.webp 360w" sizes="175px" alt="A cute koala looking through a telescope searching for your extension" class="join-status-mascot searching-mascot join-status-pulse" lang="en">
|
||||
<img src="assets/KoalaSearching-180.webp" srcset="assets/KoalaSearching-180.webp 180w, assets/KoalaSearching-360.webp 360w" sizes="175px" alt="Ein niedlicher Koala schaut durch ein Fernrohr und sucht nach deiner Erweiterung" class="join-status-mascot searching-mascot join-status-pulse" lang="de">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,14 +184,14 @@
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. <span lang="en">Open source under the MIT License.</span><span lang="de">Open Source unter der MIT-Lizenz.</span></p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;"><span lang="en">No relay logs. No tracking. Website access logs are deleted after 7 days.</span><span lang="de">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</span></p>
|
||||
<p style="font-size: 0.72rem; margin-top: 1.2rem; color: var(--text-muted); opacity: 0.65; max-width: 650px; margin-left: auto; margin-right: auto; line-height: 1.5;"><span lang="en">KoalaSync is not affiliated with, endorsed by, or associated with Netflix, Disney+, Amazon, YouTube, Twitch, or any other streaming platform. All trademarks are property of their respective owners.</span><span lang="de">KoalaSync ist nicht mit Netflix, Disney+, Amazon, YouTube, Twitch oder anderen Streaming-Plattformen verbunden, wird von diesen nicht unterstützt oder steht in keiner Beziehung zu diesen. Alle Markenrechte liegen bei ihren jeweiligen Inhabern.</span></p>
|
||||
<p class="footer-disclaimer" style="margin-top: 1.2rem;"><span lang="en">KoalaSync is not affiliated with, endorsed by, or associated with Netflix, Disney+, Amazon, YouTube, Twitch, or any other streaming platform. All trademarks are property of their respective owners.</span><span lang="de">KoalaSync ist nicht mit Netflix, Disney+, Amazon, YouTube, Twitch oder anderen Streaming-Plattformen verbunden, wird von diesen nicht unterstützt oder steht in keiner Beziehung zu diesen. Alle Markenrechte liegen bei ihren jeweiligen Inhabern.</span></p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="imprint" lang="en" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="de/impressum" lang="de" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="privacy" lang="en" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
<a href="de/datenschutz" lang="de" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives"><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></a>
|
||||
<a href="https://support.koalastuff.net" target="_blank" rel="noopener" title="Support KoalaSync" style="color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; background: #c9302c; border: 1px solid rgba(255,255,255,0.18); border-radius: 999px; padding: 3px 9px; font-size: 0.72rem; font-weight: 700; line-height: 1;">
|
||||
<a href="https://support.koalastuff.net" target="_blank" rel="noopener" title="Support KoalaSync" style="color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; background: #b94642; border: 1px solid rgba(255,255,255,0.18); border-radius: 999px; padding: 3px 9px; font-size: 0.72rem; font-weight: 700; line-height: 1;">
|
||||
<span aria-hidden="true">♥</span>
|
||||
<span>Support KoalaSync</span>
|
||||
</a>
|
||||
|
||||
+106
-28
@@ -1,39 +1,117 @@
|
||||
# KoalaSync
|
||||
|
||||
> Watch Netflix, YouTube, Twitch & any HTML5 video in perfect sync with friends. Free, open-source browser extension for Chrome and Firefox. No sign-up needed.
|
||||
> KoalaSync is a free, open-source browser extension that keeps video playback synchronized between friends. It works with Netflix, YouTube, Twitch, Emby, Jellyfin and most websites that expose an HTML5 video player. No account is required.
|
||||
|
||||
KoalaSync is a privacy-first peer-to-peer video synchronization tool. It uses WebRTC for direct connections between peers — no video data ever passes through a server. The relay server is only used for signaling and optional room-based coordination. Self-hostable, MIT licensed.
|
||||
KoalaSync synchronizes playback controls; it does not stream, proxy, host, download or unlock video. Every participant watches the content locally in their own browser and must have legitimate access to the selected website or streaming service.
|
||||
|
||||
## Features
|
||||
## Quick fit assessment
|
||||
|
||||
- [Sync any HTML5 video](https://sync.koalastuff.net/): Works with Netflix, Emby, Jellyfin, YouTube, Twitch, Amazon Prime Video, Disney+, Crunchyroll, HBO Max and more
|
||||
- [Host & co-host controls](https://sync.koalastuff.net/#features): Synchronized play, pause, seek, host control mode, co-host roles
|
||||
- [Privacy-first](https://sync.koalastuff.net/#features): No accounts, no tracking, no data collection, peer-to-peer by design
|
||||
- [Available in 15 languages](https://sync.koalastuff.net/): English, Deutsch, Français, Español, Português, Italiano, Nederlands, Polski, Türkçe, Русский, 日本語, 한국어, 中文, Українська
|
||||
KoalaSync is a good fit when users:
|
||||
|
||||
## Download
|
||||
- want synchronized play, pause and seeking while watching remotely
|
||||
- already use a separate voice or video chat application
|
||||
- want to keep the streaming site's native video and audio quality
|
||||
- need support beyond a single streaming platform
|
||||
- prefer a free, ad-free and open-source tool without user accounts
|
||||
- want the option to self-host the synchronization relay
|
||||
|
||||
- [Chrome Web Store](https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc): Install KoalaSync for Chrome, Edge, and other Chromium-based browsers
|
||||
- [Firefox Add-ons](https://addons.mozilla.org/de/firefox/addon/koalasync/): Install KoalaSync for Firefox
|
||||
- [GitHub Releases](https://github.com/Shik3i/KoalaSync/releases): Manual download and release notes
|
||||
KoalaSync is not a fit when users need:
|
||||
|
||||
## Technical
|
||||
- video or screen sharing to people who do not have access to the content
|
||||
- a hosted video library, remote browser or cloud-streaming service
|
||||
- built-in text, voice or webcam chat
|
||||
- a mobile app or Safari extension
|
||||
- guaranteed compatibility with a player that does not expose an accessible HTML5 video element
|
||||
|
||||
- [Source code](https://github.com/Shik3i/KoalaSync): Full MIT-licensed source on GitHub (extension, server, website)
|
||||
- [Changelog](https://github.com/Shik3i/KoalaSync/blob/main/docs/CHANGELOG.md): Version history and release notes
|
||||
- [Self-hosting guide](https://sync.koalastuff.net/#self-hosting): Run your own relay server
|
||||
- [Server](https://sync.koalastuff.net/): Node.js WebSocket relay, port 54000
|
||||
## How it works
|
||||
|
||||
## Optional
|
||||
1. Each participant installs KoalaSync for Chrome, Firefox or a compatible desktop browser.
|
||||
2. One participant creates a room and shares the generated invitation link.
|
||||
3. Each participant opens the video locally and selects its browser tab in KoalaSync.
|
||||
4. The extensions exchange room and playback events through a lightweight WebSocket relay.
|
||||
5. Each extension applies play, pause and seek actions to the selected local video element.
|
||||
|
||||
- [Alternatives hub](https://sync.koalastuff.net/alternatives/): Overview of watch party guide and comparisons
|
||||
- [Teleparty alternative guide](https://sync.koalastuff.net/alternatives/teleparty): Comparison with Teleparty / Netflix Party
|
||||
- [Watch2Gether alternative guide](https://sync.koalastuff.net/alternatives/watch2gether): Comparison with Watch2Gether
|
||||
- [Scener alternative guide](https://sync.koalastuff.net/alternatives/scener): Comparison with Scener
|
||||
- [Kosmi alternative guide](https://sync.koalastuff.net/alternatives/kosmi): Comparison with Kosmi
|
||||
- [TwoSeven alternative guide](https://sync.koalastuff.net/alternatives/twoseven): Comparison with TwoSeven
|
||||
- [Screen sharing guide](https://sync.koalastuff.net/alternatives/screen-sharing): Guide on browser-based screen sharing
|
||||
- [Privacy policy](https://sync.koalastuff.net/privacy.html): Data handling and privacy information
|
||||
- [Imprint](https://sync.koalastuff.net/imprint.html): Legal notice
|
||||
- [Sitemap](https://sync.koalastuff.net/sitemap.xml): Full site index
|
||||
- [Join a room](https://sync.koalastuff.net/join.html): Direct room join page
|
||||
The relay forwards synchronization messages between room participants. It never receives or forwards the video or audio stream itself. The official public relay is reached through `wss://syncserver.koalastuff.net`; self-hosted deployments commonly run the Node.js service internally on port 3000 behind a TLS reverse proxy.
|
||||
|
||||
## Main features
|
||||
|
||||
- Synchronized play, pause and seeking
|
||||
- Host-only control mode and assignable co-host roles
|
||||
- Episode auto-sync with a lobby that waits until participants are ready
|
||||
- Video-title matching to help users select corresponding tabs
|
||||
- Optional manual force-sync controls
|
||||
- Local audio compressor for balancing quiet dialogue and loud scenes
|
||||
- Invitation links that can configure official or custom relay servers
|
||||
- Interface translations in 15 languages
|
||||
- Self-hostable Node.js WebSocket relay
|
||||
|
||||
## Privacy and security
|
||||
|
||||
- No KoalaSync account or registration
|
||||
- No advertising, analytics, tracking pixels or third-party telemetry
|
||||
- No persistent room database; active room and peer state exists in relay memory only
|
||||
- Room state is removed when participants leave and inactive rooms expire
|
||||
- The official relay processes synchronization state, peer identifiers, usernames and optional media metadata while a room is active
|
||||
- IP addresses are processed temporarily for connection limits and abuse protection
|
||||
- Official relay console events are ephemeral and are not archived as persistent user logs
|
||||
- Invitation credentials are placed in the URL fragment, which is not sent to the website server
|
||||
- The extension activates its video-control logic only in the tab selected by the user
|
||||
- Source code for the extension, relay and website is publicly auditable under the MIT License
|
||||
|
||||
For exact retention periods, metadata controls and permission details, read the [privacy policy](https://sync.koalastuff.net/privacy) and [technical privacy documentation](https://github.com/Shik3i/KoalaSync/blob/main/docs/PRIVACY.md).
|
||||
|
||||
## Supported environments
|
||||
|
||||
- Google Chrome
|
||||
- Mozilla Firefox
|
||||
- Microsoft Edge and other Chromium-based desktop browsers
|
||||
- Windows, macOS, Linux and ChromeOS
|
||||
- Websites with an accessible HTML5 `<video>` element
|
||||
|
||||
Compatibility depends on each website's player implementation and can change when a streaming provider updates its site. See the [tested-services matrix](https://github.com/Shik3i/KoalaSync/blob/main/docs/TESTED_SERVICES.md) for current details and limitations.
|
||||
|
||||
## Install KoalaSync
|
||||
|
||||
- [Chrome Web Store](https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc): Chrome, Edge and compatible Chromium browsers
|
||||
- [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/koalasync/): Firefox
|
||||
- [GitHub Releases](https://github.com/Shik3i/KoalaSync/releases): Release notes and manual packages
|
||||
|
||||
## Technical information
|
||||
|
||||
- Current website release: 2.5.4
|
||||
- License: MIT
|
||||
- Extension runtime: dependency-free browser extension code
|
||||
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
|
||||
- Persistence: RAM-only room state; no room database
|
||||
- Official relay: `wss://syncserver.koalastuff.net`
|
||||
- Self-hosting: Docker image `ghcr.io/shik3i/koalasync:latest`
|
||||
- Source repository: [github.com/Shik3i/KoalaSync](https://github.com/Shik3i/KoalaSync)
|
||||
|
||||
## Documentation
|
||||
|
||||
- [How KoalaSync works](https://github.com/Shik3i/KoalaSync/blob/main/docs/HOW_IT_WORKS.md)
|
||||
- [Architecture](https://github.com/Shik3i/KoalaSync/blob/main/docs/ARCHITECTURE.md)
|
||||
- [WebSocket protocol](https://github.com/Shik3i/KoalaSync/blob/main/docs/PROTOCOL.md)
|
||||
- [Privacy](https://github.com/Shik3i/KoalaSync/blob/main/docs/PRIVACY.md)
|
||||
- [Security policy](https://github.com/Shik3i/KoalaSync/blob/main/SECURITY.md)
|
||||
- [Self-hosting guide](https://sync.koalastuff.net/#self-hosting)
|
||||
- [Changelog](https://github.com/Shik3i/KoalaSync/blob/main/docs/CHANGELOG.md)
|
||||
|
||||
## Comparisons and alternatives
|
||||
|
||||
- [Watch-party comparison overview](https://sync.koalastuff.net/alternatives/)
|
||||
- [KoalaSync vs Teleparty](https://sync.koalastuff.net/alternatives/teleparty)
|
||||
- [KoalaSync vs Watch2Gether](https://sync.koalastuff.net/alternatives/watch2gether)
|
||||
- [KoalaSync vs Scener](https://sync.koalastuff.net/alternatives/scener)
|
||||
- [KoalaSync vs Kosmi](https://sync.koalastuff.net/alternatives/kosmi)
|
||||
- [KoalaSync vs TwoSeven](https://sync.koalastuff.net/alternatives/twoseven)
|
||||
- [KoalaSync vs screen sharing](https://sync.koalastuff.net/alternatives/screen-sharing)
|
||||
|
||||
## Contact and project status
|
||||
|
||||
- [Main website](https://sync.koalastuff.net/)
|
||||
- [GitHub issues](https://github.com/Shik3i/KoalaSync/issues): Bugs, compatibility reports and feature requests
|
||||
- [Mastodon](https://mastodon.social/@koalastuff): Private contact and project updates
|
||||
- [Legal notice](https://sync.koalastuff.net/imprint)
|
||||
|
||||
KoalaSync is an independent project and is not affiliated with, endorsed by or associated with Netflix, Disney+, Amazon, YouTube, Twitch or any other streaming platform. All trademarks belong to their respective owners.
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"DEMO_JOINED": "Raum beigetreten",
|
||||
"DEMO_TAB_SELECTED": "Tab ausgewählt",
|
||||
"DEMO_SELECT_PLACEHOLDER": "Video-Tab wählen…",
|
||||
"MOBILE_DEMO_CTA": "So funktioniert's ansehen",
|
||||
"MOBILE_DEMO_CTA": "Live-Demo ansehen",
|
||||
"MOBILE_DEMO_TITLE": "KoalaSync Live-Demo",
|
||||
"MOBILE_DEMO_CLOSE": "Demo schließen",
|
||||
"ADD_TO_CHROME": "Zu Chrome hinzufügen",
|
||||
|
||||
+70
-7
@@ -42,23 +42,86 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<img src="assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
@@ -98,7 +161,7 @@
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="assets/KoalaPrivacy.webp" alt="Cute Koala representing privacy and data security" class="legal-mascot" width="180" height="180">
|
||||
<img src="assets/KoalaPrivacy-180.webp" srcset="assets/KoalaPrivacy-180.webp 180w, assets/KoalaPrivacy-360.webp 360w" sizes="180px" alt="Cute Koala representing privacy and data security" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"description": "Real-time video synchronization for friends. Privacy-first, open source.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"background_color": "#10190e",
|
||||
"theme_color": "#10190e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/NewLogoIcon_64.webp",
|
||||
|
||||
-2668
File diff suppressed because it is too large
Load Diff
+18
-5294
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
/* --- Alternatives Hub Styles --- */
|
||||
.guide-card {
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.25s ease, border-color 0.25s ease !important;
|
||||
}
|
||||
.guide-card:hover {
|
||||
transform: translateY(-4px);
|
||||
background-color: rgba(255, 255, 255, 0.07) !important;
|
||||
border-color: rgba(255, 255, 255, 0.22) !important;
|
||||
box-shadow: 0 12px 24px -10px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
html.theme-light .guide-card {
|
||||
background-color: var(--card-surface) !important;
|
||||
border-color: var(--card-border) !important;
|
||||
}
|
||||
html.theme-light .guide-card:hover {
|
||||
background-color: var(--card) !important;
|
||||
border-color: oklch(0.52 0.12 150 / 0.35) !important;
|
||||
}
|
||||
.guide-card .btn-primary {
|
||||
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||
}
|
||||
.guide-card .btn-primary:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.guides-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 1.25rem !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+1548
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,941 @@
|
||||
/* --- Hero Section --- */
|
||||
.hero {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
/* Centre the hero content vertically so the empty space is balanced above
|
||||
and below instead of pooling under the CTAs. Padding-top only needs to
|
||||
clear the fixed nav. */
|
||||
align-items: center;
|
||||
padding-top: clamp(6rem, 9vh, 8rem);
|
||||
padding-bottom: clamp(3rem, 6vh, 5rem);
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
/* Single column until the interactive demo appears (1024px). Below that the
|
||||
headline gets the full width and the install CTAs stay prominent while the
|
||||
demo stacks underneath, matching the demo's own 1024px breakpoint. */
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-content: center;
|
||||
/* Keep the whole hero vertically centered. Do not use grid alignment to fix
|
||||
the demo top edge; that moves the headline and breaks the hero balance. */
|
||||
align-items: center;
|
||||
gap: 3rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.hero-grid {
|
||||
grid-template-columns: 1.35fr 1fr;
|
||||
gap: 3.5rem;
|
||||
}
|
||||
.cta-group {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.cta-group .btn {
|
||||
padding: 0.8rem 1.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-text h1 {
|
||||
font-size: clamp(2.9rem, 4.1vw, 4rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.12;
|
||||
margin-bottom: 1.4rem;
|
||||
padding-bottom: 0.1em;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero-text h1 .hero-highlight {
|
||||
background: linear-gradient(90deg, var(--accent-green), var(--accent-terracotta), var(--accent-green));
|
||||
background-size: 300% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
white-space: nowrap;
|
||||
animation: hero-gradient-pan 10s linear infinite;
|
||||
/* background-clip: text only paints inside the layout box; with the
|
||||
negative h1 letter-spacing the last glyph overhangs it and gets cut
|
||||
off. Invisible padding extends the paint area without moving text. */
|
||||
padding-right: 0.08em;
|
||||
margin-right: -0.08em;
|
||||
}
|
||||
|
||||
@keyframes hero-gradient-pan {
|
||||
from { background-position: 0% 50%; }
|
||||
to { background-position: 300% 50%; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-text h1 .hero-highlight {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-text p, .hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.9rem;
|
||||
max-width: 600px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-mascot-container {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin-bottom: -0.5rem;
|
||||
margin-left: 2.2rem;
|
||||
}
|
||||
|
||||
.hero-lookdown-mascot {
|
||||
display: block;
|
||||
width: 90px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Changelog link as the koala's comic speech bubble. Absolutely positioned
|
||||
so it never moves the koala out of its spot above the install buttons. */
|
||||
.koala-speech-bubble {
|
||||
position: absolute;
|
||||
left: 108px;
|
||||
top: 0.2rem;
|
||||
width: max-content;
|
||||
max-width: min(240px, calc(100% - 120px));
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
background: oklch(0.27 0.035 130 / 0.95);
|
||||
border: 1px solid oklch(0.68 0.13 150 / 0.45);
|
||||
border-radius: 14px;
|
||||
border-bottom-left-radius: 4px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
line-height: 1.35;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
animation: bubble-float 4s ease-in-out infinite;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
/* Tail: outline triangle + fill triangle so it reads as part of the bubble */
|
||||
.koala-speech-bubble::before,
|
||||
.koala-speech-bubble::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.koala-speech-bubble::before {
|
||||
left: -11px;
|
||||
bottom: 10px;
|
||||
border-top: 8px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-right: 11px solid oklch(0.68 0.13 150 / 0.45);
|
||||
}
|
||||
|
||||
.koala-speech-bubble::after {
|
||||
left: -9px;
|
||||
bottom: 11px;
|
||||
border-top: 7px solid transparent;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-right: 10px solid oklch(0.27 0.035 130 / 0.95);
|
||||
}
|
||||
|
||||
.koala-speech-bubble:hover {
|
||||
border-color: oklch(0.68 0.13 150 / 0.85);
|
||||
box-shadow: 0 8px 28px oklch(0.68 0.13 150 / 0.35);
|
||||
}
|
||||
|
||||
.koala-speech-bubble .version-text-en,
|
||||
.koala-speech-bubble .version-text-de {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.koala-speech-bubble .bubble-title {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.koala-speech-bubble .bubble-sub {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
html.theme-light .koala-speech-bubble {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 8px 24px oklch(0.20 0.025 140 / 0.12);
|
||||
}
|
||||
|
||||
html.theme-light .koala-speech-bubble::after {
|
||||
border-right-color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
@keyframes bubble-float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.koala-speech-bubble {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stacked hero (below the 1024px demo breakpoint): the text block owns the
|
||||
full row, so the whole column — headline, subtitle, koala, CTAs — reads
|
||||
as one centered axis instead of a left-aligned half of a two-column grid. */
|
||||
@media (max-width: 1023px) {
|
||||
.hero-grid {
|
||||
text-align: center;
|
||||
}
|
||||
/* Lift the desktop measure cap: the text block is the full row here,
|
||||
so a left-flushed 42rem box would sit visibly off-center. */
|
||||
.hero-text {
|
||||
max-width: none;
|
||||
}
|
||||
.hero-text p,
|
||||
.hero-subtitle,
|
||||
.hero-text .cta-trust {
|
||||
margin-inline: auto;
|
||||
}
|
||||
.cta-group {
|
||||
justify-content: center;
|
||||
}
|
||||
/* The koala keeps standing on the active install CTA; app.js updates the
|
||||
offset from the real button geometry when the row wraps. */
|
||||
.hero-mascot-container {
|
||||
--hero-mascot-offset: 0px;
|
||||
margin-left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.hero-lookdown-mascot {
|
||||
transform: translateX(var(--hero-mascot-offset));
|
||||
}
|
||||
.koala-speech-bubble {
|
||||
left: calc(50% + var(--hero-mascot-offset) + 50px);
|
||||
right: 0.75rem;
|
||||
top: 0.3rem;
|
||||
max-width: 240px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.koala-speech-bubble .bubble-title {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.koala-speech-bubble .bubble-sub {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
/* The relative release time is too much text for the small mobile bubble */
|
||||
.koala-speech-bubble .bubble-ago {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cta-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Quiet trust line under the install buttons; answers "is it free, is it
|
||||
legit, do I need an account" right at the moment of the click decision. */
|
||||
.hero-text .cta-trust {
|
||||
margin-top: 0.85rem;
|
||||
margin-bottom: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.75;
|
||||
letter-spacing: 0.01em;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.85rem 1.75rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1), color 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, oklch(0.48 0.13 150), oklch(0.39 0.10 145));
|
||||
color: #fff;
|
||||
border: 1px solid oklch(0.72 0.11 95 / 0.24);
|
||||
box-shadow: 0 12px 24px -8px oklch(0.38 0.11 148 / 0.65), 0 0 0 1px oklch(0.80 0.08 90 / 0.06) inset;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, oklch(0.54 0.14 150), oklch(0.44 0.11 145));
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 18px 32px -8px oklch(0.38 0.11 148 / 0.75), 0 0 18px oklch(0.68 0.13 150 / 0.18);
|
||||
}
|
||||
|
||||
.btn-firefox {
|
||||
background: oklch(0.62 0.14 45);
|
||||
color: white;
|
||||
box-shadow: 0 10px 15px -3px oklch(0.62 0.14 45 / 0.3);
|
||||
}
|
||||
|
||||
.btn-firefox:hover {
|
||||
background: oklch(0.68 0.14 45);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 25px -5px oklch(0.62 0.14 45 / 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: oklch(0.30 0.03 125);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-demo-mobile {
|
||||
display: none;
|
||||
border: 1px solid oklch(0.68 0.13 150 / 0.34);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn-demo-mobile svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Install buttons: compact by default (just the browser name) so all three
|
||||
CTAs fit one line; the detected browser expands to the full call to action.
|
||||
The GitHub button stays compact always. */
|
||||
.btn-label-full { display: none; }
|
||||
|
||||
.btn-install:not(.is-detected),
|
||||
.btn-compact {
|
||||
padding: 0.62rem 1.05rem;
|
||||
font-size: 0.9rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.btn-install.is-detected .btn-label-short { display: none; }
|
||||
.btn-install.is-detected .btn-label-full { display: inline; }
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
background: oklch(0.68 0.13 150 / 0.1);
|
||||
color: var(--accent);
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 99px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid oklch(0.68 0.13 150 / 0.3);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.version-badge-below {
|
||||
margin-bottom: 0;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.version-badge:hover {
|
||||
background: oklch(0.68 0.13 150 / 0.2);
|
||||
box-shadow: 0 0 16px oklch(0.68 0.13 150 / 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* --- Interactive CSS Extension Mockup --- */
|
||||
.hero-mockup-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.extension-mockup {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
height: 528px;
|
||||
background: oklch(0.20 0.025 140);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4), 0 0 20px oklch(0.68 0.13 150 / 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
padding: 14px;
|
||||
padding-bottom: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mock-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mock-header-title {
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mock-header-title picture {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.mock-header-title img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mock-version-link {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s, color 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mock-version-link:hover {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Close button for the hero demo popup (hidden in the static mobile mockup) */
|
||||
.mock-close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-left: 8px;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.mock-close-btn:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.mock-close-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mock-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: oklch(0.27 0.035 130);
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mock-tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 7px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mock-tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mock-tab.active {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-green);
|
||||
}
|
||||
|
||||
.mock-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
padding-bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.8rem;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.mock-body::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
.mock-screen {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mock-screen.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Common Mockup Components */
|
||||
.mock-card {
|
||||
background: oklch(0.27 0.035 130);
|
||||
border: 1px solid oklch(0.32 0.03 125);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mock-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.mock-section-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mock-input {
|
||||
background: oklch(0.27 0.035 130);
|
||||
border: 1px solid oklch(0.32 0.03 125);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.75rem;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mock-btn {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-green);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mock-btn:hover {
|
||||
background: oklch(0.60 0.12 150);
|
||||
}
|
||||
|
||||
/* Room Tab Details */
|
||||
.mock-joined-room {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: oklch(0.68 0.13 150 / 0.1);
|
||||
border: 1px solid oklch(0.68 0.13 150 / 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.mock-room-name {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.mock-server-badge {
|
||||
background: oklch(0.68 0.13 150 / 0.2);
|
||||
color: var(--accent);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mock-invite-box {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.mock-invite-box input {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.demo-room-empty {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.demo-room-empty img {
|
||||
border-radius: 10px;
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 4px 12px oklch(0.68 0.13 150 / 0.2);
|
||||
}
|
||||
.demo-room-empty-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.demo-create-room-btn {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: var(--text-on-green);
|
||||
font-weight: 700;
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.demo-create-room-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px oklch(0.68 0.13 150 / 0.4);
|
||||
}
|
||||
.demo-create-room-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.mock-peer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.mock-peer-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.mock-peer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.mock-peer-name {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mock-peer-meta {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mock-peer-tab {
|
||||
font-size: 0.7rem;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mock-peer-status {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Sync Tab Details */
|
||||
.mock-target-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: oklch(0.62 0.14 45 / 0.1);
|
||||
border: 1px solid oklch(0.62 0.14 45 / 0.2);
|
||||
color: oklch(0.72 0.13 50);
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.mock-ctrl-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mock-btn-play { background: oklch(0.68 0.13 150); }
|
||||
.mock-btn-play:hover { background: oklch(0.60 0.12 150); }
|
||||
.mock-btn-pause { background: oklch(0.55 0.15 25); color: #fff; }
|
||||
.mock-btn-pause:hover { background: oklch(0.48 0.14 25); }
|
||||
.mock-btn-force { background: var(--accent); grid-column: span 2; }
|
||||
|
||||
/* Contrast-safe palette for the decorative hero demo. Lighthouse/axe audits
|
||||
sighted text contrast even inside aria-hidden, so every walkthrough state
|
||||
must meet WCAG AA. Accent shown as text gets a lighter indigo; buttons that
|
||||
put white text on a colour get a darker shade (these beat inline styles). */
|
||||
.hero-demo-scene {
|
||||
--accent: oklch(0.80 0.10 150);
|
||||
}
|
||||
/* The step-3 sync theater reuses the film SVG and must follow the same
|
||||
day/night palette; --accent stays hero-only (its cards sit on the page
|
||||
surface, not on the demo sky). */
|
||||
.hero-demo-scene,
|
||||
.illus-sync-theater {
|
||||
--demo-sky-top: oklch(0.22 0.055 152);
|
||||
--demo-sky-mid: oklch(0.145 0.035 150);
|
||||
--demo-sky-bottom: oklch(0.075 0.018 145);
|
||||
--demo-glow: oklch(0.76 0.13 150 / 0.34);
|
||||
--demo-stars: oklch(0.94 0.025 95);
|
||||
--demo-moon: oklch(0.97 0.025 92);
|
||||
--demo-mountain: oklch(0.18 0.055 148 / 0.9);
|
||||
--demo-midground: oklch(0.22 0.065 145 / 0.88);
|
||||
--demo-foreground: oklch(0.065 0.025 145);
|
||||
--demo-card: oklch(0.18 0.032 142);
|
||||
--demo-toolbar: oklch(0.12 0.025 142);
|
||||
--demo-control-fade: oklch(0.055 0.015 145 / 0.94);
|
||||
--demo-track: oklch(0.92 0.02 95 / 0.2);
|
||||
}
|
||||
.hero-demo-scene .mock-tab.active,
|
||||
.hero-demo-scene .mock-btn-force,
|
||||
.hero-demo-scene #demo-force-sync,
|
||||
.hero-demo-scene .demo-create-room-btn {
|
||||
background: oklch(0.42 0.09 150) !important;
|
||||
color: #fff;
|
||||
}
|
||||
.hero-demo-scene .mock-btn-play {
|
||||
background: oklch(0.50 0.11 150) !important;
|
||||
}
|
||||
.hero-demo-scene .mock-btn-pause,
|
||||
.hero-demo-scene #demo-room-joined > button.mock-btn {
|
||||
background: oklch(0.45 0.14 25) !important;
|
||||
}
|
||||
.hero-demo-scene .mock-version-link {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
html.theme-light .hero-demo-scene {
|
||||
--accent: oklch(0.60 0.12 150);
|
||||
}
|
||||
html.theme-light .hero-demo-scene,
|
||||
html.theme-light .illus-sync-theater {
|
||||
--demo-sky-top: oklch(0.86 0.085 78);
|
||||
--demo-sky-mid: oklch(0.76 0.095 112);
|
||||
--demo-sky-bottom: oklch(0.54 0.10 150);
|
||||
--demo-glow: oklch(0.92 0.13 72 / 0.44);
|
||||
--demo-stars: oklch(0.98 0.025 90);
|
||||
--demo-moon: oklch(0.99 0.025 88);
|
||||
--demo-mountain: oklch(0.48 0.095 150 / 0.82);
|
||||
--demo-midground: oklch(0.40 0.10 148 / 0.8);
|
||||
--demo-foreground: oklch(0.24 0.075 145);
|
||||
--demo-card: oklch(0.985 0.008 95);
|
||||
--demo-toolbar: oklch(0.955 0.014 100);
|
||||
--demo-control-fade: oklch(0.16 0.035 145 / 0.88);
|
||||
--demo-track: oklch(0.98 0.01 95 / 0.34);
|
||||
}
|
||||
|
||||
|
||||
html.theme-light .hero-demo-scene .demo-tab-card {
|
||||
background: var(--demo-card);
|
||||
border-color: oklch(0.20 0.025 140 / 0.1);
|
||||
box-shadow:
|
||||
0 18px 38px oklch(0.20 0.025 140 / 0.16),
|
||||
0 0 28px oklch(0.82 0.10 85 / 0.14),
|
||||
inset 0 1px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
html.theme-light .hero-demo-scene .demo-tab-titlebar {
|
||||
background: var(--demo-toolbar);
|
||||
border-bottom-color: oklch(0.88 0.015 90);
|
||||
color: oklch(0.45 0.02 110);
|
||||
}
|
||||
|
||||
html.theme-light .hero-demo-scene .demo-tab-dots i {
|
||||
background: oklch(0.78 0.02 90);
|
||||
}
|
||||
|
||||
html.theme-light .hero-demo-scene .demo-ext-launcher {
|
||||
background: #ffffff;
|
||||
border-color: oklch(0.78 0.02 90);
|
||||
}
|
||||
|
||||
html.theme-light .hero-demo-scene .demo-sync-chip {
|
||||
background: oklch(0.55 0.12 150 / 0.1);
|
||||
border-color: oklch(0.55 0.12 150 / 0.26);
|
||||
color: oklch(0.40 0.10 150);
|
||||
}
|
||||
|
||||
.mock-status-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mock-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: oklch(0.68 0.13 150 / 0.1);
|
||||
border: 1px solid oklch(0.68 0.13 150 / 0.2);
|
||||
color: oklch(0.68 0.13 150);
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mock-log-item {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 0.4rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* Settings Tab Details */
|
||||
.mock-settings-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.mock-settings-row input[type="checkbox"] {
|
||||
margin-top: 0.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mock-settings-row label {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mock-settings-row p {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Mock Toggle Switch Details */
|
||||
.mock-toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mock-toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.mock-slider {
|
||||
position: absolute;
|
||||
cursor: default;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: oklch(0.32 0.03 125);
|
||||
transition: .3s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.mock-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: oklch(0.68 0.02 100);
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .mock-slider {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
input:checked + .mock-slider:before {
|
||||
transform: translateX(16px);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* Dev Tab Details */
|
||||
.mock-diagnostic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.mock-diag-box {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.mock-diag-box .diag-val {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/* --- Join Page Loading Spinner --- */
|
||||
.join-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid oklch(0.68 0.13 150 / 0.2);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 0.75rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* --- Reduced Motion --- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
[data-reveal] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.illus-typing-text::after {
|
||||
animation: none;
|
||||
}
|
||||
.illus-timeline-progress {
|
||||
animation: none;
|
||||
width: 60%;
|
||||
}
|
||||
.illus-sync-pulse {
|
||||
animation: none;
|
||||
}
|
||||
.illus-extension-icon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/* --- Join & Invitation Page Visuals --- */
|
||||
.join-status-visual {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.join-status-mascot {
|
||||
display: block;
|
||||
width: 140px;
|
||||
height: auto;
|
||||
z-index: 2;
|
||||
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.15));
|
||||
}
|
||||
|
||||
.join-status-mascot.searching-mascot {
|
||||
width: 175px; /* Slightly wider due to antenna to keep main body scale identical */
|
||||
}
|
||||
|
||||
.join-status-pulse {
|
||||
animation: mascot-gentle-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes mascot-gentle-pulse {
|
||||
0% {
|
||||
transform: scale(0.96);
|
||||
filter: drop-shadow(0 4px 10px oklch(0.68 0.13 150 / 0.15));
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.04);
|
||||
filter: drop-shadow(0 8px 20px oklch(0.68 0.13 150 / 0.35));
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.96);
|
||||
filter: drop-shadow(0 4px 10px oklch(0.68 0.13 150 / 0.15));
|
||||
}
|
||||
}
|
||||
|
||||
.legal-mascot {
|
||||
display: block;
|
||||
width: 180px;
|
||||
height: auto;
|
||||
margin: -0.75rem auto 0.4rem auto;
|
||||
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.15));
|
||||
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.legal-mascot:hover {
|
||||
transform: scale(1.06) translateY(-4px) rotate(1.5deg);
|
||||
}
|
||||
|
||||
.selfhost-mascot {
|
||||
display: block;
|
||||
width: 220px;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
filter: drop-shadow(0 6px 14px oklch(0.15 0.02 145 / 0.22));
|
||||
}
|
||||
|
||||
.status-ring {
|
||||
position: absolute;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
background: oklch(0.68 0.13 150 / 0.05);
|
||||
border: 2px dashed oklch(0.68 0.13 150 / 0.25);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-ring.active-pulse {
|
||||
animation: radar-pulse 3s cubic-bezier(0.25, 0.8, 0.25, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes radar-pulse {
|
||||
0% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0.9;
|
||||
border-color: oklch(0.68 0.13 150 / 0.3);
|
||||
box-shadow: 0 0 0 0px oklch(0.68 0.13 150 / 0.15);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.18);
|
||||
opacity: 0.35;
|
||||
border-color: oklch(0.75 0.10 150 / 0.2);
|
||||
box-shadow: 0 0 0 15px oklch(0.75 0.10 150 / 0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0.9;
|
||||
border-color: oklch(0.68 0.13 150 / 0.3);
|
||||
box-shadow: 0 0 0 0px oklch(0.68 0.13 150 / 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.join-card-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.join-card-actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 1.1rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.join-card-actions .btn svg {
|
||||
margin-right: 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.join-card-actions .btn img {
|
||||
margin-right: 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--accent-green);
|
||||
color: var(--text-on-green) !important;
|
||||
box-shadow: 0 10px 15px -3px oklch(0.68 0.13 150 / 0.3);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: oklch(0.60 0.12 150);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 25px -5px oklch(0.68 0.13 150 / 0.4);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/* --- Language Toggle --- */
|
||||
html.lang-de [lang="en"] {
|
||||
display: none !important;
|
||||
}
|
||||
html:not(.lang-de) [lang="de"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* --- Modern Glassmorphic Language Selector --- */
|
||||
.lang-select-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: oklch(0.27 0.035 130 / 0.4);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 9999px;
|
||||
/* No own padding: the <select> carries it, so the button (and its
|
||||
focus/hover highlight in appearance:base-select) fills the whole pill
|
||||
instead of only the inner ~110px. */
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), color 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.lang-select-container:hover {
|
||||
background: oklch(0.27 0.035 130 / 0.6);
|
||||
border-color: oklch(0.68 0.13 150 / 0.3);
|
||||
color: var(--text);
|
||||
box-shadow: 0 0 15px oklch(0.68 0.13 150 / 0.1);
|
||||
}
|
||||
|
||||
.lang-select-container .globe-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.lang-select-container:hover .globe-icon {
|
||||
transform: rotate(15deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lang-select-container .chevron-icon {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.lang-select-container:hover .chevron-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lang-dropdown {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: currentColor;
|
||||
cursor: pointer;
|
||||
/* Padding moved here from the container so the control fills the pill. */
|
||||
padding: 6px 32px 6px 14px;
|
||||
margin: 0;
|
||||
width: auto;
|
||||
min-width: 110px;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@supports (appearance: base-select) {
|
||||
.lang-dropdown::picker(select) {
|
||||
appearance: base-select;
|
||||
background-color: oklch(0.20 0.025 140);
|
||||
border: 1px solid oklch(0.32 0.03 125);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
font-family: 'Twemoji Country Flags', inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.lang-dropdown option {
|
||||
background: oklch(0.20 0.025 140);
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Light theme: the pill was designed on the dark glass recipe — by day it
|
||||
becomes frosted white so it sits in the same family as the buttons. */
|
||||
html.theme-light .lang-select-container {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-color: oklch(0.28 0.035 140 / 0.14);
|
||||
}
|
||||
|
||||
html.theme-light .lang-select-container:hover {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-color: oklch(0.56 0.13 150 / 0.35);
|
||||
box-shadow: 0 0 15px oklch(0.56 0.13 150 / 0.12);
|
||||
}
|
||||
|
||||
html.theme-light .lang-dropdown option {
|
||||
background: #ffffff;
|
||||
color: oklch(0.21 0.03 140);
|
||||
}
|
||||
|
||||
@supports (appearance: base-select) {
|
||||
html.theme-light .lang-dropdown::picker(select) {
|
||||
background-color: #ffffff;
|
||||
border-color: oklch(0.28 0.035 140 / 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Hamburger Menu --- */
|
||||
.hamburger {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* --- Feature Cards: subtle differentiation --- */
|
||||
.feature-card:nth-child(1) { --card-accent: oklch(0.68 0.13 150 / 0.06); }
|
||||
.feature-card:nth-child(2) { --card-accent: oklch(0.62 0.14 45 / 0.06); }
|
||||
.feature-card:nth-child(3) { --card-accent: oklch(0.68 0.13 150 / 0.06); }
|
||||
.feature-card:nth-child(4) { --card-accent: oklch(0.75 0.10 150 / 0.06); }
|
||||
.feature-card:nth-child(5) { --card-accent: oklch(0.68 0.14 45 / 0.06); }
|
||||
.feature-card:nth-child(6) { --card-accent: oklch(0.62 0.14 45 / 0.06); }
|
||||
.feature-card:nth-child(7) { --card-accent: oklch(0.62 0.14 45 / 0.06); }
|
||||
.feature-card:nth-child(8) { --card-accent: oklch(0.68 0.13 150 / 0.06); }
|
||||
|
||||
.feature-card {
|
||||
background: linear-gradient(135deg, var(--card-accent, transparent), oklch(0.27 0.035 130 / 0.45));
|
||||
}
|
||||
|
||||
/* --- Compatibility Section --- */
|
||||
.compat-section {
|
||||
padding: 0.5rem 0 2.5rem 0;
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.compat-mascot {
|
||||
display: block;
|
||||
margin: 0 auto 0.3rem auto;
|
||||
max-width: 270px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.compat-heading {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
.compat-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2.4rem 3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.compat-logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
transition: opacity 0.3s;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.compat-logo:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.compat-logo img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.compat-logo img.compat-logo-wide {
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.compat-logo img.compat-logo-disneyplus-mark {
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
object-fit: contain;
|
||||
filter: brightness(0) saturate(100%) invert(63%) sepia(12%) saturate(621%) hue-rotate(203deg) brightness(92%) contrast(88%);
|
||||
}
|
||||
|
||||
html.theme-light .compat-logo img.compat-logo-disneyplus-mark {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.compat-section {
|
||||
padding: 0 0 1.6rem;
|
||||
}
|
||||
|
||||
.compat-mascot {
|
||||
max-width: 220px;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.compat-heading {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.compat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1.55rem 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.compat-logo {
|
||||
gap: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.compat-logo img {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.compat-logo img.compat-logo-wide {
|
||||
width: 48px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.compat-logo img.compat-logo-disneyplus-mark {
|
||||
width: 56px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.compat-logo-jellyfin {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compat-more {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.compat-more {
|
||||
opacity: 0.4;
|
||||
cursor: help;
|
||||
flex-direction: row;
|
||||
gap: 0.35rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compat-more:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.compat-more-icon {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.compat-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--card);
|
||||
color: var(--text-muted);
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.4);
|
||||
z-index: 10;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.compat-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: var(--glass-border);
|
||||
}
|
||||
|
||||
.compat-more:hover .compat-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/* --- Section collapse (FAQ, self-hosting): closed by default to keep the page short --- */
|
||||
.section-collapse {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.faq-section-collapse {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.collapse-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--accent);
|
||||
background: oklch(0.68 0.13 150 / 0.08);
|
||||
border: 1px solid oklch(0.68 0.13 150 / 0.3);
|
||||
border-radius: 99px;
|
||||
padding: 0.85rem 2rem;
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
transition: background 0.3s, box-shadow 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.collapse-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapse-summary:hover {
|
||||
background: oklch(0.68 0.13 150 / 0.16);
|
||||
box-shadow: 0 0 20px oklch(0.68 0.13 150 / 0.25);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.collapse-summary .collapse-chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.section-collapse[open] .collapse-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.collapse-label-hide { display: none; }
|
||||
.section-collapse[open] .collapse-label-show { display: none; }
|
||||
.section-collapse[open] .collapse-label-hide { display: inline; }
|
||||
|
||||
.section-collapse[open] .collapse-summary {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-collapse[open] .collapse-content {
|
||||
animation: collapse-content-in 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes collapse-content-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.section-collapse[open] .collapse-content {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- FAQ Section --- */
|
||||
|
||||
.faq-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
background: oklch(0.27 0.035 130 / 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem 2rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.faq-item:hover {
|
||||
border-color: oklch(0.68 0.13 150 / 0.3);
|
||||
box-shadow: 0 8px 20px oklch(0.68 0.13 150 / 0.1);
|
||||
}
|
||||
|
||||
.faq-item[open] {
|
||||
border-color: oklch(0.68 0.13 150 / 0.4);
|
||||
background: oklch(0.27 0.035 130 / 0.65);
|
||||
}
|
||||
|
||||
.faq-item summary {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text);
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.faq-item summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faq-item summary::after {
|
||||
content: '+';
|
||||
font-size: 1.4rem;
|
||||
color: var(--accent);
|
||||
font-weight: 300;
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
flex-shrink: 0;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.faq-item[open] summary::after {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.faq-item p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* --- Focus states for interactive cards --- */
|
||||
.feature-card:focus-visible,
|
||||
.use-case-card:focus-visible,
|
||||
.compat-logo:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
.feature-card,
|
||||
.use-case-card,
|
||||
.compat-logo,
|
||||
section[id],
|
||||
header[id] {
|
||||
scroll-margin-top: 100px;
|
||||
}
|
||||
|
||||
/* --- Screen-reader-only (also crawlable by search engines) --- */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* --- Skip-to-content accessibility link --- */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--accent);
|
||||
color: var(--text-on-green);
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 0 0 8px 0;
|
||||
font-weight: 700;
|
||||
z-index: 9999;
|
||||
text-decoration: none;
|
||||
}
|
||||
.skip-link:focus {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* --- Reduced motion support --- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.join-status-pulse { animation: none; }
|
||||
.status-ring.active-pulse { animation: none; }
|
||||
}
|
||||
|
||||
/* --- will-change hints for animated elements --- */
|
||||
.join-status-pulse,
|
||||
.status-ring.active-pulse,
|
||||
.hero-mockup-wrapper,
|
||||
.cta-group .btn {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.join-status-pulse,
|
||||
.status-ring.active-pulse,
|
||||
.hero-mockup-wrapper,
|
||||
.cta-group .btn {
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
/* --- Use Cases / Anwendungsszenarien Section --- */
|
||||
.use-cases-section {
|
||||
padding: clamp(3.5rem, 6.5vh, 5rem) 0;
|
||||
position: relative;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.use-cases-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Sub-heading that separates the use-case scenarios from the feature tiles so
|
||||
the two card types read as intentional groups (and gives the "Features" nav
|
||||
link a real target). */
|
||||
.section-subheading {
|
||||
text-align: center;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 3.25rem 0 1.5rem;
|
||||
scroll-margin-top: 96px;
|
||||
}
|
||||
|
||||
.use-cases-feature-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.use-cases-feature-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.use-cases-feature-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.use-case-card {
|
||||
background: oklch(0.27 0.035 130 / 0.4);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.2rem 1.6rem 1.7rem 1.6rem;
|
||||
text-align: left;
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.use-case-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, transparent, var(--card-accent-border, var(--accent)), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.use-case-card:nth-child(1) { --card-accent-border: oklch(0.68 0.13 150); }
|
||||
.use-case-card:nth-child(2) { --card-accent-border: oklch(0.62 0.14 45); }
|
||||
.use-case-card:nth-child(3) { --card-accent-border: oklch(0.75 0.10 150); }
|
||||
|
||||
|
||||
/* Use-case cards are static content, not links — no hover lift so they don't
|
||||
read as clickable. The top accent bar (::before) stays visible instead. */
|
||||
.use-case-card::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.use-case-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 272px;
|
||||
height: 150px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
margin: 0 auto 0 auto;
|
||||
}
|
||||
|
||||
.cta-github-mascot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
height: auto;
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
|
||||
/* Shared framing so the section mascots read as one set even though the
|
||||
underlying illustrations differ slightly in style (a consistent, soft ground
|
||||
shadow — true art-direction alignment needs the assets themselves redrawn). */
|
||||
.compat-mascot,
|
||||
.features-questions-mascot,
|
||||
.comparison-mascot,
|
||||
.selfhost-mascot {
|
||||
filter: drop-shadow(0 6px 14px oklch(0.15 0.02 145 / 0.22));
|
||||
}
|
||||
|
||||
.features-questions-mascot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 185px;
|
||||
height: auto;
|
||||
margin: -1rem auto 0.4rem auto;
|
||||
}
|
||||
|
||||
.comparison-mascot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 195px;
|
||||
height: auto;
|
||||
margin: -1.4rem auto 0.4rem auto;
|
||||
}
|
||||
|
||||
.use-case-icon {
|
||||
font-size: 1.6rem;
|
||||
margin-right: 0.6rem;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
filter: drop-shadow(0 2px 8px rgba(0,0,0,0.2));
|
||||
}
|
||||
|
||||
.use-case-card h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.use-case-card p {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* --- Comparison Section --- */
|
||||
.comparison-section {
|
||||
padding: clamp(3.5rem, 6.5vh, 5rem) 0;
|
||||
background: transparent;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.comparison-table-wrapper {
|
||||
overflow-x: auto;
|
||||
background: oklch(0.27 0.035 130 / 0.35);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.comparison-table th {
|
||||
background: oklch(0.20 0.025 140 / 0.6);
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.comparison-table th.highlight,
|
||||
.comparison-table td.highlight {
|
||||
background: oklch(0.68 0.13 150 / 0.08);
|
||||
}
|
||||
|
||||
.comparison-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.comparison-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.feat-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.feat-name strong {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feat-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.check {
|
||||
color: oklch(0.68 0.13 150);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cross {
|
||||
color: oklch(0.68 0.17 25);
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* On phones the 3-column table is turned into stacked cards so there is
|
||||
no horizontal scrolling — one card per feature, KoalaSync above Teleparty. */
|
||||
@media (max-width: 600px) {
|
||||
.comparison-table-wrapper {
|
||||
overflow-x: visible;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Visually hide the header row but keep it for screen readers. */
|
||||
.comparison-table thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.comparison-table tbody,
|
||||
.comparison-table tr,
|
||||
.comparison-table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comparison-table tbody tr {
|
||||
background: oklch(0.27 0.035 130 / 0.35);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comparison-table tbody tr:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comparison-table td {
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.comparison-table tbody tr td:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Feature name acts as the card header. */
|
||||
.comparison-table td.feat-name {
|
||||
background: oklch(0.20 0.025 140 / 0.55);
|
||||
padding: 0.9rem 1.1rem;
|
||||
}
|
||||
|
||||
/* Room is back on the card, so show the description again. */
|
||||
.feat-desc {
|
||||
display: block;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* Label each value cell with the product it belongs to. */
|
||||
.comparison-table td.check,
|
||||
.comparison-table td.cross {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.comparison-table td.check::before,
|
||||
.comparison-table td.cross::before {
|
||||
flex: 0 0 5.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.comparison-table td.check::before,
|
||||
.comparison-table td.cross::before {
|
||||
content: attr(data-label);
|
||||
}
|
||||
|
||||
/* Keep the KoalaSync row subtly highlighted, like on desktop. */
|
||||
.comparison-table td.check.highlight {
|
||||
background: oklch(0.68 0.13 150 / 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
html.theme-light .comparison-section {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html.theme-light .comparison-table tbody tr {
|
||||
background: #ffffff;
|
||||
border-color: oklch(0.20 0.025 140 / 0.1);
|
||||
box-shadow: 0 8px 20px oklch(0.20 0.025 140 / 0.08);
|
||||
}
|
||||
|
||||
html.theme-light .comparison-table tbody tr:hover {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
html.theme-light .comparison-table td {
|
||||
border-bottom-color: oklch(0.20 0.025 140 / 0.08);
|
||||
}
|
||||
|
||||
html.theme-light .comparison-table td.feat-name {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
html.theme-light .comparison-table td.check.highlight {
|
||||
background: oklch(0.52 0.12 150 / 0.07);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Footnotes & Source Links --- */
|
||||
.source-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
margin-left: 0.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: super;
|
||||
opacity: 0.82;
|
||||
transition: opacity 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.source-link:hover {
|
||||
opacity: 1;
|
||||
color: oklch(0.75 0.10 150);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.table-footnotes {
|
||||
padding: 0.8rem 1.25rem;
|
||||
background: oklch(0.20 0.025 140 / 0.45);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
opacity: 0.65;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Link from the comparison table to the fuller guides/alternatives hub */
|
||||
.comparison-guides-link {
|
||||
text-align: center;
|
||||
margin: 2rem 0 0;
|
||||
}
|
||||
|
||||
.comparison-guides-link a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
transition: gap 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.comparison-guides-link a:hover {
|
||||
gap: 0.7rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.table-footnotes:hover {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.table-footnotes p {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.table-footnotes p:not(:last-child) {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.table-footnotes a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease, text-decoration 0.2s ease;
|
||||
}
|
||||
|
||||
.table-footnotes a:hover {
|
||||
color: oklch(0.75 0.10 150);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.table-footnotes sup {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.table-footnotes {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.selfhost-mascot {
|
||||
display: block;
|
||||
width: 300px;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.15));
|
||||
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.selfhost-mascot:hover {
|
||||
transform: scale(1.05) translateY(-4px) rotate(-1deg);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/* --- Legal Pages --- */
|
||||
.legal-content {
|
||||
max-width: 800px;
|
||||
margin: 8rem auto 4rem auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.legal-card {
|
||||
background: var(--card);
|
||||
padding: 3rem;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.legal-card h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.legal-card h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.legal-card p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legal-inline-link {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
|
||||
.legal-inline-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Reset global section padding for legal sections specifically */
|
||||
.legal-card section {
|
||||
padding: 0 !important;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* --- Join Page Specifics --- */
|
||||
.join-card {
|
||||
max-width: 450px;
|
||||
margin: 6rem auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.room-badge {
|
||||
display: inline-block;
|
||||
background: oklch(0.68 0.13 150 / 0.1);
|
||||
color: var(--accent);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 99px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid oklch(0.68 0.13 150 / 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
align-items: center;
|
||||
padding-top: 5.5rem;
|
||||
padding-bottom: 3.5rem;
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
text-align: center;
|
||||
}
|
||||
.step {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
text-align: left;
|
||||
}
|
||||
.hero-text h1 {
|
||||
font-size: 2.5rem;
|
||||
max-width: none;
|
||||
}
|
||||
.cta-group {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav-links {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-direction: column;
|
||||
background: oklch(0.20 0.025 140 / 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 1rem 2rem;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
margin-left: 0;
|
||||
}
|
||||
.nav-links.open {
|
||||
display: flex;
|
||||
}
|
||||
/* The dropdown panel is dark-green by default; in the light theme that
|
||||
hard-coded colour clashed with the pale navbar and washed out the link
|
||||
text. Match the panel and its links to the active theme instead. */
|
||||
.nav-links a {
|
||||
color: var(--text);
|
||||
}
|
||||
html.theme-light .nav-links {
|
||||
background: oklch(0.95 0.01 100 / 0.97);
|
||||
border-bottom-color: oklch(0.20 0.025 140 / 0.10);
|
||||
}
|
||||
.nav-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
.hamburger {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
nav .container {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.logo-area {
|
||||
gap: 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.logo-area img {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
margin: 0;
|
||||
}
|
||||
.lang-select-container {
|
||||
padding: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
.lang-select-container .globe-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.lang-dropdown {
|
||||
font-size: 0.7rem;
|
||||
/* Padding on the control (not the container) so the highlight fills
|
||||
the whole pill — matches the desktop rule in landing-controls.css. */
|
||||
padding: 4px 24px 4px 10px;
|
||||
}
|
||||
.lang-select-container .chevron-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.legal-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) and (max-height: 920px) {
|
||||
.hero {
|
||||
padding-top: clamp(6.5rem, 9vh, 8rem);
|
||||
padding-bottom: clamp(2rem, 4vh, 3.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra-narrow phones: make sure logo, language picker and the menu button
|
||||
never crowd each other in the header (kept comfortable down to ~320px). */
|
||||
@media (max-width: 380px) {
|
||||
nav .container {
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
.nav-right {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.logo-area {
|
||||
font-size: 1rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ const path = require('path');
|
||||
const https = require('https');
|
||||
|
||||
const websiteDir = __dirname;
|
||||
const sitemapPath = path.join(websiteDir, 'sitemap.xml');
|
||||
const sitemapPath = path.join(websiteDir, 'www', 'sitemap.xml');
|
||||
|
||||
// 1. Find the IndexNow key file in the website directory
|
||||
const files = fs.readdirSync(websiteDir);
|
||||
@@ -34,7 +34,7 @@ console.log(`Key Location: ${keyLocation}`);
|
||||
|
||||
// 2. Parse URLs from sitemap.xml
|
||||
if (!fs.existsSync(sitemapPath)) {
|
||||
console.error(`Error: sitemap.xml not found at ${sitemapPath}`);
|
||||
console.error(`Error: generated sitemap.xml not found at ${sitemapPath}. Run \`node website/build.cjs\` first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
+273
-130
@@ -10,13 +10,14 @@
|
||||
<link rel="dns-prefetch" href="https://addons.mozilla.org">
|
||||
<link rel="dns-prefetch" href="https://github.com">
|
||||
|
||||
<link rel="preload" href="{{ASSET_PATH}}style.min.css" as="style">
|
||||
<link rel="preload" href="{{ASSET_PATH}}landing.min.css" as="style">
|
||||
<link rel="stylesheet" href="{{ASSET_PATH}}landing.min.css">
|
||||
<link rel="preload" as="image" href="{{ASSET_PATH}}assets/LookDownKoala.avif" imagesrcset="{{ASSET_PATH}}assets/LookDownKoala-1x.avif 90w, {{ASSET_PATH}}assets/LookDownKoala.avif 205w" imagesizes="90px" type="image/avif" fetchpriority="high">
|
||||
<link rel="stylesheet" href="{{ASSET_PATH}}style.min.css">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ASSET_PATH}}assets/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ASSET_PATH}}assets/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ASSET_PATH}}assets/icon-192x192.png">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/{{CANONICAL_PATH}}">
|
||||
<link rel="alternate" type="text/markdown" href="https://sync.koalastuff.net/llms.txt" title="KoalaSync information for AI assistants">
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/">
|
||||
<link rel="alternate" hreflang="fr" href="https://sync.koalastuff.net/fr/">
|
||||
@@ -69,7 +70,7 @@
|
||||
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/og-image.png">
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="manifest" href="{{ASSET_PATH}}site.webmanifest">
|
||||
@@ -214,22 +215,94 @@
|
||||
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
<span class="bamboo-stalk far-6"></span>
|
||||
<span class="bamboo-stalk far-7"></span>
|
||||
</div>
|
||||
<div id="bamboo-mid">
|
||||
<span class="bamboo-stalk mid-1"></span>
|
||||
<span class="bamboo-stalk mid-2"></span>
|
||||
<span class="bamboo-stalk mid-3"></span>
|
||||
<span class="bamboo-stalk mid-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<div class="logo-area">
|
||||
<img class="nav-logo-img" src="{{ASSET_PATH}}assets/NewLogoIcon_128.webp" srcset="{{ASSET_PATH}}assets/NewLogoIcon_64.webp 64w, {{ASSET_PATH}}assets/NewLogoIcon_128.webp 128w, {{ASSET_PATH}}assets/NewLogoIcon.webp 256w" sizes="64px" alt="KoalaSync Logo" width="64" height="64">
|
||||
<a class="logo-area" href="{{ASSET_PATH}}" aria-label="KoalaSync home">
|
||||
<img class="nav-logo-img" src="{{ASSET_PATH}}assets/NewLogoIcon_128.webp" srcset="{{ASSET_PATH}}assets/NewLogoIcon_64.webp 64w, {{ASSET_PATH}}assets/NewLogoIcon_128.webp 128w, {{ASSET_PATH}}assets/NewLogoIcon.webp 256w" sizes="64px" alt="KoalaSync Logo" width="64" height="64" fetchpriority="high" decoding="async">
|
||||
<span>KoalaSync</span>
|
||||
<div id="nav-extension-status" class="nav-ext-status" style="display: none;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" width="12" height="12"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
<span>Installed</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
|
||||
<a href="#features"><span>{{NAV_FEATURES}}</span></a>
|
||||
<a href="#how-it-works"><span>{{NAV_HOW_IT_WORKS}}</span></a>
|
||||
@@ -287,7 +360,8 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="hero" id="main-content">
|
||||
<main id="main-content">
|
||||
<header class="hero" id="top">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<h1>{{HERO_TITLE}}</h1>
|
||||
@@ -344,14 +418,24 @@
|
||||
<span class="demo-tab-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="11" height="11" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> <span class="demo-title-text-ep1">Stranger Things - S4E1</span></span>
|
||||
<span class="demo-tab-user demo-user-a">🐱 ChillCat</span>
|
||||
<button class="demo-ext-launcher" id="demo-launcher" type="button" aria-expanded="true" title="Open or close the KoalaSync popup">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="" width="16" height="16" loading="eager" decoding="async">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon_64.webp" alt="" width="16" height="16" loading="eager" decoding="async">
|
||||
</button>
|
||||
</div>
|
||||
<div class="demo-video">
|
||||
<div class="demo-video-art" aria-hidden="true"></div>
|
||||
<div class="demo-seek-sweep" aria-hidden="true"></div>
|
||||
<svg class="demo-film" viewBox="0 0 160 90" preserveAspectRatio="xMidYMid slice" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="film-stars" fill="#e2e8f0">
|
||||
<g class="film-clouds" fill="#f0efe7">
|
||||
<g class="film-cloud film-cloud-1" opacity="0.09">
|
||||
<ellipse cx="34" cy="15" rx="17" ry="3"/>
|
||||
<ellipse cx="42" cy="12" rx="9" ry="2.1"/>
|
||||
</g>
|
||||
<g class="film-cloud film-cloud-2" opacity="0.07">
|
||||
<ellipse cx="100" cy="21" rx="13" ry="2.4"/>
|
||||
<ellipse cx="107" cy="19" rx="7" ry="1.7"/>
|
||||
</g>
|
||||
</g>
|
||||
<g class="film-stars" fill="#dbd7cd">
|
||||
<circle class="film-star" cx="18" cy="14" r="0.8"/>
|
||||
<circle class="film-star" cx="42" cy="24" r="0.6"/>
|
||||
<circle class="film-star" cx="64" cy="10" r="0.9"/>
|
||||
@@ -360,27 +444,40 @@
|
||||
<circle class="film-star" cx="134" cy="22" r="0.8"/>
|
||||
<circle class="film-star" cx="150" cy="12" r="0.5"/>
|
||||
</g>
|
||||
<circle class="film-moon" cx="122" cy="26" r="7.5" fill="#f1f5f9" opacity="0.92"/>
|
||||
<path class="film-mtn-scroll" d="M0,60 L30,45 L70,55 L110,40 L160,60 L190,45 L230,55 L270,40 L320,60 L320,92 L0,92 Z" fill="rgba(30,27,75,0.7)"/>
|
||||
<line class="film-shooting-star" x1="151" y1="6" x2="143" y2="9.5" stroke="#f0efe7" stroke-width="0.6" stroke-linecap="round"/>
|
||||
<g class="film-moon-g">
|
||||
<circle class="film-moon" cx="122" cy="26" r="7.5" fill="#f0efe7" opacity="0.92"/>
|
||||
<g class="film-moon-detail" fill="#10190e" opacity="0.1">
|
||||
<circle cx="119.6" cy="24.2" r="1.7"/>
|
||||
<circle cx="124.6" cy="28.4" r="1.15"/>
|
||||
<circle cx="123" cy="22.4" r="0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="film-mtn-scroll" d="M0,60 L30,45 L70,55 L110,40 L160,60 L190,45 L230,55 L270,40 L320,60 L320,92 L0,92 Z" fill="rgba(14, 31, 15, 0.7)"/>
|
||||
<g class="film-mist-scroll" fill="#dbd7cd">
|
||||
<ellipse cx="40" cy="63" rx="46" ry="4" opacity="0.055"/>
|
||||
<ellipse cx="120" cy="65" rx="52" ry="4.5" opacity="0.045"/>
|
||||
<ellipse cx="200" cy="63" rx="46" ry="4" opacity="0.055"/>
|
||||
<ellipse cx="280" cy="65" rx="52" ry="4.5" opacity="0.045"/>
|
||||
</g>
|
||||
<g class="film-mid-scroll">
|
||||
<polygon points="20,76 26,60 32,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="55,78 63,52 71,78" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="95,76 101,62 107,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="135,77 144,55 153,77" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="180,76 186,60 192,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="215,78 223,52 231,78" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="255,76 261,62 267,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="295,77 304,55 313,77" fill="rgba(49,46,129,0.55)"/>
|
||||
<path d="M0,76 Q40,73 80,76 T160,76 Q200,73 240,76 T320,76 L320,92 L0,92 Z" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="20,76 26,60 32,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="55,78 63,52 71,78" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="95,76 101,62 107,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="135,77 144,55 153,77" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="180,76 186,60 192,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="215,78 223,52 231,78" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="255,76 261,62 267,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="295,77 304,55 313,77" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<path d="M0,76 Q40,73 80,76 T160,76 Q200,73 240,76 T320,76 L320,92 L0,92 Z" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
</g>
|
||||
<g class="film-fore-scroll">
|
||||
<polygon points="40,84 52,48 64,84" fill="#090d16"/>
|
||||
<polygon points="115,84 128,42 141,84" fill="#090d16"/>
|
||||
<polygon points="200,84 212,48 224,84" fill="#090d16"/>
|
||||
<polygon points="275,84 288,42 301,84" fill="#090d16"/>
|
||||
<path d="M0,84 Q80,82 160,84 T320,84 L320,92 L0,92 Z" fill="#090d16"/>
|
||||
<polygon points="40,84 52,48 64,84" fill="#040903"/>
|
||||
<polygon points="115,84 128,42 141,84" fill="#040903"/>
|
||||
<polygon points="200,84 212,48 224,84" fill="#040903"/>
|
||||
<polygon points="275,84 288,42 301,84" fill="#040903"/>
|
||||
<path d="M0,84 Q80,82 160,84 T320,84 L320,92 L0,92 Z" fill="#040903"/>
|
||||
</g>
|
||||
<circle class="film-bounce-ball" cx="80" cy="46" r="3.5" fill="#fde047"/>
|
||||
</svg>
|
||||
<div class="demo-play-overlay" aria-hidden="true">
|
||||
<span class="demo-icon-wrap">
|
||||
@@ -389,8 +486,16 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="demo-video-controls">
|
||||
<span class="demo-ctrl-icon demo-ctrl-play" aria-hidden="true">
|
||||
<svg class="icon-play" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="11" height="11"><path d="M8 5v14l11-7z"/></svg>
|
||||
<svg class="icon-pause" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="11" height="11"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>
|
||||
</span>
|
||||
<span class="demo-time">12:34</span>
|
||||
<div class="demo-progress" title="Seek: KoalaSync jumps the other tab to the same spot"><div class="demo-progress-fill"></div></div>
|
||||
<div class="demo-progress" title="Seek: KoalaSync jumps the other tab to the same spot"><div class="demo-progress-buffer"></div><div class="demo-progress-fill"></div></div>
|
||||
<span class="demo-duration" aria-hidden="true">42:17</span>
|
||||
<span class="demo-ctrl-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="11" height="11"><path d="M11 5 6 9H2v6h4l5 4V5z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span>
|
||||
<span class="demo-ctrl-badge" aria-hidden="true">HD</span>
|
||||
<span class="demo-ctrl-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="11" height="11"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,14 +506,24 @@
|
||||
<span class="demo-tab-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="11" height="11" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> <span class="demo-title-text-ep1">Stranger Things - S4E1</span></span>
|
||||
<span class="demo-tab-user demo-user-b">🐶 HappyDog</span>
|
||||
<span class="demo-ext-launcher demo-ext-static" aria-hidden="true" title="KoalaSync is installed here too">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="" width="16" height="16" loading="eager" decoding="async">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon_64.webp" alt="" width="16" height="16" loading="eager" decoding="async">
|
||||
</span>
|
||||
</div>
|
||||
<div class="demo-video">
|
||||
<div class="demo-video-art" aria-hidden="true"></div>
|
||||
<div class="demo-seek-sweep" aria-hidden="true"></div>
|
||||
<svg class="demo-film" viewBox="0 0 160 90" preserveAspectRatio="xMidYMid slice" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="film-stars" fill="#e2e8f0">
|
||||
<g class="film-clouds" fill="#f0efe7">
|
||||
<g class="film-cloud film-cloud-1" opacity="0.09">
|
||||
<ellipse cx="34" cy="15" rx="17" ry="3"/>
|
||||
<ellipse cx="42" cy="12" rx="9" ry="2.1"/>
|
||||
</g>
|
||||
<g class="film-cloud film-cloud-2" opacity="0.07">
|
||||
<ellipse cx="100" cy="21" rx="13" ry="2.4"/>
|
||||
<ellipse cx="107" cy="19" rx="7" ry="1.7"/>
|
||||
</g>
|
||||
</g>
|
||||
<g class="film-stars" fill="#dbd7cd">
|
||||
<circle class="film-star" cx="18" cy="14" r="0.8"/>
|
||||
<circle class="film-star" cx="42" cy="24" r="0.6"/>
|
||||
<circle class="film-star" cx="64" cy="10" r="0.9"/>
|
||||
@@ -417,27 +532,40 @@
|
||||
<circle class="film-star" cx="134" cy="22" r="0.8"/>
|
||||
<circle class="film-star" cx="150" cy="12" r="0.5"/>
|
||||
</g>
|
||||
<circle class="film-moon" cx="122" cy="26" r="7.5" fill="#f1f5f9" opacity="0.92"/>
|
||||
<path class="film-mtn-scroll" d="M0,60 L30,45 L70,55 L110,40 L160,60 L190,45 L230,55 L270,40 L320,60 L320,92 L0,92 Z" fill="rgba(30,27,75,0.7)"/>
|
||||
<line class="film-shooting-star" x1="151" y1="6" x2="143" y2="9.5" stroke="#f0efe7" stroke-width="0.6" stroke-linecap="round"/>
|
||||
<g class="film-moon-g">
|
||||
<circle class="film-moon" cx="122" cy="26" r="7.5" fill="#f0efe7" opacity="0.92"/>
|
||||
<g class="film-moon-detail" fill="#10190e" opacity="0.1">
|
||||
<circle cx="119.6" cy="24.2" r="1.7"/>
|
||||
<circle cx="124.6" cy="28.4" r="1.15"/>
|
||||
<circle cx="123" cy="22.4" r="0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="film-mtn-scroll" d="M0,60 L30,45 L70,55 L110,40 L160,60 L190,45 L230,55 L270,40 L320,60 L320,92 L0,92 Z" fill="rgba(14, 31, 15, 0.7)"/>
|
||||
<g class="film-mist-scroll" fill="#dbd7cd">
|
||||
<ellipse cx="40" cy="63" rx="46" ry="4" opacity="0.055"/>
|
||||
<ellipse cx="120" cy="65" rx="52" ry="4.5" opacity="0.045"/>
|
||||
<ellipse cx="200" cy="63" rx="46" ry="4" opacity="0.055"/>
|
||||
<ellipse cx="280" cy="65" rx="52" ry="4.5" opacity="0.045"/>
|
||||
</g>
|
||||
<g class="film-mid-scroll">
|
||||
<polygon points="20,76 26,60 32,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="55,78 63,52 71,78" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="95,76 101,62 107,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="135,77 144,55 153,77" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="180,76 186,60 192,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="215,78 223,52 231,78" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="255,76 261,62 267,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="295,77 304,55 313,77" fill="rgba(49,46,129,0.55)"/>
|
||||
<path d="M0,76 Q40,73 80,76 T160,76 Q200,73 240,76 T320,76 L320,92 L0,92 Z" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="20,76 26,60 32,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="55,78 63,52 71,78" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="95,76 101,62 107,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="135,77 144,55 153,77" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="180,76 186,60 192,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="215,78 223,52 231,78" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="255,76 261,62 267,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="295,77 304,55 313,77" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<path d="M0,76 Q40,73 80,76 T160,76 Q200,73 240,76 T320,76 L320,92 L0,92 Z" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
</g>
|
||||
<g class="film-fore-scroll">
|
||||
<polygon points="40,84 52,48 64,84" fill="#090d16"/>
|
||||
<polygon points="115,84 128,42 141,84" fill="#090d16"/>
|
||||
<polygon points="200,84 212,48 224,84" fill="#090d16"/>
|
||||
<polygon points="275,84 288,42 301,84" fill="#090d16"/>
|
||||
<path d="M0,84 Q80,82 160,84 T320,84 L320,92 L0,92 Z" fill="#090d16"/>
|
||||
<polygon points="40,84 52,48 64,84" fill="#040903"/>
|
||||
<polygon points="115,84 128,42 141,84" fill="#040903"/>
|
||||
<polygon points="200,84 212,48 224,84" fill="#040903"/>
|
||||
<polygon points="275,84 288,42 301,84" fill="#040903"/>
|
||||
<path d="M0,84 Q80,82 160,84 T320,84 L320,92 L0,92 Z" fill="#040903"/>
|
||||
</g>
|
||||
<circle class="film-bounce-ball" cx="80" cy="46" r="3.5" fill="#fde047"/>
|
||||
</svg>
|
||||
<div class="demo-play-overlay" aria-hidden="true">
|
||||
<span class="demo-icon-wrap">
|
||||
@@ -446,8 +574,16 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="demo-video-controls">
|
||||
<span class="demo-ctrl-icon demo-ctrl-play" aria-hidden="true">
|
||||
<svg class="icon-play" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="11" height="11"><path d="M8 5v14l11-7z"/></svg>
|
||||
<svg class="icon-pause" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="11" height="11"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>
|
||||
</span>
|
||||
<span class="demo-time">12:34</span>
|
||||
<div class="demo-progress" title="Seek: KoalaSync jumps the other tab to the same spot"><div class="demo-progress-fill"></div></div>
|
||||
<div class="demo-progress" title="Seek: KoalaSync jumps the other tab to the same spot"><div class="demo-progress-buffer"></div><div class="demo-progress-fill"></div></div>
|
||||
<span class="demo-duration" aria-hidden="true">42:17</span>
|
||||
<span class="demo-ctrl-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="11" height="11"><path d="M11 5 6 9H2v6h4l5 4V5z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span>
|
||||
<span class="demo-ctrl-badge" aria-hidden="true">HD</span>
|
||||
<span class="demo-ctrl-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="11" height="11"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="demo-tab-toast" id="demo-toast-b" data-joined="{{DEMO_JOINED}}" data-selected="{{DEMO_TAB_SELECTED}}" aria-hidden="true"></div>
|
||||
@@ -457,7 +593,7 @@
|
||||
|
||||
<div class="extension-mockup">
|
||||
<div class="mock-header-row">
|
||||
<div class="mock-header-title"><img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="KoalaSync Logo">KoalaSync</div>
|
||||
<div class="mock-header-title"><img src="{{ASSET_PATH}}assets/NewLogoIcon_64.webp" alt="KoalaSync Logo">KoalaSync</div>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" class="mock-version-link" title="Visit GitHub Repository">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" style="width: 12px; height: 12px; display: block;"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
<span class="mockup-version">{{VERSION}}</span>
|
||||
@@ -477,7 +613,7 @@
|
||||
<div id="mock-room" class="mock-screen active">
|
||||
<!-- Pre-room state, shown only during the scripted walkthrough -->
|
||||
<div id="demo-room-empty" class="demo-room-empty">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="" width="42" height="42" loading="eager" decoding="async">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon_128.webp" alt="" width="42" height="42" loading="eager" decoding="async">
|
||||
<div class="demo-room-empty-text">{{DEMO_NO_ROOM}}</div>
|
||||
<button id="demo-create-room" class="demo-create-room-btn" type="button">{{DEMO_CREATE_ROOM}}</button>
|
||||
</div>
|
||||
@@ -493,7 +629,7 @@
|
||||
<div class="mock-label" title="Share this link with friends so they can join">{{MOCK_03}}</div>
|
||||
<div class="mock-invite-box">
|
||||
<input type="text" class="mock-input" value="https://sync.koalastuff.net/join#join:brave-eagle-80:pass" readonly style="flex: 1;" aria-label="Invite link">
|
||||
<button class="mock-btn" id="demo-invite-copy" style="width: 40px; padding: 0.45rem 0; flex-shrink: 0; background: #334155;" title="Copy Invite Link"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
<button class="mock-btn" id="demo-invite-copy" style="width: 40px; padding: 0.45rem 0; flex-shrink: 0; background: #2f3625; color: #f4f2ea;" title="Copy Invite Link"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-card" style="margin-bottom: 12px;">
|
||||
@@ -505,7 +641,7 @@
|
||||
<span class="mock-peer-meta">{{MOCK_05}}</span>
|
||||
</div>
|
||||
<div class="mock-peer-tab"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12" aria-hidden="true"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></svg> <span class="demo-title-text-ep1">Stranger Things - S4E1</span></div>
|
||||
<div class="mock-peer-status"><span style="color:#22c55e;">●</span> 0:55 • {{MOCK_06}}</div>
|
||||
<div class="mock-peer-status"><span style="color:#56ae6c;">●</span> 0:55 • {{MOCK_06}}</div>
|
||||
</div>
|
||||
<div class="mock-peer-item demo-peer-b">
|
||||
<div class="mock-peer-header">
|
||||
@@ -513,11 +649,11 @@
|
||||
<span class="mock-peer-meta">{{MOCK_07}}</span>
|
||||
</div>
|
||||
<div class="mock-peer-tab"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12" aria-hidden="true"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></svg> <span class="demo-title-text-ep1">Stranger Things - S4E1</span></div>
|
||||
<div class="mock-peer-status"><span style="color:#22c55e;">●</span> 0:55 • {{MOCK_06}}</div>
|
||||
<div class="mock-peer-status"><span style="color:#56ae6c;">●</span> 0:55 • {{MOCK_06}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mock-btn" style="background:#ef4444; width:100%; border: none; padding: 10px; border-radius: 8px; color: white; font-weight: 700; cursor: pointer; border: 1px solid #ef4444;">{{MOCK_08}}</button>
|
||||
<button class="mock-btn" style="background:#b94642; width:100%; border: none; padding: 10px; border-radius: 8px; color: white; font-weight: 700; cursor: pointer; border: 1px solid #b94642;">{{MOCK_08}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -527,9 +663,9 @@
|
||||
<label class="mock-section-label" for="demo-video-select" title="Choose the browser tab containing the video to sync">
|
||||
{{MOCK_09}}
|
||||
</label>
|
||||
<select class="mock-input" id="demo-video-select" style="width: 100%; box-sizing: border-box; background: var(--card); border: 1px solid #334155; color: white; padding: 8px; border-radius: 8px; font-family: inherit; font-size: 0.75rem; outline: none; cursor: default;">
|
||||
<select class="mock-input" id="demo-video-select" style="width: 100%; box-sizing: border-box; background: var(--card); border: 1px solid #2f3625; color: var(--text); padding: 8px; border-radius: 8px; font-family: inherit; font-size: 0.75rem; outline: none; cursor: default;">
|
||||
<option value="" class="demo-select-placeholder" selected hidden>{{DEMO_SELECT_PLACEHOLDER}}</option>
|
||||
<option><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12" aria-hidden="true"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></svg> <span class="demo-title-text-ep1">Stranger Things - S4E1</span></option>
|
||||
<option class="demo-title-text-ep1">Stranger Things - S4E1</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -537,21 +673,21 @@
|
||||
<label style="margin: 0; font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 700;">
|
||||
{{MOCK_10}}
|
||||
</label>
|
||||
<button class="mock-btn" style="background:transparent; border: 1px solid #334155; border-radius: 6px; padding: 2px 6px; font-size: 0.65rem; cursor:default; opacity:0.8; color: var(--text-muted); display: flex; align-items: center; gap: 4px; white-space: nowrap;" title="Copy Invite Link">
|
||||
<button class="mock-btn" style="background:transparent; border: 1px solid #2f3625; border-radius: 6px; padding: 2px 6px; font-size: 0.65rem; cursor:default; opacity:0.8; color: var(--text-muted); display: flex; align-items: center; gap: 4px; white-space: nowrap;" title="Copy Invite Link">
|
||||
<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span>
|
||||
{{MOCK_03}}
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
||||
<button class="mock-btn mock-btn-play" id="demo-play-btn" style="flex: 1; background: #22c55e; color: white; padding: 8px; border-radius: 8px; font-weight: 700; border: none; cursor: pointer;" title="Send a Play command to everyone">{{MOCK_11}}</button>
|
||||
<button class="mock-btn mock-btn-pause" id="demo-pause-btn" style="flex: 1; background: #ef4444; color: white; padding: 8px; border-radius: 8px; font-weight: 700; border: none; cursor: pointer;" title="Send a Pause command to everyone">{{MOCK_12}}</button>
|
||||
<button class="mock-btn mock-btn-play" id="demo-play-btn" style="flex: 1; background: #56ae6c; color: white; padding: 8px; border-radius: 8px; font-weight: 700; border: none; cursor: pointer;" title="Send a Play command to everyone">{{MOCK_11}}</button>
|
||||
<button class="mock-btn mock-btn-pause" id="demo-pause-btn" style="flex: 1; background: #b94642; color: white; padding: 8px; border-radius: 8px; font-weight: 700; border: none; cursor: pointer;" title="Send a Pause command to everyone">{{MOCK_12}}</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 15px; align-items: stretch;">
|
||||
<button class="mock-btn" id="demo-force-sync" style="background: linear-gradient(135deg, #6366f1, #a855f7); flex: 1; color: white; border: none; font-weight: 700; border-radius: 8px; padding: 8px; cursor: pointer;" title="Force all users to sync up">
|
||||
<button class="mock-btn" id="demo-force-sync" style="background: linear-gradient(135deg, #56ae6c, #c96736); flex: 1; color: white; border: none; font-weight: 700; border-radius: 8px; padding: 8px; cursor: pointer;" title="Force all users to sync up">
|
||||
{{MOCK_13}}
|
||||
</button>
|
||||
<div class="mock-input" style="flex: 1; background: var(--card); border: 1px solid #334155; color: white; padding: 6px; border-radius: 8px; font-size: 0.75rem; display: flex; align-items: center; justify-content: space-between;" title="Choose sync target">
|
||||
<div class="mock-input" style="flex: 1; background: var(--card); border: 1px solid #2f3625; color: var(--text); padding: 6px; border-radius: 8px; font-size: 0.75rem; display: flex; align-items: center; justify-content: space-between;" title="Choose sync target">
|
||||
<span>
|
||||
{{MOCK_14}}
|
||||
</span>
|
||||
@@ -564,7 +700,7 @@
|
||||
</label>
|
||||
<div class="mock-card" style="margin-bottom: 15px; display: flex; flex-direction: column; gap: 6px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<span style="font-weight: 700; color: #ef4444; font-size: 11px;">PAUSE</span>
|
||||
<span style="font-weight: 700; color: #b94642; font-size: 11px;">PAUSE</span>
|
||||
<span style="font-size: 9px; color: var(--text-muted);">🐱 ChillCat @ 12:44:32</span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); gap: 5px; margin-top: 4px;">
|
||||
@@ -586,7 +722,7 @@
|
||||
<span class="mock-peer-meta">{{MOCK_05}}</span>
|
||||
</div>
|
||||
<div class="mock-peer-tab"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12" aria-hidden="true"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></svg> <span class="demo-title-text-ep1">Stranger Things - S4E1</span></div>
|
||||
<div class="mock-peer-status"><span style="color:#22c55e;">●</span> 0:55 • {{MOCK_06}}</div>
|
||||
<div class="mock-peer-status"><span style="color:#56ae6c;">●</span> 0:55 • {{MOCK_06}}</div>
|
||||
</div>
|
||||
<div class="mock-peer-item demo-peer-b">
|
||||
<div class="mock-peer-header">
|
||||
@@ -594,7 +730,7 @@
|
||||
<span class="mock-peer-meta">{{MOCK_07}}</span>
|
||||
</div>
|
||||
<div class="mock-peer-tab"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12" aria-hidden="true"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></svg> <span class="demo-title-text-ep1">Stranger Things - S4E1</span></div>
|
||||
<div class="mock-peer-status"><span style="color:#22c55e;">●</span> 0:55 • {{MOCK_06}}</div>
|
||||
<div class="mock-peer-status"><span style="color:#56ae6c;">●</span> 0:55 • {{MOCK_06}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -602,14 +738,14 @@
|
||||
|
||||
<!-- SETTINGS TAB -->
|
||||
<div id="mock-settings" class="mock-screen">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 8px 10px; border-radius: 8px; margin-bottom: 10px; border: 1px solid #334155;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 8px 10px; border-radius: 8px; margin-bottom: 10px; border: 1px solid #2f3625;">
|
||||
<label style="margin: 0; font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 700;" title="Username helps others identify you.">
|
||||
{{MOCK_18}}
|
||||
</label>
|
||||
<input type="text" class="mock-input" value="🐱 ChillCat" readonly style="width: 110px; padding: 4px; text-align: right; background: transparent; border: none; color: var(--accent); font-weight: 700;">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 8px 10px; border-radius: 8px; margin-bottom: 8px; border: 1px solid #334155;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 8px 10px; border-radius: 8px; margin-bottom: 8px; border: 1px solid #2f3625;">
|
||||
<label style="margin: 0; font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 700; cursor: help;" title="Filters out non-video tabs and unrelated domains to keep the list clean">
|
||||
{{MOCK_19}}
|
||||
</label>
|
||||
@@ -619,7 +755,7 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 8px 10px; border-radius: 8px; margin-bottom: 8px; border: 1px solid #334155;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 8px 10px; border-radius: 8px; margin-bottom: 8px; border: 1px solid #2f3625;">
|
||||
<label style="margin: 0; font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 700; cursor: help;" title="Pauses automatically and waits for all peers when an episode changes, then sync-starts together.">
|
||||
{{MOCK_20}}
|
||||
</label>
|
||||
@@ -629,7 +765,7 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 8px 10px; border-radius: 8px; margin-bottom: 8px; border: 1px solid #334155;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 8px 10px; border-radius: 8px; margin-bottom: 8px; border: 1px solid #2f3625;">
|
||||
<label style="margin: 0; font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 700; cursor: help;" title="Automatically copies the invite link to your clipboard when creating a new room.">
|
||||
{{MOCK_21}}
|
||||
</label>
|
||||
@@ -639,7 +775,7 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 8px 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 8px 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #2f3625;">
|
||||
<label style="margin: 0; font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 700; cursor: help;" title="Shows native system notifications when someone joins/leaves or plays/pauses.">
|
||||
{{MOCK_22}}
|
||||
</label>
|
||||
@@ -649,11 +785,11 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px; padding-top: 8px; border-top: 1px solid #334155;">
|
||||
<div style="margin-top: 15px; padding-top: 8px; border-top: 1px solid #2f3625;">
|
||||
<label class="mock-section-label" title="Tools for fixing connection issues">
|
||||
{{MOCK_23}}
|
||||
</label>
|
||||
<button class="mock-btn" style="background:#334155; width:100%; border: none; padding: 10px; border-radius: 8px; color: white; font-weight: 600; cursor: pointer; font-size: 11px;" title="Regenerate your internal ID and reconnect">
|
||||
<button class="mock-btn" style="background:#2f3625; width:100%; border: none; padding: 10px; border-radius: 8px; color: white; font-weight: 600; cursor: pointer; font-size: 11px;" title="Regenerate your internal ID and reconnect">
|
||||
{{MOCK_24}}
|
||||
</button>
|
||||
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center; margin-bottom: 0;">
|
||||
@@ -668,11 +804,11 @@
|
||||
{{MOCK_26}}
|
||||
</label>
|
||||
<div class="mock-card" style="display:flex; align-items:center; gap: 10px; margin-bottom: 12px;">
|
||||
<span class="status-dot status-online" style="width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: #22c55e; box-shadow: 0 0 8px #22c55e;"></span>
|
||||
<span style="flex:1; font-size: 0.75rem; font-weight: 600; color: white;">
|
||||
<span class="status-dot status-online" style="width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: #56ae6c; box-shadow: 0 0 8px #56ae6c;"></span>
|
||||
<span style="flex:1; font-size: 0.75rem; font-weight: 600; color: var(--text);">
|
||||
{{MOCK_27}}
|
||||
</span>
|
||||
<button class="mock-btn" style="width: auto; padding: 4px 8px; font-size: 0.65rem; border: none; background: #334155; color: white; border-radius: 6px; cursor: pointer;" title="Copy logs to clipboard for sharing">
|
||||
<button class="mock-btn" style="width: auto; padding: 4px 8px; font-size: 0.65rem; border: none; background: #2f3625; color: white; border-radius: 6px; cursor: pointer;" title="Copy logs to clipboard for sharing">
|
||||
{{MOCK_28}}
|
||||
</button>
|
||||
</div>
|
||||
@@ -684,14 +820,14 @@
|
||||
<div>readyState: HAVE_ENOUGH_DATA</div>
|
||||
<div>seekDelta: 0.05s</div>
|
||||
<div>buffered: 300.2s</div>
|
||||
<div>engineState: <span style="color:#22c55e; font-weight:700;">SYNCED</span></div>
|
||||
<div>engineState: <span style="color:#56ae6c; font-weight:700;">SYNCED</span></div>
|
||||
</div>
|
||||
|
||||
<label class="mock-section-label" title="Chronological log of all sync commands in the room">
|
||||
{{MOCK_30}}
|
||||
</label>
|
||||
<div class="mock-card" style="font-size: 0.65rem; color: var(--text-muted); line-height: 1.4; margin-bottom: 12px; max-height: 100px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="display: flex; justify-content: space-between; border-bottom: 1px solid #334155; padding-bottom: 4px;">
|
||||
<div style="display: flex; justify-content: space-between; border-bottom: 1px solid #2f3625; padding-bottom: 4px;">
|
||||
<span>🐱 ChillCat: PAUSE</span>
|
||||
<span style="opacity: 0.5;">0:55</span>
|
||||
</div>
|
||||
@@ -710,7 +846,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div style="max-height: 120px; overflow-y: auto; font-size: 0.65rem; font-family: monospace; background: #000; padding: 8px 10px; border-radius: 8px; color: #ccc; line-height: 1.4;">
|
||||
<div style="color:#22c55e;">[18:37:32] EVENT: PLAY -> ACK</div>
|
||||
<div style="color:#56ae6c;">[18:37:32] EVENT: PLAY -> ACK</div>
|
||||
<div>[18:37:32] FORCE_SYNC -> 0.05s diff</div>
|
||||
<div>[18:37:38] EVENT: PAUSE -> BROADCAST</div>
|
||||
<div style="color:var(--text-muted);">[18:37:42] content_heartbeat: active</div>
|
||||
@@ -729,7 +865,7 @@
|
||||
sync.koalastuff.net/join…
|
||||
</div>
|
||||
<div class="demo-cursor" id="demo-cursor" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 2l13 11.5-6 .7 3.4 6.3-2.6 1.4-3.4-6.4L5 19.5z" fill="#f8fafc" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 2l13 11.5-6 .7 3.4 6.3-2.6 1.4-3.4-6.4L5 19.5z" fill="#f4f2ea" stroke="#10190e" stroke-width="1.4" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -825,8 +961,13 @@
|
||||
</h3>
|
||||
<p>{{USE_CASE_3_DESC}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h3 id="features" class="section-subheading" data-reveal>
|
||||
<span>{{NAV_FEATURES}}</span>
|
||||
</h3>
|
||||
<div class="use-cases-grid use-cases-feature-grid">
|
||||
<!-- Tile 1: Instant Invites / 1-Click Join -->
|
||||
<div id="features" class="feature-card feature-card-compact" data-reveal>
|
||||
<div class="feature-card feature-card-compact" data-reveal>
|
||||
<h3>
|
||||
<span class="feature-icon-svg">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="bento-icon"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
@@ -1005,7 +1146,7 @@
|
||||
<div class="illus-popup-card">
|
||||
<div class="illus-popup-header">
|
||||
<div class="illus-popup-brand">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon_64.webp" alt="KoalaSync Logo" width="14" height="14">
|
||||
<span>KoalaSync</span>
|
||||
</div>
|
||||
<div class="illus-popup-version mockup-version">{{VERSION}}</div>
|
||||
@@ -1031,11 +1172,6 @@
|
||||
<span class="details-arrow">▶</span>
|
||||
<span>{{STEP_2_ILLUS_MANUAL}}</span>
|
||||
</div>
|
||||
|
||||
<div class="illus-floating-success">
|
||||
<span class="success-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span>
|
||||
<span>{{STEP_2_ILLUS_COPIED}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1051,7 +1187,7 @@
|
||||
<div class="illus-popup-card">
|
||||
<div class="illus-popup-header">
|
||||
<div class="illus-popup-brand">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon_64.webp" alt="KoalaSync Logo" width="14" height="14">
|
||||
<span>KoalaSync</span>
|
||||
</div>
|
||||
<div class="illus-popup-version mockup-version">{{VERSION}}</div>
|
||||
@@ -1103,28 +1239,27 @@
|
||||
<div class="player-video-canvas">
|
||||
<div class="player-video-overlay"></div>
|
||||
<svg class="demo-film" viewBox="0 0 160 90" preserveAspectRatio="xMidYMid slice" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="film-stars" fill="#e2e8f0">
|
||||
<g class="film-stars" fill="#dbd7cd">
|
||||
<circle class="film-star" cx="18" cy="14" r="0.8"/>
|
||||
<circle class="film-star" cx="42" cy="24" r="0.6"/>
|
||||
<circle class="film-star" cx="64" cy="10" r="0.9"/>
|
||||
<circle class="film-star" cx="90" cy="28" r="0.6"/>
|
||||
<circle class="film-star" cx="134" cy="22" r="0.8"/>
|
||||
</g>
|
||||
<circle class="film-moon" cx="122" cy="26" r="7.5" fill="#f1f5f9" opacity="0.92"/>
|
||||
<path class="film-mtn-scroll" d="M0,60 L30,45 L70,55 L110,40 L160,60 L190,45 L230,55 L270,40 L320,60 L320,92 L0,92 Z" fill="rgba(30,27,75,0.7)"/>
|
||||
<circle class="film-moon" cx="122" cy="26" r="7.5" fill="#f0efe7" opacity="0.92"/>
|
||||
<path class="film-mtn-scroll" d="M0,60 L30,45 L70,55 L110,40 L160,60 L190,45 L230,55 L270,40 L320,60 L320,92 L0,92 Z" fill="rgba(14, 31, 15, 0.7)"/>
|
||||
<g class="film-mid-scroll">
|
||||
<polygon points="20,76 26,60 32,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="55,78 63,52 71,78" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="95,76 101,62 107,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="135,77 144,55 153,77" fill="rgba(49,46,129,0.55)"/>
|
||||
<path d="M0,76 Q40,73 80,76 T160,76 Q200,73 240,76 T320,76 L320,92 L0,92 Z" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="20,76 26,60 32,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="55,78 63,52 71,78" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="95,76 101,62 107,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="135,77 144,55 153,77" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<path d="M0,76 Q40,73 80,76 T160,76 Q200,73 240,76 T320,76 L320,92 L0,92 Z" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
</g>
|
||||
<g class="film-fore-scroll">
|
||||
<polygon points="40,84 52,48 64,84" fill="#090d16"/>
|
||||
<polygon points="115,84 128,42 141,84" fill="#090d16"/>
|
||||
<path d="M0,84 Q40,82 80,84 T160,84 L160,92 L0,92 Z" fill="#090d16"/>
|
||||
<polygon points="40,84 52,48 64,84" fill="#040903"/>
|
||||
<polygon points="115,84 128,42 141,84" fill="#040903"/>
|
||||
<path d="M0,84 Q40,82 80,84 T160,84 L160,92 L0,92 Z" fill="#040903"/>
|
||||
</g>
|
||||
<circle class="film-bounce-ball" cx="80" cy="80" r="4.5" fill="#fde047"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="player-controls">
|
||||
@@ -1156,28 +1291,27 @@
|
||||
<div class="player-video-canvas">
|
||||
<div class="player-video-overlay"></div>
|
||||
<svg class="demo-film" viewBox="0 0 160 90" preserveAspectRatio="xMidYMid slice" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="film-stars" fill="#e2e8f0">
|
||||
<g class="film-stars" fill="#dbd7cd">
|
||||
<circle class="film-star" cx="18" cy="14" r="0.8"/>
|
||||
<circle class="film-star" cx="42" cy="24" r="0.6"/>
|
||||
<circle class="film-star" cx="64" cy="10" r="0.9"/>
|
||||
<circle class="film-star" cx="90" cy="28" r="0.6"/>
|
||||
<circle class="film-star" cx="134" cy="22" r="0.8"/>
|
||||
</g>
|
||||
<circle class="film-moon" cx="122" cy="26" r="7.5" fill="#f1f5f9" opacity="0.92"/>
|
||||
<path class="film-mtn-scroll" d="M0,60 L30,45 L70,55 L110,40 L160,60 L190,45 L230,55 L270,40 L320,60 L320,92 L0,92 Z" fill="rgba(30,27,75,0.7)"/>
|
||||
<circle class="film-moon" cx="122" cy="26" r="7.5" fill="#f0efe7" opacity="0.92"/>
|
||||
<path class="film-mtn-scroll" d="M0,60 L30,45 L70,55 L110,40 L160,60 L190,45 L230,55 L270,40 L320,60 L320,92 L0,92 Z" fill="rgba(14, 31, 15, 0.7)"/>
|
||||
<g class="film-mid-scroll">
|
||||
<polygon points="20,76 26,60 32,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="55,78 63,52 71,78" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="95,76 101,62 107,76" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="135,77 144,55 153,77" fill="rgba(49,46,129,0.55)"/>
|
||||
<path d="M0,76 Q40,73 80,76 T160,76 Q200,73 240,76 T320,76 L320,92 L0,92 Z" fill="rgba(49,46,129,0.55)"/>
|
||||
<polygon points="20,76 26,60 32,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="55,78 63,52 71,78" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="95,76 101,62 107,76" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<polygon points="135,77 144,55 153,77" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
<path d="M0,76 Q40,73 80,76 T160,76 Q200,73 240,76 T320,76 L320,92 L0,92 Z" fill="rgba(26, 47, 23, 0.55)"/>
|
||||
</g>
|
||||
<g class="film-fore-scroll">
|
||||
<polygon points="40,84 52,48 64,84" fill="#090d16"/>
|
||||
<polygon points="115,84 128,42 141,84" fill="#090d16"/>
|
||||
<path d="M0,84 Q40,82 80,84 T160,84 L160,92 L0,92 Z" fill="#090d16"/>
|
||||
<polygon points="40,84 52,48 64,84" fill="#040903"/>
|
||||
<polygon points="115,84 128,42 141,84" fill="#040903"/>
|
||||
<path d="M0,84 Q40,82 80,84 T160,84 L160,92 L0,92 Z" fill="#040903"/>
|
||||
</g>
|
||||
<circle class="film-bounce-ball" cx="80" cy="80" r="4.5" fill="#fde047"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="player-controls">
|
||||
@@ -1240,14 +1374,21 @@
|
||||
<span class="t-key">koala-sync:</span>
|
||||
<span class="t-key">image:</span> <span class="t-str">ghcr.io/shik3i/koalasync:latest</span>
|
||||
<span class="t-key">container_name:</span> <span class="t-str">KoalaSync</span>
|
||||
<span class="t-key">restart:</span> <span class="t-str">always</span>
|
||||
<span class="t-comment"># Access internally by proxying to container port 3000</span>
|
||||
<span class="t-key">restart:</span> <span class="t-str">unless-stopped</span>
|
||||
<span class="t-key">environment:</span>
|
||||
<span class="t-key">-</span> <span class="t-str">TZ=Europe/Berlin</span>
|
||||
<span class="t-key">-</span> <span class="t-str">PORT=3000</span>
|
||||
<span class="t-key">-</span> <span class="t-str">MIN_VERSION=1.0.0</span>
|
||||
<span class="t-key">-</span> <span class="t-str">MAX_ROOMS=100</span>
|
||||
<span class="t-key">-</span> <span class="t-str">MAX_PEERS_PER_ROOM=50</span>
|
||||
<span class="t-key">TZ:</span> <span class="t-str">Europe/Berlin</span>
|
||||
<span class="t-key">PORT:</span> <span class="t-val">3000</span>
|
||||
<span class="t-key">MIN_VERSION:</span> <span class="t-str">1.0.0</span>
|
||||
<span class="t-key">MAX_ROOMS:</span> <span class="t-val">100</span>
|
||||
<span class="t-key">MAX_PEERS_PER_ROOM:</span> <span class="t-val">25</span>
|
||||
<span class="t-comment"># Required: replace before starting</span>
|
||||
<span class="t-key">SERVER_SALT:</span> <span class="t-str">"REPLACE_WITH_OPENSSL_RAND_BASE64_32_OUTPUT"</span>
|
||||
<span class="t-comment"># Optional: leave empty or replace with a 32+ character token</span>
|
||||
<span class="t-key">ADMIN_METRICS_TOKEN:</span> <span class="t-str">""</span>
|
||||
<span class="t-key">DEBUG_LOGGING:</span> <span class="t-str">"0"</span>
|
||||
<span class="t-comment"># Host Caddy reaches the relay only through this loopback binding</span>
|
||||
<span class="t-key">ports:</span>
|
||||
<span class="t-key">-</span> <span class="t-str">"127.0.0.1:3000:3000"</span>
|
||||
<span class="t-key">pids_limit:</span> <span class="t-val">2048</span></code></pre>
|
||||
<div style="margin-top: 1rem; font-size: 0.75rem; text-align: right; padding-right: 0.5rem;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/pkgs/container/koalasync" target="_blank" rel="noopener" style="color: var(--accent); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; font-weight: 600; transition: opacity 0.2s; opacity: 0.85;">
|
||||
@@ -1315,7 +1456,7 @@
|
||||
<span class="t-key">syncserver.koalastuff.net</span> {
|
||||
<span class="t-key">import</span> <span class="t-val">security_headers</span>
|
||||
<span class="t-key">encode</span> <span class="t-val">zstd gzip</span>
|
||||
<span class="t-key">reverse_proxy</span> <span class="t-val">KoalaSync:3000</span>
|
||||
<span class="t-key">reverse_proxy</span> <span class="t-val">localhost:3000</span>
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1377,7 +1518,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="text-align: center; padding: 6rem 0 2rem 0;">
|
||||
<section style="text-align: center; padding: clamp(3.5rem, 6.5vh, 5rem) 0 2rem 0;">
|
||||
<div class="container">
|
||||
<h2 data-reveal style="font-size: 2.2rem; margin-bottom: 1rem;">
|
||||
<span>{{BOTTOM_TITLE}}</span>
|
||||
@@ -1409,20 +1550,22 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. {{FOOTER_MIT}}</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">{{FOOTER_RAM}}</p>
|
||||
<p style="font-size: 0.72rem; margin-top: 1.2rem; color: var(--text-muted); opacity: 0.65; max-width: 650px; margin-left: auto; margin-right: auto; line-height: 1.5;">{{FOOTER_DISCLAIMER}}</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_LEGAL_LINK}}" style="color: var(--text-muted); text-decoration: none;"><span>{{FOOTER_LEGAL}}</span></a>
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}" style="color: var(--text-muted); text-decoration: none;"><span>{{FOOTER_PRIVACY}}</span></a>
|
||||
<p class="footer-ram">{{FOOTER_RAM}}</p>
|
||||
<p class="footer-disclaimer">{{FOOTER_DISCLAIMER}}</p>
|
||||
<p class="footer-copyright">© 2026 KoalaSync. {{FOOTER_MIT}}</p>
|
||||
<div class="footer-links">
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_LEGAL_LINK}}"><span>{{FOOTER_LEGAL}}</span></a>
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}"><span>{{FOOTER_PRIVACY}}</span></a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives"><span><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></span></a>
|
||||
<a href="https://support.koalastuff.net" target="_blank" rel="noopener" title="{{FOOTER_SUPPORT}}" style="color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; background: #c9302c; border: 1px solid rgba(255,255,255,0.18); border-radius: 999px; padding: 3px 9px; font-size: 0.72rem; font-weight: 700; line-height: 1;">
|
||||
<a href="https://support.koalastuff.net" target="_blank" rel="noopener" title="{{FOOTER_SUPPORT}}" class="footer-support-btn">
|
||||
<span aria-hidden="true">♥</span>
|
||||
<span>{{FOOTER_SUPPORT}}</span>
|
||||
</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" class="footer-mastodon-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regenerate the committed country-flag font subset.
|
||||
*
|
||||
* npm run subset-flags (or: node website/tools/subset-flag-font.mjs)
|
||||
*
|
||||
* Extracts every flag emoji used in website HTML/locales, subsets
|
||||
* assets/TwemojiCountryFlags.woff2 down to exactly those flags via Python
|
||||
* fontTools (harfbuzz-based JS subsetters drop the COLR color tables and
|
||||
* produce invisible flags), and writes:
|
||||
*
|
||||
* assets/TwemojiCountryFlags.subset.woff2 (committed)
|
||||
* assets/TwemojiCountryFlags.subset.json (committed manifest)
|
||||
*
|
||||
* The website build (build.cjs) is pure Node: it only validates the
|
||||
* manifest and fails when a flag was added without re-running this tool.
|
||||
*
|
||||
* Requires python3 with fonttools + brotli. Override the interpreter with
|
||||
* the PYTHON env var, e.g. PYTHON=.venv/bin/python npm run subset-flags
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const websiteDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const { collectFlagSourceFiles, extractUsedFlags, flagToCountryCode } = require(path.join(websiteDir, 'flag-font-utils.cjs'));
|
||||
|
||||
const sourceFont = path.join(websiteDir, 'assets', 'TwemojiCountryFlags.woff2');
|
||||
const outputFont = path.join(websiteDir, 'assets', 'TwemojiCountryFlags.subset.woff2');
|
||||
const manifestPath = path.join(websiteDir, 'assets', 'TwemojiCountryFlags.subset.json');
|
||||
|
||||
const python = process.env.PYTHON || 'python3';
|
||||
try {
|
||||
execFileSync(python, ['-c', 'import fontTools.subset, brotli'], { stdio: 'pipe' });
|
||||
} catch {
|
||||
console.error(`error: ${python} lacks fonttools/brotli.`);
|
||||
console.error('Install them (e.g. "pip3 install fonttools brotli") or point PYTHON at an interpreter that has them:');
|
||||
console.error(' PYTHON=/path/to/venv/bin/python npm run subset-flags');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const flags = extractUsedFlags(collectFlagSourceFiles(websiteDir));
|
||||
if (flags.length === 0) {
|
||||
console.error('error: no flag emoji found in website sources — extraction is broken');
|
||||
process.exit(1);
|
||||
}
|
||||
const codes = flags.map(flagToCountryCode);
|
||||
console.log(`Found ${codes.length} flags: ${codes.join(' ')}`);
|
||||
|
||||
execFileSync(python, [
|
||||
path.join(websiteDir, 'tools', 'subset_flag_font.py'),
|
||||
sourceFont,
|
||||
outputFont,
|
||||
codes.join(',')
|
||||
], { stdio: 'inherit' });
|
||||
|
||||
const sha = buf => crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const manifest = {
|
||||
comment: 'Generated by tools/subset-flag-font.mjs — do not edit. build.cjs fails when site flags and this manifest diverge.',
|
||||
flags: codes,
|
||||
sourceFontSha256: sha(fs.readFileSync(sourceFont)),
|
||||
subsetSha256: sha(fs.readFileSync(outputFont))
|
||||
};
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
|
||||
const before = fs.statSync(sourceFont).size;
|
||||
const after = fs.statSync(outputFont).size;
|
||||
console.log(`Wrote ${path.relative(process.cwd(), outputFont)} (${(after / 1024).toFixed(1)} KB, -${((1 - after / before) * 100).toFixed(0)}% vs source) + manifest.`);
|
||||
console.log('Commit both files.');
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Subset TwemojiCountryFlags.woff2 to the given country flags.
|
||||
|
||||
Invoked by subset-flag-font.mjs — do not run directly unless debugging:
|
||||
python3 subset_flag_font.py <source.woff2> <output.woff2> DE,FR,GB,...
|
||||
|
||||
Requires: fonttools, brotli (pip install fonttools brotli).
|
||||
|
||||
harfbuzz-based JS subsetters (subset-font/harfbuzzjs) silently drop the
|
||||
COLR/CPAL color tables of this font, producing invisible flags — fontTools
|
||||
subsets them correctly. Each requested flag is validated to have a GSUB
|
||||
ligature AND COLR color layers so a broken subset can never be emitted.
|
||||
"""
|
||||
import io
|
||||
import sys
|
||||
|
||||
from fontTools import subset
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
|
||||
def flag_codepoints(code):
|
||||
return [0x1F1E6 + ord(ch) - ord("A") for ch in code]
|
||||
|
||||
|
||||
def ligature_glyph(font, first, second):
|
||||
"""Return the GSUB ligature glyph for a regional-indicator pair."""
|
||||
gsub = font["GSUB"].table
|
||||
for lookup in gsub.LookupList.Lookup:
|
||||
subtables = []
|
||||
if lookup.LookupType == 4:
|
||||
subtables = lookup.SubTable
|
||||
elif lookup.LookupType == 7: # extension
|
||||
subtables = [st.ExtSubTable for st in lookup.SubTable if st.ExtensionLookupType == 4]
|
||||
for st in subtables:
|
||||
for lig in st.ligatures.get(first, []):
|
||||
if lig.Component == [second]:
|
||||
return lig.LigGlyph
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
src_path, out_path, codes_arg = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
codes = sorted(codes_arg.split(","))
|
||||
|
||||
font = TTFont(src_path)
|
||||
if "COLR" not in font or "CPAL" not in font:
|
||||
sys.exit("source font has no COLR/CPAL tables")
|
||||
|
||||
text = "".join(chr(cp) for code in codes for cp in flag_codepoints(code))
|
||||
options = subset.Options()
|
||||
options.flavor = "woff2"
|
||||
subsetter = subset.Subsetter(options=options)
|
||||
subsetter.populate(text=text)
|
||||
subsetter.subset(font)
|
||||
|
||||
buf = io.BytesIO()
|
||||
font.save(buf)
|
||||
result = TTFont(io.BytesIO(buf.getvalue()))
|
||||
|
||||
for table in ("COLR", "CPAL", "GSUB", "cmap"):
|
||||
if table not in result:
|
||||
sys.exit(f"subset lost the {table} table")
|
||||
|
||||
cmap = result.getBestCmap()
|
||||
glyph_name = {}
|
||||
for code in codes:
|
||||
for cp in flag_codepoints(code):
|
||||
if cp not in cmap:
|
||||
sys.exit(f"flag {code}: regional indicator U+{cp:X} missing from subset cmap")
|
||||
first, second = (cmap[cp] for cp in flag_codepoints(code))
|
||||
lig = ligature_glyph(result, first, second)
|
||||
if lig is None:
|
||||
sys.exit(f"flag {code}: no GSUB ligature in subset — font has no artwork for it")
|
||||
glyph_name[code] = lig
|
||||
|
||||
colr = result["COLR"]
|
||||
if colr.version == 0:
|
||||
for code, lig in glyph_name.items():
|
||||
if not colr.ColorLayers.get(lig):
|
||||
sys.exit(f"flag {code}: ligature glyph {lig} has no COLR color layers")
|
||||
# COLR v1 would need different traversal; Twemoji country flags is v0
|
||||
elif colr.version != 0:
|
||||
sys.exit(f"unexpected COLR version {colr.version} — validation only supports v0")
|
||||
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(buf.getvalue())
|
||||
print(f"subset OK: {len(codes)} flags, {len(buf.getvalue())} bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user