diff --git a/dashcaddy-api/src/docker/app-templates.js b/dashcaddy-api/src/docker/app-templates.js
index fdf57ae..48f8002 100644
--- a/dashcaddy-api/src/docker/app-templates.js
+++ b/dashcaddy-api/src/docker/app-templates.js
@@ -1764,6 +1764,47 @@ const APP_TEMPLATES = {
]
},
+ "vintage-radio": {
+ name: "Vintage Stereo",
+ description: "Glass-front console stereo that tunes curated real internet stations (SomaFM, KEXP, Radio Paradise, and more) through a beautiful analog UI",
+ icon: "📻",
+ category: "Media",
+ popularity: 72,
+ difficulty: "Easy",
+ docker: {
+ image: "nginx:alpine",
+ ports: ["{{PORT}}:80"],
+ volumes: [
+ "/opt/vintage-radio/web:/usr/share/nginx/html:ro"
+ ],
+ environment: {}
+ },
+ subdomain: "radio",
+ defaultPort: 8090,
+ healthCheck: "/",
+ subpathSupport: 'none',
+ preInstall: {
+ description: "Materialize the bundled static assets into /opt/vintage-radio/web before starting the container.",
+ script: "vintage-radio-install.sh"
+ },
+ features: [
+ "Glass-front console stereo UI with wooden end caps and brushed-metal faceplate",
+ "Tunable analog slide-rule dial with click-stop detents and red cursor flag",
+ "Twin glowing VU meters with smooth needle animation while powered",
+ "Power / Mode / Mute knobs, vertical volume slider, signal-strength LED",
+ "MODE knob filters stations by genre (ALL / AMBIENT / ROCK / MIXED)",
+ "18 curated real internet-radio streams (SomaFM, KEXP, Radio Paradise, Space Station Soma, Mission Control, and more)"
+ ],
+ setupInstructions: [
+ "Run `bash /usr/local/bin/vintage-radio-install.sh` once before starting the container — copies the bundled web assets (index.html, radio.css, radio.js, stations.json) from the DashCaddy repo (dashcaddy-api/static-sites/vintage-radio/web) into /opt/vintage-radio/web",
+ "Open radio.sami (or your configured subdomain)",
+ "Press the PWR knob, drag the dial or click a station card",
+ "Cycle the MODE knob to filter by genre (ALL / AMBIENT / ROCK / MIXED)",
+ "To add stations, edit /opt/vintage-radio/web/stations.json on the host and restart the container"
+ ],
+ tags: ["radio", "music", "streaming", "audio", "vintage", "retro", "media"]
+ },
+
"airsonic": {
name: "Airsonic Advanced",
description: "Free web-based media streamer",
diff --git a/dashcaddy-api/static-sites/vintage-radio/install-installer.sh b/dashcaddy-api/static-sites/vintage-radio/install-installer.sh
new file mode 100755
index 0000000..540cd14
--- /dev/null
+++ b/dashcaddy-api/static-sites/vintage-radio/install-installer.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# install-installer.sh — Installs vintage-radio-install.sh into /usr/local/bin.
+#
+# Run this once on a host to make `bash /usr/local/bin/vintage-radio-install.sh`
+# available as a system command. Idempotent.
+
+set -euo pipefail
+
+SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SRC="${SELF_DIR}/install.sh"
+DEST="/usr/local/bin/vintage-radio-install.sh"
+
+if [[ ! -f "$SRC" ]]; then
+ echo "FATAL: $SRC not found" >&2
+ exit 1
+fi
+
+install -m 0755 "$SRC" "$DEST"
+echo "Installed: $SRC -> $DEST"
+echo "Run it with: bash $DEST"
diff --git a/dashcaddy-api/static-sites/vintage-radio/install.sh b/dashcaddy-api/static-sites/vintage-radio/install.sh
new file mode 100755
index 0000000..775c8dd
--- /dev/null
+++ b/dashcaddy-api/static-sites/vintage-radio/install.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# vintage-radio-install.sh — Materializes the Vintage Stereo bundled web assets.
+#
+# The Vintage Stereo radio template serves its UI through an nginx:alpine
+# container that mounts /opt/vintage-radio/web as /usr/share/nginx/html. This
+# script copies the assets (index.html, radio.css, radio.js, stations.json)
+# from the DashCaddy source tree into that mount target.
+#
+# Usage:
+# bash /usr/local/bin/vintage-radio-install.sh
+#
+# Environment overrides:
+# DASHCADDY_ROOT — Path to the DashCaddy install root (defaults to /opt/dashcaddy).
+# TARGET_DIR — Mount target directory (defaults to /opt/vintage-radio/web).
+#
+# Idempotent: safe to re-run; overwrites the target files each time.
+
+set -euo pipefail
+
+DASHCADDY_ROOT="${DASHCADDY_ROOT:-/opt/dashcaddy}"
+TARGET_DIR="${TARGET_DIR:-/opt/vintage-radio/web}"
+SOURCE_DIR="${DASHCADDY_ROOT}/dashcaddy-api/static-sites/vintage-radio/web"
+
+if [[ ! -d "$SOURCE_DIR" ]]; then
+ echo "FATAL: source assets not found at $SOURCE_DIR" >&2
+ echo " Install DashCaddy, or set DASHCADDY_ROOT to its location." >&2
+ exit 1
+fi
+
+if [[ ! -f "$SOURCE_DIR/index.html" || ! -f "$SOURCE_DIR/radio.css" \
+ || ! -f "$SOURCE_DIR/radio.js" || ! -f "$SOURCE_DIR/stations.json" ]]; then
+ echo "FATAL: incomplete assets in $SOURCE_DIR" >&2
+ ls -la "$SOURCE_DIR" >&2
+ exit 1
+fi
+
+mkdir -p "$TARGET_DIR"
+
+install -m 0644 "$SOURCE_DIR/index.html" "$TARGET_DIR/index.html"
+install -m 0644 "$SOURCE_DIR/radio.css" "$TARGET_DIR/radio.css"
+install -m 0644 "$SOURCE_DIR/radio.js" "$TARGET_DIR/radio.js"
+install -m 0644 "$SOURCE_DIR/stations.json" "$TARGET_DIR/stations.json"
+
+chmod 0755 "$TARGET_DIR"
+
+echo "Vintage Stereo assets installed:"
+echo " Source: $SOURCE_DIR"
+echo " Target: $TARGET_DIR"
+ls -la "$TARGET_DIR"
diff --git a/dashcaddy-api/static-sites/vintage-radio/web/index.html b/dashcaddy-api/static-sites/vintage-radio/web/index.html
new file mode 100644
index 0000000..9b5b0e3
--- /dev/null
+++ b/dashcaddy-api/static-sites/vintage-radio/web/index.html
@@ -0,0 +1,143 @@
+
+
+
+
+
+ Vintage Stereo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
--.-
+
VINTAGE STEREO
+
+
+
+
+
+
+ 889296100104
+
+
+
+
+
+
+
+
+
+ Standby
+
+ Signal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashcaddy-api/static-sites/vintage-radio/web/radio.css b/dashcaddy-api/static-sites/vintage-radio/web/radio.css
new file mode 100644
index 0000000..2b86a73
--- /dev/null
+++ b/dashcaddy-api/static-sites/vintage-radio/web/radio.css
@@ -0,0 +1,682 @@
+/* Vintage Stereo — glass-front console stereo styling */
+
+:root {
+ --wood-light: #c89466;
+ --wood-mid: #8a5326;
+ --wood-dark: #3e2110;
+ --wood-cap: #2a160a;
+ --brushed: #d4cfc2;
+ --brushed-dk: #807a6e;
+ --face: #b8b2a3;
+ --face-dk: #615d54;
+ --led-off: #341a10;
+ --led-on: #ff5733;
+ --dial-glow: #ffa84a;
+ --vu-glow: #f1c40f;
+ --knob-cap: #1d1814;
+ --ink: #14110a;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ min-height: 100%;
+ background:
+ radial-gradient(ellipse at center, #1f140a 0%, #0a0604 80%);
+ color: var(--ink);
+ font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ overflow: hidden;
+}
+
+.room {
+ display: grid;
+ grid-template-columns: minmax(640px, 1fr) 340px;
+ gap: 24px;
+ padding: 28px;
+ align-items: stretch;
+ min-height: 100vh;
+}
+
+@media (max-width: 1000px) {
+ .room {
+ grid-template-columns: 1fr;
+ overflow-y: auto;
+ height: auto;
+ min-height: 100vh;
+ }
+ .room > .console { justify-self: center; }
+ .room > .panel { min-height: 60vh; }
+}
+
+/* Very narrow phones: zoom the console down to fit the viewport.
+ Note: `zoom` is supported in Chrome/Edge/Safari and Firefox 126+. Older Firefox
+ falls back to the unzoomed layout (with mild horizontal overflow). */
+@media (max-width: 760px) {
+ html, body { overflow: auto; }
+ .room { padding: 12px; }
+ .room > .console { zoom: 0.92; }
+}
+
+@media (max-width: 600px) {
+ .room > .console { zoom: 0.78; }
+}
+
+@media (max-width: 480px) {
+ .room > .console { zoom: 0.62; }
+}
+
+/* ====== Console ====== */
+
+.console {
+ position: relative;
+ background:
+ repeating-linear-gradient(90deg,
+ rgba(255,255,255,0.05) 0 2px,
+ transparent 2px 5px),
+ linear-gradient(180deg, var(--wood-light) 0%, var(--wood-mid) 50%, var(--wood-dark) 100%);
+ border-radius: 24px;
+ padding: 0;
+ box-shadow:
+ inset 0 1px 0 rgba(255,255,255,0.25),
+ inset 0 -30px 80px rgba(0,0,0,0.55),
+ 0 30px 80px rgba(0,0,0,0.6),
+ 0 0 0 8px var(--wood-cap);
+ display: grid;
+ grid-template-columns: 130px 1fr 200px;
+ grid-template-rows: 360px 1fr;
+ grid-template-areas:
+ "left face right"
+ "grille grille grille";
+ min-height: 720px;
+ overflow: hidden;
+}
+
+.console::before {
+ content: "";
+ position: absolute;
+ inset: 6px;
+ border-radius: 20px;
+ border: 2px solid rgba(0,0,0,0.35);
+ pointer-events: none;
+ z-index: 6;
+}
+
+/* ====== End caps (left & right wooden panels with knobs) ====== */
+
+.endcap {
+ background: linear-gradient(180deg, var(--wood-mid) 0%, var(--wood-dark) 100%);
+ padding: 22px 12px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ box-shadow: inset 8px 0 18px rgba(0,0,0,0.45);
+ position: relative;
+}
+
+.endcap-left { grid-area: left; border-right: 2px solid rgba(0,0,0,0.4); }
+.endcap-right { grid-area: right; border-left: 2px solid rgba(0,0,0,0.4); box-shadow: inset -8px 0 18px rgba(0,0,0,0.45); }
+
+/* ====== Knobs ====== */
+
+.knob {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ font-family: inherit;
+ color: #f4ead0;
+ font-size: 9px;
+ letter-spacing: 2px;
+}
+
+.knob-face {
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background:
+ radial-gradient(circle at 30% 25%, #f0e8d4 0%, #8a7e5e 60%, #1c1610 100%);
+ border: 2px solid #0a0805;
+ box-shadow:
+ 0 3px 6px rgba(0,0,0,0.5),
+ inset 0 -1px 2px rgba(255,255,255,0.18),
+ inset 0 2px 4px rgba(255,255,255,0.15);
+ position: relative;
+ transition: transform 0.05s;
+}
+
+.knob:active .knob-face { transform: translateY(1px); }
+
+.knob-indicator {
+ position: absolute;
+ top: 6px;
+ left: 50%;
+ width: 3px;
+ height: 14px;
+ background: var(--led-on);
+ border-radius: 1px;
+ transform: translateX(-50%);
+ box-shadow: 0 0 4px var(--led-on);
+}
+
+.knob-power[aria-pressed="true"] .knob-indicator {
+ box-shadow: 0 0 10px var(--led-on), 0 0 16px rgba(255,87,51,0.4);
+}
+
+.knob-label {
+ font-weight: bold;
+ color: var(--brushed);
+ text-shadow: 0 1px 0 rgba(0,0,0,0.5);
+}
+
+.knob-mode-name {
+ font-size: 8px;
+ letter-spacing: 1.5px;
+ color: var(--dial-glow);
+ background: #1a0d05;
+ padding: 2px 6px;
+ border-radius: 3px;
+ border: 1px solid #0a0805;
+ margin-top: -2px;
+ text-shadow: 0 0 3px var(--dial-glow);
+}
+
+/* ====== Glass face ====== */
+
+.glass-face {
+ grid-area: face;
+ position: relative;
+ background:
+ linear-gradient(180deg, #c4beae 0%, #a39c8b 50%, #7a7363 100%);
+ padding: 28px 32px 22px;
+ display: grid;
+ grid-template-rows: auto 1fr auto auto;
+ gap: 16px;
+ overflow: hidden;
+}
+
+/* The smoked-glass overlay that sits ON TOP of all face contents */
+.glass-overlay {
+ position: absolute;
+ inset: 0;
+ background:
+ linear-gradient(180deg, rgba(20, 14, 6, 0.18) 0%, rgba(20, 14, 6, 0.35) 100%),
+ repeating-linear-gradient(135deg,
+ rgba(255,255,255,0.04) 0 1px,
+ transparent 1px 4px);
+ box-shadow:
+ inset 0 1px 0 rgba(255,255,255,0.45),
+ inset 0 0 30px rgba(0,0,0,0.35);
+ border-left: 2px solid rgba(0,0,0,0.4);
+ border-right: 2px solid rgba(0,0,0,0.4);
+ pointer-events: none;
+ z-index: 4;
+}
+
+.glass-face > *:not(.glass-overlay) { position: relative; z-index: 2; }
+
+/* Faint streaks like a polished-glass reflection */
+.glass-face::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background:
+ linear-gradient(120deg,
+ transparent 30%,
+ rgba(255,255,255,0.18) 38%,
+ transparent 46%,
+ rgba(255,255,255,0.08) 60%,
+ transparent 70%);
+ pointer-events: none;
+ z-index: 5;
+ mix-blend-mode: screen;
+}
+
+/* ====== Dial window: backlit section behind glass ====== */
+
+.dial-window {
+ background:
+ linear-gradient(180deg, #1a0d05 0%, #2b1608 100%);
+ padding: 16px 24px;
+ border-radius: 8px;
+ border: 2px solid #0a0805;
+ text-align: center;
+ box-shadow:
+ inset 0 2px 6px rgba(0,0,0,0.7),
+ 0 0 12px rgba(0,0,0,0.4);
+}
+
+.dial-frequency {
+ font-family: 'Courier New', monospace;
+ font-size: 56px;
+ font-weight: bold;
+ color: var(--dial-glow);
+ letter-spacing: 4px;
+ line-height: 1;
+ text-shadow:
+ 0 0 8px var(--dial-glow),
+ 0 0 18px rgba(255,168,74,0.4);
+ font-variant-numeric: tabular-nums;
+}
+
+.console[data-power="off"] .dial-frequency { color: #4a2b14; text-shadow: none; }
+
+.dial-station {
+ margin-top: 8px;
+ font-size: 16px;
+ letter-spacing: 5px;
+ color: #f6e6c8;
+ text-shadow: 0 0 6px rgba(255,176,102,0.4);
+}
+
+.console[data-power="off"] .dial-station { color: #4a2b14; text-shadow: none; }
+
+/* ====== Tuning rail ====== */
+
+.dial-rail-wrap {
+ position: relative;
+}
+
+.dial-rail {
+ position: relative;
+ width: 100%;
+ height: 72px;
+ background:
+ linear-gradient(180deg, #161109 0%, #2a1c0b 100%);
+ border-radius: 6px;
+ border: 2px solid #0a0805;
+ cursor: ew-resize;
+ touch-action: none;
+ user-select: none;
+ padding: 0;
+ overflow: visible;
+}
+
+.dial-ticks {
+ position: absolute;
+ inset: 0;
+ background:
+ repeating-linear-gradient(90deg,
+ rgba(255,168,74,0.25) 0 1px,
+ transparent 1px 2px,
+ rgba(255,168,74,0.5) 8px 9px,
+ rgba(255,168,74,0.15) 9px 14px);
+}
+
+.dial-stop {
+ position: absolute;
+ top: 4px;
+ bottom: 4px;
+ width: 3px;
+ background: var(--dial-glow);
+ border-radius: 2px;
+ box-shadow: 0 0 4px var(--dial-glow);
+ pointer-events: none;
+ opacity: 0.7;
+}
+
+.dial-cursor {
+ position: absolute;
+ top: -6px;
+ bottom: -6px;
+ left: 50%;
+ width: 0;
+ pointer-events: none;
+ transition: left 0.18s ease-out;
+}
+
+.cursor-line {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -1px;
+ width: 2px;
+ background: var(--led-on);
+ box-shadow: 0 0 6px var(--led-on), 0 0 12px rgba(255,87,51,0.5);
+}
+
+.cursor-flag {
+ position: absolute;
+ top: -10px;
+ left: -7px;
+ width: 0;
+ height: 0;
+ border-left: 7px solid transparent;
+ border-right: 7px solid transparent;
+ border-bottom: 8px solid var(--led-on);
+ filter: drop-shadow(0 0 4px var(--led-on));
+}
+
+.dial-scale {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 4px;
+ font-family: 'Courier New', monospace;
+ font-size: 10px;
+ color: var(--face-dk);
+ letter-spacing: 1px;
+ padding: 0 6px;
+}
+
+/* ====== Twin VU meters ====== */
+
+.vu-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 18px;
+ padding: 0 6px;
+}
+
+.vu-meter {
+ position: relative;
+ height: 80px;
+ background:
+ linear-gradient(180deg, #f7f0d8 0%, #d8cfb5 100%);
+ border-radius: 6px;
+ border: 2px solid #0a0805;
+ overflow: hidden;
+ box-shadow: inset 0 2px 4px rgba(0,0,0,0.25);
+}
+
+.vu-needle {
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ width: 1.5px;
+ height: 100%;
+ background: #c0392b;
+ transform-origin: bottom center;
+ transition: transform 0.12s ease-out;
+}
+
+.vu-falloff {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(90deg, transparent 49%, rgba(0,0,0,0.15) 50%, transparent 51%);
+ pointer-events: none;
+}
+
+.vu-label {
+ position: absolute;
+ top: 4px;
+ left: 6px;
+ font-size: 11px;
+ font-weight: bold;
+ color: #c0392b;
+}
+
+.vu-bg-marks {
+ position: absolute;
+ bottom: 4px;
+ left: 4px;
+ right: 4px;
+ display: flex;
+ justify-content: space-around;
+}
+
+.vu-bg-marks span {
+ width: 1px;
+ height: 6px;
+ background: rgba(60, 40, 25, 0.6);
+ display: block;
+}
+
+.vu-bg-marks span.red { background: #c0392b; }
+
+/* ====== Status row under glass ====== */
+
+.status-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 8px;
+ font-size: 11px;
+ letter-spacing: 2px;
+ color: var(--face-dk);
+ font-family: 'Courier New', monospace;
+}
+
+.led {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--led-off);
+ box-shadow: inset 0 1px 1px rgba(255,255,255,0.2);
+ transition: background 0.2s, box-shadow 0.2s;
+}
+
+.console[data-power="on"] .led { background: var(--led-on); box-shadow: 0 0 8px var(--led-on), inset 0 1px 1px rgba(255,255,255,0.3); }
+
+.led-signal { background: #2a1608; }
+
+.console[data-power="on"][data-streaming="true"] .led-signal {
+ background: #2ecc71;
+ box-shadow: 0 0 6px #2ecc71, inset 0 1px 1px rgba(255,255,255,0.3);
+ animation: signal-pulse 1.6s infinite ease-in-out;
+}
+
+@keyframes signal-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.55; }
+}
+
+.status-text { font-weight: bold; text-transform: uppercase; }
+
+/* ====== Right end cap: volume + presets ====== */
+
+.volume-block, .preset-block {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+}
+
+.block-label {
+ font-size: 9px;
+ letter-spacing: 3px;
+ color: var(--brushed);
+ font-weight: bold;
+}
+
+.volume-slider {
+ writing-mode: vertical-lr;
+ direction: rtl;
+ width: 28px;
+ height: 100px;
+ accent-color: var(--led-on);
+ cursor: pointer;
+}
+
+.volume-readout {
+ font-family: 'Courier New', monospace;
+ font-size: 18px;
+ font-weight: bold;
+ color: var(--dial-glow);
+ text-shadow: 0 0 6px var(--dial-glow);
+ background: #1a0d05;
+ padding: 4px 10px;
+ border-radius: 4px;
+ border: 1px solid #0a0805;
+ min-width: 48px;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+
+.preset-buttons { display: flex; gap: 6px; }
+
+.preset {
+ background: var(--brushed);
+ border: 2px solid var(--brushed-dk);
+ border-radius: 4px;
+ padding: 8px 12px;
+ cursor: pointer;
+ font-family: inherit;
+ color: var(--ink);
+ font-size: 12px;
+ font-weight: bold;
+ letter-spacing: 1px;
+ box-shadow: inset 0 -2px 3px rgba(0,0,0,0.25), 0 2px 3px rgba(0,0,0,0.4);
+}
+
+.preset:active { transform: translateY(1px); box-shadow: inset 0 2px 3px rgba(0,0,0,0.25), 0 0 0 transparent; }
+
+.preset:disabled { opacity: 0.3; cursor: not-allowed; }
+
+.preset-label {
+ font-family: 'Courier New', monospace;
+ font-size: 11px;
+ color: var(--dial-glow);
+ text-shadow: 0 0 4px var(--dial-glow);
+ background: #1a0d05;
+ padding: 3px 8px;
+ border-radius: 3px;
+ border: 1px solid #0a0805;
+}
+
+/* ====== Speaker grille (spans full bottom) ====== */
+
+.grille {
+ grid-area: grille;
+ background:
+ repeating-linear-gradient(90deg,
+ rgba(0,0,0,0.85) 0 2px,
+ rgba(255,255,255,0.04) 2px 6px);
+ border-top: 4px solid rgba(0,0,0,0.5);
+ box-shadow: inset 0 4px 12px rgba(0,0,0,0.6);
+ position: relative;
+ min-height: 120px;
+}
+
+.grille-fabric {
+ position: absolute;
+ inset: 12px;
+ background:
+ repeating-linear-gradient(90deg,
+ rgba(0,0,0,0.4) 0 3px,
+ rgba(120, 80, 40, 0.2) 3px 6px),
+ radial-gradient(ellipse at center, rgba(0,0,0,0.4) 0%, transparent 70%);
+ border-radius: 4px;
+}
+
+/* ====== Side panel ====== */
+
+.panel {
+ background: linear-gradient(180deg, #1a120a 0%, #0d0805 100%);
+ color: #d4c9a8;
+ border-radius: 22px;
+ padding: 22px;
+ border: 2px solid var(--wood-dark);
+ box-shadow:
+ inset 0 0 30px rgba(0,0,0,0.6),
+ 0 12px 30px rgba(0,0,0,0.4);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.panel-head h1 {
+ margin: 0;
+ font-size: 16px;
+ letter-spacing: 4px;
+ color: var(--dial-glow);
+ text-shadow: 0 0 8px var(--dial-glow);
+}
+
+.panel-sub {
+ margin: 4px 0 18px;
+ font-size: 11px;
+ letter-spacing: 1.5px;
+ opacity: 0.6;
+}
+
+.station-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ flex: 1;
+ overflow-y: auto;
+}
+
+.station-list li {
+ padding: 10px 12px;
+ margin-bottom: 4px;
+ border-radius: 6px;
+ cursor: pointer;
+ display: grid;
+ grid-template-columns: 56px 1fr;
+ gap: 12px;
+ align-items: center;
+ border: 1px solid transparent;
+ transition: background 0.15s, border-color 0.15s, transform 0.05s;
+}
+
+.station-list li:hover { background: rgba(255,176,102,0.08); border-color: rgba(255,176,102,0.3); }
+
+.station-list li[aria-selected="true"] {
+ background: rgba(255,176,102,0.15);
+ border-color: var(--dial-glow);
+}
+
+.station-list li:active { transform: translateX(2px); }
+
+.station-freq {
+ font-size: 16px;
+ font-weight: bold;
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ color: var(--dial-glow);
+ font-family: 'Courier New', monospace;
+}
+
+.station-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+
+.station-name {
+ font-size: 14px;
+ color: #f4ead0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.station-genre {
+ font-size: 10px;
+ letter-spacing: 1px;
+ opacity: 0.6;
+ text-transform: uppercase;
+}
+
+.panel-foot {
+ margin-top: 16px;
+ padding-top: 12px;
+ border-top: 1px solid rgba(255,176,102,0.2);
+ font-size: 11px;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ letter-spacing: 1px;
+}
+
+.panel-foot .sep { opacity: 0.4; }
+
+#streamInfo.live::before {
+ content: "\25CF";
+ color: var(--led-on);
+ margin-right: 4px;
+ animation: blink 1.2s infinite;
+}
+
+@keyframes blink {
+ 0%, 60%, 100% { opacity: 1; }
+ 30% { opacity: 0.2; }
+}
+
+.station-list::-webkit-scrollbar { width: 6px; }
+.station-list::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); }
+.station-list::-webkit-scrollbar-thumb { background: var(--wood-mid); border-radius: 3px; }
diff --git a/dashcaddy-api/static-sites/vintage-radio/web/radio.js b/dashcaddy-api/static-sites/vintage-radio/web/radio.js
new file mode 100644
index 0000000..74c2cbe
--- /dev/null
+++ b/dashcaddy-api/static-sites/vintage-radio/web/radio.js
@@ -0,0 +1,474 @@
+// Vintage Stereo — tuner logic for the glass-front console stereo
+// Loads stations from /stations.json, manages playback through an