:root {
--bg-main: #0e0e10;
--bg-sidebar: #18181b;
--bg-card: #1f1f23;
--accent: #9146FF;
--accent-hover: #772ce8;
--text: #efeff1;
--text-secondary: #adadb8;
--success: #00c853;
--error: #ff4444;
--warning: #ffab00;
/* Soft border that adapts to theme — used by post-4.5.x UI additions
(filter input, sort select, bulk bar) so they don't visually break
in light theme. */
--border-soft: rgba(255, 255, 255, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-main);
color: var(--text);
height: 100vh;
overflow: hidden;
}
.app {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 300px;
background: var(--bg-sidebar);
display: flex;
flex-direction: column;
border-right: 1px solid rgba(255,255,255,0.1);
overflow-y: auto;
overflow-x: hidden;
}
.logo {
padding: 20px;
font-size: 18px;
font-weight: bold;
color: var(--accent);
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.logo svg {
width: 28px;
height: 28px;
fill: currentColor;
}
.nav {
padding: 15px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 15px;
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
margin-bottom: 4px;
font-size: 14px;
}
.nav-item:hover {
background: rgba(145, 71, 255, 0.15);
color: var(--text);
}
.nav-item.active {
background: var(--accent);
color: white;
}
.nav-item:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
}
.nav-item svg {
width: 20px;
height: 20px;
}
.section-title {
font-size: 11px;
text-transform: uppercase;
color: var(--text-secondary);
padding: 15px 15px 8px;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
justify-content: space-between;
}
/* Inner label group inside .section-title — keeps the title text and
the streamer-section-counter aligned on the text baseline as a single
unit, separate from the bulk-remove X button that pins to the right. */
.section-title-label {
display: flex;
align-items: baseline;
gap: 8px;
}
/* Compact spacing variant — applied when the sidebar's streamer-list
filter input is visible directly below the title, so the default
padding-bottom + the filter's own margin don't double up into a
visually loose gap. */
.section-title.compact {
margin-bottom: 4px;
}
.streamers {
flex: 1;
overflow-y: auto;
padding: 0 10px;
}
.streamer-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
font-size: 14px;
}
.streamer-item:hover {
background: rgba(255,255,255,0.05);
color: var(--text);
}
.streamer-item:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px rgba(145, 70, 255, 0.55);
}
.streamer-item.active {
background: linear-gradient(90deg, rgba(145, 71, 255, 0.28) 0%, rgba(145, 71, 255, 0.08) 100%);
color: var(--text);
border-left: 3px solid var(--accent);
box-shadow: inset 0 0 0 1px rgba(145, 71, 255, 0.18);
position: relative;
}
.streamer-item.active::after {
content: '';
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background: var(--accent);
border-radius: 2px 0 0 2px;
opacity: 0.85;
}
.streamer-item.active .streamer-name {
font-weight: 600;
}
/* VOD filter row — sits above the grid: filter input, clear, sort, count, hide-toggle.
Previously every property was inline-styled on the
in index.html. */
.vod-filter-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.vod-filter-row .vod-sort-label {
margin-left: 8px;
}
.vod-filter-row .vod-filter-count {
min-width: 80px;
}
/* ============================================
VOD BULK-ACTION BAR — slides in when 1+ VOD is selected
============================================
Lives between the filter row and the VOD grid. Used to be all
inline-styled in HTML; extracted to a class so the slide-in
animation has somewhere to live and the styling stays consistent
with the rest of the action surfaces. */
.vod-bulk-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: linear-gradient(135deg, rgba(145, 70, 255, 0.15) 0%, rgba(145, 70, 255, 0.08) 100%);
border: 1px solid rgba(145, 70, 255, 0.42);
border-radius: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
box-shadow: 0 4px 16px rgba(145, 70, 255, 0.10);
/* Animation fires whenever the bar transitions from display:none
(.is-hidden present) back to display:flex (.is-hidden removed),
because Animation events restart on each display change. */
animation: vod-bulk-bar-slide 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes vod-bulk-bar-slide {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.vod-bulk-count {
color: var(--text);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.2px;
}
.vod-bulk-spacer {
flex: 1;
}
/* ============================================
CLIP-CUTTER MODAL — themed to match the rest of the app
============================================
This modal had a stack of hard-coded #2b2b2b / #E5A00D / #888 /
#333 colors from before the Twitch-purple theme was a thing.
Extracted to classes and re-themed using CSS vars + accent. */
.clip-modal {
max-width: 500px;
background: var(--bg-card);
}
.clip-modal-title {
text-align: center;
margin-bottom: 20px;
color: var(--text);
}
.clip-modal-field {
margin-bottom: 15px;
}
.clip-modal-label {
display: block;
margin-bottom: 6px;
color: var(--text);
font-size: 13px;
font-weight: 500;
}
.clip-modal-meta {
color: var(--text-secondary);
font-size: 12px;
}
.clip-modal-time-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.clip-modal-time-input {
width: 100px;
background: var(--bg-elevated);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 6px 10px;
color: var(--text);
font-family: 'Consolas', 'Segoe UI Mono', monospace;
text-align: center;
}
.clip-modal-part-input {
width: 100px;
background: var(--bg-elevated);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 8px 12px;
color: var(--text);
}
.clip-modal-template-input {
width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 8px 12px;
color: var(--text);
font-family: 'Consolas', 'Segoe UI Mono', monospace;
}
.clip-modal-duration {
text-align: center;
margin-bottom: 20px;
padding: 10px;
background: rgba(0, 200, 83, 0.06);
border: 1px solid rgba(0, 200, 83, 0.18);
border-radius: 6px;
}
.clip-modal-duration-value {
color: var(--success);
font-weight: 600;
font-family: 'Consolas', 'Segoe UI Mono', monospace;
font-size: 14px;
}
/* updateClipDuration flips this class when end <= start so the value
reads as a clear "Ungueltig!" / error message in red. */
.clip-modal-duration-value.invalid {
color: var(--error);
}
.clip-modal-hint {
color: var(--text-secondary);
font-size: 12px;
margin-top: 6px;
line-height: 1.4;
}
.clip-template-wrap {
display: none;
margin-top: 10px;
}
.clip-template-wrap.shown {
display: block;
}
/* Template-Guide button below the clip-template input — small offset
from the lint badge that sits directly above it. Was a one-off
inline style on the button. */
.clip-template-wrap .btn-secondary {
margin-top: 8px;
}
.clip-radio-row {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
margin-bottom: 8px;
padding: 6px 8px;
border-radius: 6px;
transition: background 0.15s;
}
.clip-radio-row:hover {
background: rgba(145, 70, 255, 0.06);
}
.clip-radio-label {
color: var(--text-secondary);
font-size: 13px;
flex: 1;
transition: color 0.15s;
}
.clip-radio-row:has(input[type="radio"]:checked) .clip-radio-label {
color: var(--text);
font-weight: 500;
}
.clip-modal-actions {
text-align: center;
margin-top: 10px;
}
/* ============================================
EVENTS / CHAT VIEWER MODALS — shared structure
============================================
Both viewers used to roll their own inline-styled list container
+ status row. Extracted into a small shared family so the look
stays consistent if either one is touched independently later. */
.viewer-modal {
display: flex;
flex-direction: column;
}
.viewer-modal-events {
max-width: 700px;
max-height: 80vh;
}
.viewer-modal-chat {
max-width: 800px;
height: 80vh;
}
.viewer-modal-title {
margin-top: 0;
}
.viewer-modal-status {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 8px;
}
.viewer-modal-status-inline {
margin-bottom: 0;
}
.viewer-modal-list {
flex: 1;
overflow-y: auto;
background: var(--bg-main);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 8px;
}
.viewer-modal-list-chat {
font-family: 'Consolas', 'Segoe UI Mono', monospace;
font-size: 12px;
}
.viewer-modal-filter-row {
display: flex;
margin-bottom: 8px;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.viewer-modal-filter-input {
flex: 1;
min-width: 160px;
background: var(--bg-card);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 6px 10px;
color: var(--text);
font-size: 13px;
}
/* ============================================
FILTER INPUTS — shared family for search/filter boxes
============================================
Used by the primary VOD filter above the grid, the archive search,
and (in a compact variant) the sidebar streamer-list filter that
only shows once the list crosses the threshold. */
.filter-input {
flex: 1;
min-width: 180px;
background: var(--bg-card);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 8px 12px;
color: var(--text);
font-size: 13px;
}
/* No .filter-input:hover here — it's redundant with the global
input[type="text"]:hover rule added in 4.6.142 (same effect: soft
purple border on hover). The class is always applied to
elements, so the global rule already covers them. */
.filter-input.compact {
width: calc(100% - 16px);
margin: 0 8px 8px;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
flex: none;
min-width: 0;
}
.filter-input.flex-1-1-240 {
flex: 1 1 240px;
min-width: 200px;
border-radius: 4px;
padding: 6px 10px;
}
/* Monospace input utility — for filename-template fields and similar
places where the value is expected to be a code-shaped string. */
.input-monospace {
font-family: 'Consolas', 'Segoe UI Mono', monospace;
}
.streamer-item .remove {
margin-left: auto;
opacity: 0;
color: var(--error);
cursor: pointer;
}
.streamer-item:hover .remove,
.streamer-item .remove:focus-visible {
opacity: 1;
}
.streamer-item .remove:focus-visible,
.queue-item .remove:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 70, 70, 0.5);
border-radius: 3px;
}
/* Keyboard focus rings for the AUTO / VOD / REC chip buttons.
Each picks up its own accent: AUTO + VOD use semantic green/blue
tints matching their active state, REC the red of the live dot. */
.streamer-auto:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(0, 200, 83, 0.55);
}
.streamer-vod:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.55);
}
.streamer-rec:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 68, 68, 0.55);
}
/* Live-dot — red pulsing indicator shown next to a streamer's name in
the sidebar when they are currently broadcasting on Twitch. */
.streamer-live-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #e91916;
flex-shrink: 0;
animation: streamer-live-pulse 1.6s ease-in-out infinite;
box-shadow: 0 0 0 0 rgba(233, 25, 22, 0.55);
}
@keyframes streamer-live-pulse {
0% { box-shadow: 0 0 0 0 rgba(233, 25, 22, 0.55); }
70% { box-shadow: 0 0 0 6px rgba(233, 25, 22, 0); }
100% { box-shadow: 0 0 0 0 rgba(233, 25, 22, 0); }
}
.streamer-name.is-live {
color: var(--text);
font-weight: 600;
}
/* ============================================
VOD SKELETON CARDS — shown while VODs load
============================================ */
.vod-card-skeleton {
cursor: default;
pointer-events: none;
border-color: transparent !important;
box-shadow: none !important;
transform: none !important;
}
.vod-card-skeleton .vod-skel-thumb {
width: 100%;
aspect-ratio: 16/9;
background: linear-gradient(90deg, var(--bg-elevated) 0%, rgba(255,255,255,0.04) 50%, var(--bg-elevated) 100%);
background-size: 200% 100%;
animation: skel-shimmer 1.5s linear infinite;
}
.vod-card-skeleton .vod-skel-line {
height: 14px;
background: linear-gradient(90deg, var(--bg-elevated) 0%, rgba(255,255,255,0.06) 50%, var(--bg-elevated) 100%);
background-size: 200% 100%;
border-radius: 3px;
animation: skel-shimmer 1.5s linear infinite;
}
/* Three skeleton-line variants for the VOD card placeholder — match
the visual rhythm of a real card (title line, then two shorter
meta lines). Replaces inline width/height/margin-top declarations. */
.vod-card-skeleton .vod-skel-line.title { width: 85%; }
.vod-card-skeleton .vod-skel-line.meta-1 { width: 55%; height: 10px; margin-top: 8px; }
.vod-card-skeleton .vod-skel-line.meta-2 { width: 40%; height: 10px; margin-top: 6px; }
@keyframes skel-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ============================================
TAB SWITCH FADE — subtle 180ms ease-in
============================================ */
.tab-content.active {
animation: tab-fade-in 0.18s ease-out;
}
@keyframes tab-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ============================================
SCROLLBAR — twitch-themed, thin, subtle
============================================ */
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
}
*::-webkit-scrollbar-thumb {
background: rgba(145, 70, 255, 0.25);
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
transition: background 0.2s;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(145, 70, 255, 0.55);
background-clip: padding-box;
border: 2px solid transparent;
}
*::-webkit-scrollbar-corner {
background: transparent;
}
.add-streamer {
padding: 10px;
display: flex;
gap: 8px;
}
.add-streamer input {
flex: 1;
background: var(--bg-card);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 8px 12px;
color: var(--text);
font-size: 13px;
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
}
.add-streamer input:focus {
outline: none;
border-color: var(--accent);
background: rgba(145, 70, 255, 0.08);
box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.18);
}
.add-streamer input::placeholder {
color: var(--text-secondary);
}
.add-streamer button {
background: var(--accent);
border: none;
border-radius: 6px;
color: white;
width: 36px;
cursor: pointer;
font-size: 18px;
font-weight: 600;
transition: background 0.18s, transform 0.12s, box-shadow 0.18s;
}
.add-streamer button:hover {
background: var(--accent-hover);
box-shadow: 0 4px 14px rgba(145, 70, 255, 0.35);
}
.add-streamer button:active {
transform: scale(0.96);
}
/* ============================================
GLOBAL TEXT-INPUT POLISH — focus ring + smooth transitions
============================================ */
input[type="text"],
input[type="search"],
input[type="number"],
input[type="password"],
input[type="email"],
textarea,
select {
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
}
input[type="text"]:focus,
input[type="search"]:focus,
input[type="number"]:focus,
input[type="password"]:focus,
input[type="email"]:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--accent) !important;
box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.18);
}
/* Soft mouseover affordance — every text/search/number/etc. input + textarea
+ select picks up a half-tone accent border on hover, matching the
.select-compact + .filter-input hover pattern. :not(:focus) keeps the
focus ring (above) from competing, :not(:disabled) leaves disabled
inputs inert. */
input[type="text"]:hover:not(:focus):not(:disabled),
input[type="search"]:hover:not(:focus):not(:disabled),
input[type="number"]:hover:not(:focus):not(:disabled),
input[type="password"]:hover:not(:focus):not(:disabled),
input[type="email"]:hover:not(:focus):not(:disabled),
textarea:hover:not(:focus):not(:disabled),
select:hover:not(:focus):not(:disabled) {
border-color: rgba(145, 70, 255, 0.45);
}
/* ============================================
CUSTOM CHECKBOX — modern Twitch-purple
============================================
Replaces the OS gray box with a 16px rounded square that fills
purple + draws a white check when checked. Smooth 0.18s transition
on every state change. Focus ring matches inputs. */
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
flex-shrink: 0;
border: 1.5px solid var(--border-soft);
border-radius: 4px;
background: var(--bg-card);
cursor: pointer;
position: relative;
transition: background 0.18s, border-color 0.18s, box-shadow 0.18s, transform 0.12s;
vertical-align: middle;
}
input[type="checkbox"]:hover:not(:disabled) {
border-color: rgba(145, 70, 255, 0.6);
}
input[type="checkbox"]:checked {
background: var(--accent);
border-color: var(--accent);
}
input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 4px;
top: 0.5px;
width: 5px;
height: 9px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
input[type="checkbox"]:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.25);
}
input[type="checkbox"]:active:not(:disabled) {
transform: scale(0.92);
}
input[type="checkbox"]:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ============================================
CUSTOM RADIO — matches the checkbox visual
============================================ */
input[type="radio"] {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
flex-shrink: 0;
border: 1.5px solid var(--border-soft);
border-radius: 50%;
background: var(--bg-card);
cursor: pointer;
position: relative;
transition: background 0.18s, border-color 0.18s, box-shadow 0.18s, transform 0.12s;
vertical-align: middle;
}
input[type="radio"]:hover:not(:disabled) {
border-color: rgba(145, 70, 255, 0.6);
}
input[type="radio"]:checked {
border-color: var(--accent);
background: var(--bg-card);
}
input[type="radio"]:checked::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px rgba(145, 70, 255, 0.45);
}
input[type="radio"]:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.25);
}
input[type="radio"]:active:not(:disabled) {
transform: scale(0.92);
}
/* ============================================
CUSTOM SELECT — chevron via inline SVG background
============================================ */
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23999'%3E%3Cpath d='M4 6l4 4 4-4z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 14px;
padding-right: 28px !important;
cursor: pointer;
}
select:hover:not(:disabled) {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%239146FF'%3E%3Cpath d='M4 6l4 4 4-4z'/%3E%3C/svg%3E");
}
select option {
background: var(--bg-card);
color: var(--text);
}
/* Compact-row select — used in tool/filter rows that sit OUTSIDE the
.form-group container (which has its own select padding rules).
Without this class the bare