video side: Music <-> Video sidebar toggle + video nav shell (isolated)

First slice of the video side, on the experimental branch. Purely additive and
fully isolated from music:
- A Music | Video toggle in the sidebar header; clicking flips body[data-side]
  (remembered in localStorage). The shared shell (logo, user, Support, Version)
  stays; only the nav set + subtitle swap.
- A second sidebar nav (.video-nav) with the video pages — Dashboard, Search,
  Discover, Library, Calendar, Import, Settings, Issues, Help & Docs — shown via
  CSS off body[data-side]. Service Status is hidden on the video side.
- A placeholder content host; real video pages land later.

Isolation contract held: index.html is +51/-0 (no music markup changed), music
JS/CSS untouched, nothing in music references the controller. The controller
(webui/static/video/video-side.js) is a self-contained IIFE wired purely via
addEventListener (no globals, no inline onclick) — so it can't affect music and
doesn't trip the script-split-integrity contract.

Tests: 6 video-shell structural/isolation tests + 64 script-integrity green.
This commit is contained in:
BoulderBadgeDad 2026-06-13 19:13:54 -07:00
parent 2953c2c5d2
commit 3202197740
4 changed files with 359 additions and 0 deletions

View file

@ -0,0 +1,86 @@
"""Video side shell: the Music ↔ Video toggle + video sidebar (experimental branch).
This is the first slice of the video side. It must be ADDITIVE and ISOLATED:
the music sidebar/nav is untouched, the toggle + video nav are wired purely via
data-attributes (no inline onclick which would also break the script-split
integrity contract), and the controller is a self-contained IIFE that adds no
globals. These pin all of that so a regression can't silently couple the two
sides or break the music shell.
"""
from __future__ import annotations
import re
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8", errors="replace")
_JS = (_ROOT / "webui" / "static" / "video" / "video-side.js").read_text(encoding="utf-8")
_CSS_PATH = _ROOT / "webui" / "static" / "video" / "video-side.css"
EXPECTED_VIDEO_PAGES = {
"video-dashboard", "video-search", "video-discover", "video-library",
"video-calendar", "video-import", "video-settings", "video-issues", "video-help",
}
def _block(html: str, open_tag_re: str, close_tag: str) -> str:
"""Return the substring from the first match of open_tag_re to the next close_tag."""
m = re.search(open_tag_re, html)
assert m, f"could not find {open_tag_re!r}"
end = html.index(close_tag, m.end())
return html[m.start():end + len(close_tag)]
# --- the toggle ------------------------------------------------------------
def test_side_toggle_has_music_and_video():
block = _block(_INDEX, r'<div class="side-toggle"', "</div>")
assert 'data-side-target="music"' in block
assert 'data-side-target="video"' in block
# Wired by JS, not inline handlers (isolation + integrity contract).
assert "onclick" not in block
# --- the video nav ---------------------------------------------------------
def test_video_nav_has_all_expected_pages():
block = _block(_INDEX, r'<nav class="sidebar-nav video-nav"', "</nav>")
found = set(re.findall(r'data-video-page="([^"]+)"', block))
assert found == EXPECTED_VIDEO_PAGES, f"video nav pages mismatch: {found ^ EXPECTED_VIDEO_PAGES}"
assert "onclick" not in block # data-attr wired, no inline handlers
def test_video_page_host_present():
assert 'id="video-page-host"' in _INDEX
assert 'class="page video-page"' in _INDEX
def test_video_assets_referenced():
assert "video/video-side.js" in _INDEX
assert "video/video-side.css" in _INDEX
assert _CSS_PATH.exists() and _CSS_PATH.stat().st_size > 0
# --- isolation: music side untouched --------------------------------------
def test_music_sidebar_nav_still_intact():
# The original music nav (a sibling .sidebar-nav WITHOUT the video-nav class)
# must still carry its pages — the video side is additive, not a rewrite.
music_nav = _block(_INDEX, r'<nav class="sidebar-nav">', "</nav>")
for page in ("dashboard", "sync", "library", "settings", "issues", "help"):
assert f'data-page="{page}"' in music_nav, f"music nav lost '{page}'"
# The music subtitle is still the default in markup (JS swaps it at runtime).
assert "Music Sync & Manager" in _INDEX
def test_controller_is_isolated_iife_with_no_globals():
stripped = _JS.strip()
# Wrapped in an IIFE → declares no module-level globals that could collide
# with or shadow music functions.
assert stripped.startswith("/*") or stripped.startswith("(function")
assert "(function" in _JS and "})();" in _JS
# No window.X assignments (the cross-file leak pattern) and no inline-handler
# dependence — everything is addEventListener.
assert "window." not in _JS or "window.location" in _JS # tolerate none; none expected
assert "addEventListener" in _JS

View file

@ -13,6 +13,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='basic-search-v2.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='video/video-side.css', v=static_v) }}">
{{ vite_assets('head')|safe }}
</head>
@ -219,6 +220,11 @@
<p class="app-subtitle">Music Sync & Manager</p>
</div>
</div>
<!-- Music ↔ Video side toggle (shell-only; wired by video/video-side.js) -->
<div class="side-toggle" role="group" aria-label="Switch between Music and Video">
<button type="button" class="side-toggle-btn active" data-side-target="music">Music</button>
<button type="button" class="side-toggle-btn" data-side-target="video">Video</button>
</div>
<div id="profile-indicator" class="profile-indicator" style="display: none;" title="Switch profile">
<div id="profile-indicator-avatar" class="profile-indicator-avatar"></div>
<span id="profile-indicator-name" class="profile-indicator-name"></span>
@ -310,6 +316,46 @@
</a>
</nav>
<!-- Video Navigation (shell-only; shown when body[data-side="video"], wired by video/video-side.js) -->
<nav class="sidebar-nav video-nav" aria-label="Video navigation">
<a class="nav-button" data-video-page="video-dashboard" href="#">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="4" rx="1"/><rect x="3" y="14" width="7" height="4" rx="1"/><rect x="14" y="11" width="7" height="7" rx="1"/></svg></span>
<span class="nav-text">Dashboard</span>
</a>
<a class="nav-button" data-video-page="video-search" href="#">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<span class="nav-text">Search</span>
</a>
<a class="nav-button" data-video-page="video-discover" href="#">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" fill="currentColor" opacity="0.2" stroke="currentColor"/></svg></span>
<span class="nav-text">Discover</span>
</a>
<a class="nav-button" data-video-page="video-library" href="#">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></span>
<span class="nav-text">Library</span>
</a>
<a class="nav-button" data-video-page="video-calendar" href="#">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
<span class="nav-text">Calendar</span>
</a>
<a class="nav-button" data-video-page="video-import" href="#">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/><polyline points="7 9 12 4 17 9"/><line x1="12" y1="4" x2="12" y2="16"/></svg></span>
<span class="nav-text">Import</span>
</a>
<a class="nav-button" data-video-page="video-settings" href="#">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="nav-text">Settings</span>
</a>
<a class="nav-button" data-video-page="video-issues" href="#">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></span>
<span class="nav-text">Issues</span>
</a>
<a class="nav-button" data-video-page="video-help" href="#">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
<span class="nav-text">Help & Docs</span>
</a>
</nav>
<!-- Spacer -->
<div class="sidebar-spacer"></div>
@ -350,6 +396,9 @@
<canvas id="page-particles-canvas"></canvas>
<div id="webui-react-root" class="page" data-react-app="router"></div>
<!-- Video side content host (shell-only; filled by video/video-side.js) -->
<div class="page video-page" id="video-page-host"></div>
<!-- Dashboard Page -->
<div class="page" id="dashboard-page">
<div class="page-shell dashboard-container">
@ -8473,6 +8522,8 @@
<script src="{{ url_for('static', filename='helper.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='particles.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='worker-orbs.js', v=static_v) }}"></script>
<!-- Video side shell controller (isolated; music never references it) -->
<script src="{{ url_for('static', filename='video/video-side.js', v=static_v) }}"></script>
</body>
</html>

View file

@ -0,0 +1,86 @@
/*
* SoulSync Video side shell styling.
*
* Loaded AFTER style.css so these rules win where specificity ties. Everything
* keys off body[data-side="music|video"] (set by video-side.js). The music side
* is the default and is never affected these rules only do something once the
* body flips to data-side="video".
*/
/* ── The Music ↔ Video header toggle ───────────────────────────────────── */
.side-toggle {
display: flex;
gap: 3px;
margin-top: 12px;
padding: 3px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 9px;
}
.side-toggle-btn {
flex: 1;
padding: 6px 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--text-secondary, #9aa0aa);
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.side-toggle-btn:hover {
color: var(--text-primary, #e8e8ea);
background: rgba(255, 255, 255, 0.04);
}
.side-toggle-btn.active {
background: rgb(var(--accent-rgb, 88 101 242));
color: #fff;
}
/* ── Nav swap: music nav vs video nav ──────────────────────────────────── */
/* The video nav reuses .sidebar-nav layout (same class) only visibility
differs. Hidden by default (music side); shown when data-side="video". */
.video-nav {
display: none;
}
body[data-side="video"] nav.sidebar-nav:not(.video-nav) {
display: none;
}
body[data-side="video"] .video-nav {
display: flex;
}
/* ── Page visibility per side ──────────────────────────────────────────── */
/* Music pages hidden on the video side; video pages hidden on the music side.
The music router still controls .page.active among music pages; we just hide
the whole set while the video side is showing. */
body[data-side="music"] .page.video-page {
display: none !important;
}
body[data-side="video"] .page:not(.video-page) {
display: none !important;
}
body[data-side="video"] .page.video-page {
display: block;
}
/* ── Service status: not needed on the video side (for now) ─────────────── */
body[data-side="video"] .status-section {
display: none;
}
/* ── Placeholder content (until the real video pages land) ─────────────── */
.video-placeholder {
padding: 48px 40px;
}
.video-placeholder .header-title {
font-size: 22px;
font-weight: 700;
}
.video-placeholder-note {
margin-top: 10px;
color: var(--text-secondary, #9aa0aa);
font-size: 14px;
}

View file

@ -0,0 +1,136 @@
/*
* SoulSync Video side shell controller.
*
* ISOLATION CONTRACT: the music side never imports or references anything here.
* This file is a self-contained IIFE (no globals) wired entirely via
* addEventListener (no inline onclick), so it cannot affect the music side and
* a merge can't touch it. It only drives shared SHELL behaviour:
* - the Music Video header toggle (+ remembers the side in localStorage)
* - showing/hiding the video sidebar nav vs the music nav (CSS does the work
* off body[data-side]; this just flips the attribute)
* - a placeholder content host for the video pages (real pages land later)
*
* The actual video domain (data model, services, pages, DB) lives elsewhere and
* is built on top of this shell.
*/
(function () {
'use strict';
var SIDE_KEY = 'soulsync_side';
var MUSIC_SUBTITLE = 'Music Sync & Manager';
var VIDEO_SUBTITLE = 'Video Sync & Manager';
var DEFAULT_VIDEO_PAGE = 'video-dashboard';
// The video sidebar pages. Pages flagged shared: true are "same as music"
// (Import / Issues / Help) — wired to reuse the music pages in a later step;
// for now every page renders the placeholder.
var VIDEO_PAGES = [
{ id: 'video-dashboard', label: 'Dashboard' },
{ id: 'video-search', label: 'Search' },
{ id: 'video-discover', label: 'Discover' },
{ id: 'video-library', label: 'Library' },
{ id: 'video-calendar', label: 'Calendar' },
{ id: 'video-import', label: 'Import', shared: true },
{ id: 'video-settings', label: 'Settings' },
{ id: 'video-issues', label: 'Issues', shared: true },
{ id: 'video-help', label: 'Help & Docs', shared: true },
];
function readSide() {
try {
return localStorage.getItem(SIDE_KEY) === 'video' ? 'video' : 'music';
} catch (e) {
return 'music';
}
}
function persistSide(side) {
try { localStorage.setItem(SIDE_KEY, side); } catch (e) { /* ignore */ }
}
function pageMeta(pageId) {
for (var i = 0; i < VIDEO_PAGES.length; i++) {
if (VIDEO_PAGES[i].id === pageId) return VIDEO_PAGES[i];
}
return VIDEO_PAGES[0];
}
function showVideoPage(pageId) {
var meta = pageMeta(pageId);
var navButtons = document.querySelectorAll('.video-nav .nav-button[data-video-page]');
for (var i = 0; i < navButtons.length; i++) {
navButtons[i].classList.toggle(
'active', navButtons[i].getAttribute('data-video-page') === meta.id);
}
var host = document.getElementById('video-page-host');
if (!host) return;
// Built from our own static constants only — no user input.
var shell = document.createElement('div');
shell.className = 'page-shell video-placeholder';
var h2 = document.createElement('h2');
h2.className = 'header-title';
var span = document.createElement('span');
span.textContent = 'Video · ' + meta.label;
h2.appendChild(span);
var note = document.createElement('p');
note.className = 'video-placeholder-note';
note.textContent = 'The ' + meta.label + ' page for the video side is coming soon.';
host.textContent = '';
host.appendChild(h2);
host.appendChild(note);
}
function applySide(side) {
document.body.setAttribute('data-side', side);
var subtitle = document.querySelector('.sidebar-header .app-subtitle');
if (subtitle) subtitle.textContent = side === 'video' ? VIDEO_SUBTITLE : MUSIC_SUBTITLE;
var toggleButtons = document.querySelectorAll('.side-toggle-btn');
for (var i = 0; i < toggleButtons.length; i++) {
toggleButtons[i].classList.toggle(
'active', toggleButtons[i].getAttribute('data-side-target') === side);
}
if (side === 'video') {
var active = document.querySelector('.video-nav .nav-button.active');
showVideoPage(active ? active.getAttribute('data-video-page') : DEFAULT_VIDEO_PAGE);
}
}
function switchSide(side) {
if (side !== 'music' && side !== 'video') return;
persistSide(side);
applySide(side);
}
function init() {
var toggleButtons = document.querySelectorAll('.side-toggle-btn');
for (var i = 0; i < toggleButtons.length; i++) {
(function (btn) {
btn.addEventListener('click', function () {
switchSide(btn.getAttribute('data-side-target'));
});
})(toggleButtons[i]);
}
var navButtons = document.querySelectorAll('.video-nav .nav-button[data-video-page]');
for (var j = 0; j < navButtons.length; j++) {
(function (btn) {
btn.addEventListener('click', function (e) {
e.preventDefault();
showVideoPage(btn.getAttribute('data-video-page'));
});
})(navButtons[j]);
}
var defaultNav = document.querySelector(
'.video-nav .nav-button[data-video-page="' + DEFAULT_VIDEO_PAGE + '"]');
if (defaultNav) defaultNav.classList.add('active');
applySide(readSide());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();