docs: add comprehensive roadmap with implementation plans

This commit is contained in:
Koala
2026-05-25 09:36:42 +02:00
parent 3c671bcfab
commit 552afac26a
+665
View File
@@ -0,0 +1,665 @@
# KoalaSync Roadmap
This document tracks planned features, improvements, and their implementation details.
---
## Antworten auf offene Fragen
### 1. Graceful Shutdown
**Korrektur:** Der Server hat bereits Graceful Shutdown implementiert (`server/index.js:481-499`). Bei SIGTERM/SIGINT wird allen Clients eine Neustart-Nachricht gesendet, der HTTP-Server geschlossen und nach 5s erzwungen beendet. **Kein Handlungsbedarf.**
### 2. `/health` Endpoint Rate Limiting
Ja, sollte rate-limitiert werden. Aktuell ist `/` ein offener GET-Endpoint. Ein Angreifer könnte diesen endlos pollen. Lösung: Einfacher IP-basierter Rate-Limiter (ähnlich wie bei WebSocket-Connections) mit 60 Requests/Minute pro IP.
### 3. `setInterval(refreshLogs, 5000)` Last
5 Sekunden ist technisch wenig Last (~0.2 IPC calls/sec). Aber es ist **architektonisch unsauber** — der Interval läuft auch wenn der Dev-Tab nicht sichtbar ist. Besser: Nur pollen wenn Dev-Tab aktiv, oder auf `chrome.runtime.onMessage` umstellen (Push statt Poll).
### 4. `startInterpolation()` Memory Leak
**Ja, echtes Problem.** Wenn das Popup geschlossen wird, läuft `interpolationInterval` weiter im Service Worker Kontext. Bei jedem Popup-Öffnen wird ein neuer Interval erstellt (`if (interpolationInterval) return;` schützt nur vor Duplikaten innerhalb derselben Instanz). Über Zeit summieren sich verwaiste Intervals. Fix: `window.addEventListener('unload')` oder Popup-Lifecycle-Listener.
### 5. `<video>`-Element dynamisch entfernt (SPA-Navigation)
Der `MutationObserver` (`content.js:523-532`) erkennt DOM-Änderungen und ruft `checkVideo()` auf. Was passieren sollte:
- **Video entfernt:** Event-Listener cleanen, `lastVideoSrc` zurücksetzen, Heartbeat pausieren (kein Video = nichts zu syncen)
- **Neues Video erscheint:** `setupListeners()` aufrufen, neue Event-Listener attachen, Heartbeat fortsetzen
- **Aktueller Stand:** `checkVideo()` erkennt neue Videos, aber wenn das Video komplett verschwindet, laufen die alten Listener ins Leere und `sendHeartbeat()` returned early (`if (!video) return;`). **Verbesserung:** Explizites Cleanup wenn kein Video mehr gefunden wird.
### 6. Service Worker Fallback bei Room-State Verlust
Manifest V3 suspendiert den Service Worker nach ~30s Inaktivität. `chrome.alarms` weckt ihn auf, aber:
- **Problem:** Wenn der SW neu startet, sind alle Variablen (`currentRoom`, `socket`, `isNamespaceJoined`) weg
- **Aktueller Stand:** `chrome.storage.session` persistiert `currentRoom`, `peerId`, `eventQueue` — der SW stellt diese beim Start wieder her (`restoreSession()`)
- **Lücke:** Der WebSocket muss neu aufgebaut werden. Das passiert automatisch via `connect()`, aber es gibt eine **Zeitlücke** von 2-5 Sekunden in der Events verloren gehen können. **Verbesserung:** Queue-Events während Reconnect, visualisiere "Reconnecting..." im Popup.
### 7. Tests für Extensions
Stimmt, sind aufwändig. Praktische Ansätze:
- **Unit Tests:** `jest` + `jest-chrome` (mockt `chrome.*` APIs) — testet `popup.js` Logik, Server-Logik
- **Integration Tests:** `puppeteer` mit `--load-extension` Flag — testet Extension im echten Browser
- **Server Tests:** `supertest` + `socket.io-client` — testet WebSocket-Flows
- **Aufwand:** ~400-600 LOC für sinnvolle Testabdeckung der Kernlogik
---
## Phase 1: UI/UX Quick Wins (nächstes Release)
### 1.1 Toast-Benachrichtigungssystem
**Ziel:** Zentrales Toast-System für Success/Error/Info/Warning Nachrichten. Ersetzt `showError()` mit Shake-Animation.
**Dateien:** `extension/popup.html`, `extension/popup.js`
**Implementierung:**
1. **HTML-Struktur** (in `popup.html`, nach `<body>`):
```html
<div id="toast-container" style="position:fixed; top:0; left:0; right:0; z-index:9999; display:flex; flex-direction:column; align-items:center; pointer-events:none;"></div>
```
2. **CSS** (in `<style>` in `popup.html`):
```css
.toast {
pointer-events: auto;
padding: 10px 16px;
margin-bottom: 6px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
max-width: 280px;
text-align: center;
animation: toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in 2.7s forwards;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.toast-success { background: var(--success); color: white; }
.toast-error { background: var(--error); color: white; }
.toast-info { background: var(--accent); color: white; }
.toast-warning { background: #f59e0b; color: white; }
@keyframes toastSlideIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toastFadeOut {
from { opacity: 1; }
to { opacity: 0; transform: translateY(-10px); }
}
```
3. **JavaScript** (in `popup.js`):
```javascript
function showToast(message, type = 'info', duration = 3000) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), duration);
}
```
4. **Integration:**
- `showError(msg)` ersetzen durch `showToast(msg, 'error')`
- Peer joined/left: `showToast(\`${username} joined\`, 'success')`
- Play/Pause/Seek empfangen: `showToast(\`${username} ▶ Play\`, 'info', 2000)`
- Force Sync: `showToast('Force Sync initiated', 'warning')`
- Connection status: `showToast('Connected', 'success')`
**Aufwand:** ~80 LOC
**Nutzen:** 9/10 — Sofort spürbare UX-Verbesserung
---
### 1.2 Tab-Sortierung (Target Tab Dropdown)
**Ziel:** Target Tab Dropdown sortieren: (1) Aktiver Tab zuerst, (2) Matches, (3) Rest alphabetisch.
**Dateien:** `extension/popup.js` — Funktion `populateTabs()`
**Implementierung:**
In `populateTabs()` nach dem Filtern (ca. Zeile 455):
```javascript
// Sortierung: 1. Aktueller Tab, 2. Matches, 3. Rest
const currentTabId = currentTargetTabId ? parseInt(currentTargetTabId) : null;
options.sort((a, b) => {
const aId = parseInt(a.value);
const bId = parseInt(b.value);
// 1. Aktiver Tab zuerst
if (aId === currentTabId) return -1;
if (bId === currentTabId) return 1;
// 2. Matches vor Nicht-Matches
const aMatch = a.textContent.includes('⭐');
const bMatch = b.textContent.includes('⭐');
if (aMatch && !bMatch) return -1;
if (!aMatch && bMatch) return 1;
// 3. Alphabetisch innerhalb der Gruppen
return a.textContent.localeCompare(b.textContent);
});
```
**Aufwand:** ~15 LOC
**Nutzen:** 7/10 — Intuitivere Tab-Auswahl
---
### 1.3 Copy-to-Clipboard Feedback (Invite Link)
**Ziel:** Besseres visuelles Feedback beim Kopieren des Invite-Links.
**Dateien:** `extension/popup.js``elements.copyInvite` Event Listener
**Implementierung:**
Bestehenden Handler (Zeile 893-897) ersetzen:
```javascript
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';
showToast('Invite link copied!', 'success', 2000);
setTimeout(() => {
elements.copyInvite.textContent = original;
elements.copyInvite.style.background = '';
elements.copyInvite.style.color = '';
}, 2000);
});
});
```
**Aufwand:** ~10 LOC
**Nutzen:** 6/10 — Kleines aber feines Detail
---
### 1.4 Last Action Card mit targetTime
**Ziel:** Bei Seek/Force Sync die `targetTime` in der Last Action Card anzeigen.
**Dateien:** `extension/popup.js` — Funktion `updateLastActionUI()`
**Implementierung:**
In `updateLastActionUI()` nach dem Header (ca. Zeile 179):
```javascript
// targetTime für Seek/Force Sync anzeigen
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.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.textContent = `Sync to: ${formatTime(state.targetTime)}`;
elements.lastActionCard.appendChild(timeInfo);
}
```
**Aufwand:** ~15 LOC
**Nutzen:** 7/10 — Mehr Kontext bei Sync-Actions
---
### 1.5 Verbesserte Empty States
**Ziel:** Aussagekräftigere Empty States mit Icons und Hilfetexten.
**Dateien:** `extension/popup.html`, `extension/popup.js`
**Implementierung:**
Ersetze generische "No peers connected" / "No activity yet" Texte:
```javascript
// In updatePeerList() und updateHistory():
const emptyStates = {
peers: {
icon: '👥',
title: 'No peers yet',
hint: 'Share your invite link to get started'
},
history: {
icon: '📋',
title: 'No activity yet',
hint: 'Play, pause, or seek to see history'
},
logs: {
icon: '📝',
title: 'No logs',
hint: 'Connection events will appear here'
},
rooms: {
icon: '🔍',
title: 'No active rooms',
hint: 'Create a room or refresh to find public ones'
}
};
// Render-Funktion:
function renderEmpty(container, type) {
const state = emptyStates[type];
container.innerHTML = `
<div style="text-align:center; padding:16px 8px; color:var(--text-muted);">
<div style="font-size:24px; margin-bottom:6px;">${state.icon}</div>
<div style="font-size:12px; font-weight:600; margin-bottom:4px;">${state.title}</div>
<div style="font-size:10px; opacity:0.7;">${state.hint}</div>
</div>
`;
}
```
**Aufwand:** ~40 LOC
**Nutzen:** 7/10 — Professionellerer Look
---
### 1.6 Onboarding Tour
**Ziel:** Beim ersten Start kurze Erklärung der 4 Tabs mit Tooltips.
**Dateien:** `extension/popup.html`, `extension/popup.js`, `extension/background.js`
**Implementierung:**
1. **Storage-Flag** (in `background.js` beim ersten Start):
```javascript
// In restoreSession() oder init():
const data = await chrome.storage.sync.get(['onboardingComplete']);
if (!data.onboardingComplete) {
chrome.runtime.sendMessage({ type: 'SHOW_ONBOARDING' });
}
```
2. **Onboarding-Overlay** (in `popup.html`, am Ende von `<body>`):
```html
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:1000; display:flex; align-items:center; justify-content:center;">
<div id="onboarding-card" style="background:var(--card); padding:24px; border-radius:16px; max-width:280px; text-align:center;">
<div id="onboarding-icon" style="font-size:48px; margin-bottom:12px;">👋</div>
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 8px;">Welcome to KoalaSync!</h2>
<p id="onboarding-text" style="color:var(--text-muted); font-size:13px; margin:0 0 16px;">Let's get you started.</p>
<div style="display:flex; gap:8px; justify-content:center;">
<button id="onboarding-skip" class="secondary" style="width:auto; padding:8px 16px;">Skip</button>
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;">Next</button>
</div>
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
</div>
</div>
```
3. **JavaScript** (in `popup.js`):
```javascript
const onboardingSteps = [
{ icon: '🏠', title: 'Room Tab', text: 'Create or join a room to sync with friends. Share the invite link!' },
{ icon: '🎬', title: 'Sync Tab', text: 'Select your video tab and control playback. Force Sync fixes drift.' },
{ icon: '⚙️', title: 'Settings', text: 'Customize your username, filter noise tabs, and toggle auto-sync.' },
{ icon: '🔧', title: 'Dev Tab', text: 'Debug connection status, video state, and view action history.' }
];
let onboardingStep = 0;
function showOnboarding() {
const overlay = document.getElementById('onboarding-overlay');
overlay.style.display = 'flex';
renderOnboardingStep();
}
function renderOnboardingStep() {
const step = onboardingSteps[onboardingStep];
document.getElementById('onboarding-icon').textContent = step.icon;
document.getElementById('onboarding-title').textContent = step.title;
document.getElementById('onboarding-text').textContent = step.text;
// Dots
const dots = document.getElementById('onboarding-dots');
dots.innerHTML = onboardingSteps.map((_, i) =>
`<div style="width:8px; height:8px; border-radius:50%; background:${i === onboardingStep ? 'var(--accent)' : '#475569'};"></div>`
).join('');
document.getElementById('onboarding-next').textContent =
onboardingStep === onboardingSteps.length - 1 ? 'Done!' : 'Next';
}
document.getElementById('onboarding-next').addEventListener('click', () => {
onboardingStep++;
if (onboardingStep >= onboardingSteps.length) {
completeOnboarding();
} else {
renderOnboardingStep();
}
});
document.getElementById('onboarding-skip').addEventListener('click', completeOnboarding);
function completeOnboarding() {
document.getElementById('onboarding-overlay').style.display = 'none';
chrome.storage.sync.set({ onboardingComplete: true });
}
// In init():
chrome.storage.sync.get(['onboardingComplete'], (data) => {
if (!data.onboardingComplete) showOnboarding();
});
```
**Aufwand:** ~120 LOC
**Nutzen:** 8/10 — Reduziert Abbruchrate neuer Nutzer
---
### 1.7 Browser Notifications + Toasts für Sync-Events
**Ziel:** System-Benachrichtigungen bei Peer-Events (opt-in) UND Toast-Nachrichten für alle Sync-Events.
**Dateien:** `extension/popup.html`, `extension/popup.js`, `extension/background.js`, `extension/manifest.base.json`
**Implementierung:**
1. **Permission** (in `manifest.base.json`):
```json
"permissions": ["storage", "tabs", "scripting", "alarms", "notifications"]
```
2. **Settings Toggle** (in `popup.html`, Settings Tab):
```html
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<label style="margin-bottom: 0;">Browser Notifications</label>
<input type="checkbox" id="browserNotifications" style="width: auto;">
</div>
```
3. **Background.js — Notification Handler:**
```javascript
function notifyPeerEvent(event, data) {
chrome.storage.sync.get(['browserNotifications'], (settings) => {
if (!settings.browserNotifications) return;
const notifications = {
'joined': { title: 'Peer Joined', body: `${data.username || data.peerId} joined the room` },
'left': { title: 'Peer Left', body: `${data.username || data.peerId} left the room` },
'play': { title: 'Playback Started', body: `${data.senderId} pressed Play` },
'pause': { title: 'Playback Paused', body: `${data.senderId} pressed Pause` },
'force_sync': { title: 'Force Sync', body: `${data.senderId} initiated Force Sync` }
};
const config = notifications[event];
if (!config) return;
chrome.notifications.create({
type: 'basic',
iconUrl: 'icons/icon128.png',
title: config.title,
message: config.body,
priority: 1
});
});
}
```
4. **Popup.js — Toasts für alle Events:**
```javascript
// In chrome.runtime.onMessage.addListener():
if (msg.type === 'PEER_UPDATE') {
updatePeerList(msg.peers);
// Detect join/leave durch Vergleich mit vorheriger Liste
detectPeerChanges(msg.peers);
}
function detectPeerChanges(newPeers) {
const oldIds = new Set(lastKnownPeers.map(p => p.peerId || p));
const newIds = new Set(newPeers.map(p => p.peerId || p));
// Neue Peers
for (const peer of newPeers) {
const id = peer.peerId || peer;
if (!oldIds.has(id)) {
const name = peer.username || id.substring(0, 4);
showToast(`${name} joined the room`, 'success');
}
}
// Verlassene Peers
for (const oldPeer of lastKnownPeers) {
const id = oldPeer.peerId || oldPeer;
if (!newIds.has(id)) {
const name = oldPeer.username || id.substring(0, 4);
showToast(`${name} left the room`, 'info');
}
}
lastKnownPeers = newPeers;
}
```
5. **Toast für Play/Pause/Seek:**
```javascript
// In onMessage für ACTION_UPDATE:
if (msg.type === 'ACTION_UPDATE') {
const state = msg.state;
if (state && state.senderId !== 'You') {
const actionNames = {
'play': '▶ Play',
'pause': '⏸ Pause',
'seek': '⏩ Seek',
'force_sync_prepare': '⚡ Force Sync',
'force_sync_execute': '⚡ Force Play'
};
const action = actionNames[state.action] || state.action;
showToast(`${state.senderId} ${action}`, 'info', 2000);
}
// ... existing updateLastActionUI call
}
```
**Aufwand:** ~150 LOC
**Nutzen:** 9/10 — Kritisch für UX, Nutzer wissen was passiert
---
## Phase 2: Quality of Life Fixes
### 2.1 Interpolation Cleanup (Memory Leak Fix)
**Ziel:** `interpolationInterval` beim Popup-Schließen bereinigen.
**Dateien:** `extension/popup.js`
**Implementierung:**
```javascript
// In init() oder am Ende der Datei:
window.addEventListener('unload', () => {
stopInterpolation();
});
// Oder für Browser Extensions spezifisch:
chrome.runtime.onSuspend.addListener(() => {
stopInterpolation();
});
```
**Aufwand:** ~5 LOC
**Nutzen:** 8/10 — Verhindert Memory Leak
---
### 2.2 Dev-Tab Logs nur bei Sichtbarkeit pollen
**Ziel:** `refreshLogs` nur ausführen wenn Dev-Tab sichtbar.
**Dateien:** `extension/popup.js`
**Implementierung:**
```javascript
// Ersetze setInterval(refreshLogs, 5000) durch:
let isDevTabVisible = false;
elements.tabs.forEach(btn => {
btn.addEventListener('click', () => {
// ... existing tab switch logic
isDevTabVisible = btn.dataset.tab === 'tab-dev';
if (isDevTabVisible) refreshLogs();
});
});
// Poll nur wenn sichtbar
setInterval(() => {
if (isDevTabVisible) refreshLogs();
}, 5000);
```
**Aufwand:** ~10 LOC
**Nutzen:** 5/10 — Kleine Optimierung
---
### 2.3 content.js: Explizites Video-Cleanup
**Ziel:** Wenn `<video>`-Element entfernt wird, Listener cleanen und Heartbeat pausieren.
**Dateien:** `extension/content.js`
**Implementierung:**
In `checkVideo()` (ca. Zeile 507):
```javascript
function checkVideo() {
lastMutate = Date.now();
const video = findVideo();
// Video removed — cleanup
if (!video && lastVideoSrc) {
reportLog('Video element removed from page', 'warn');
lastVideoSrc = null;
// Heartbeat returns early automatically (if (!video) return)
return;
}
if (!video) return;
const currentSrc = video.currentSrc || video.src;
if (!video.dataset.koalaAttached || (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc)) {
if (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc) {
checkEpisodeTransition();
}
setupListeners();
}
}
```
**Aufwand:** ~10 LOC
**Nutzen:** 7/10 — Saubereres Verhalten bei SPA-Navigation
---
### 2.4 `/health` Endpoint Rate Limiting
**Ziel:** Den offenen `/` Endpoint gegen IP-basiertes Rate Limiting schützen (60 req/min pro IP).
**Dateien:** `server/index.js`
**Implementierung:**
In `server/index.js`, nach den bestehenden Rate-Limiting Maps (ca. Zeile 47):
```javascript
// Health Endpoint Rate Limiting
const healthCounts = new Map(); // ip -> { count, resetTime }
function checkHealthRate(ip) {
const now = Date.now();
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
healthCounts.set(ip, entry);
return entry.count <= 60; // 60 requests per minute
}
// Cleanup health counts im bestehenden Cleanup-Interval (Zeile 89):
// Füge hinzu in der setInterval cleanup loop:
for (const [ip, entry] of healthCounts.entries()) {
if (now > entry.resetTime) {
healthCounts.delete(ip);
}
}
```
Bestehenden `/` Endpoint (Zeile 19) ersetzen:
```javascript
app.get('/', (req, res) => {
const clientIp = req.ip;
if (!checkHealthRate(clientIp)) {
return res.status(429).json({ error: 'Too many requests. Try again later.' });
}
res.json({ status: 'online', service: 'KoalaSync Relay', version: process.env.npm_package_version || 'unknown' });
});
```
Optional: Dedizierten `/health` Endpoint für Docker/Monitoring hinzufügen:
```javascript
app.get('/health', (req, res) => {
const clientIp = req.ip;
if (!checkHealthRate(clientIp)) {
return res.status(429).json({ error: 'Rate limited' });
}
res.json({
status: 'ok',
uptime: process.uptime(),
rooms: rooms.size,
connections: io.engine.clientsCount,
timestamp: Date.now()
});
});
```
**Aufwand:** ~25 LOC
**Nutzen:** 7/10 — Verhindert Missbrauch des offenen Endpoints
---
## Priorisierung
| Priority | Feature | Aufwand | Nutzen |
|----------|---------|---------|--------|
| 🔴 Hoch | Toast-Benachrichtigungen | ~80 LOC | 9/10 |
| 🔴 Hoch | Browser Notifications + Event Toasts | ~150 LOC | 9/10 |
| 🟡 Mittel | Onboarding Tour | ~120 LOC | 8/10 |
| 🟡 Mittel | Interpolation Cleanup | ~5 LOC | 8/10 |
| 🟡 Mittel | `/health` Rate Limiting | ~25 LOC | 7/10 |
| 🟡 Mittel | Verbesserte Empty States | ~40 LOC | 7/10 |
| 🟡 Mittel | Last Action Card mit targetTime | ~15 LOC | 7/10 |
| 🟡 Mittel | content.js Video Cleanup | ~10 LOC | 7/10 |
| 🟢 Niedrig | Tab-Sortierung | ~15 LOC | 7/10 |
| 🟢 Niedrig | Copy-to-Clipboard Feedback | ~10 LOC | 6/10 |
| 🟢 Niedrig | Dev-Tab Logs Sichtbarkeit | ~10 LOC | 5/10 |
**Gesamtaufwand Phase 1+2:** ~480 LOC
**Geschätzte Zeit:** 2-4 Tage
---
## Zukünftige Features (spätere Releases)
Diese Features wurden evaluiert aber sind nicht Teil der nächsten Release:
| Feature | Aufwand | Nutzen | Status |
|---------|---------|--------|--------|
| Chat im Room | ~400 LOC | 9/10 | Geplant v1.6 |
| Playback Speed Sync | ~150 LOC | 8/10 | Geplant v1.5 |
| Room Host/Owner | ~350 LOC | 8/10 | Geplant v1.6 |
| Auto-Reconnect mit State | ~250 LOC | 9/10 | Teilweise implementiert |
| Multi-Video Support | ~300 LOC | 7/10 | Geplant v2.0 |
| Volume Sync | ~120 LOC | 6/10 | Backlog |
| Stats Dashboard | ~200 LOC | 6/10 | Backlog |
| Custom Themes | ~80 LOC | 5/10 | Backlog |