feat(quality): v3 ranked-targets UI editor (drag-to-reorder)

Replace the v2 per-tier quality UI (FLAC on/off + MP3 sliders + bit-depth
buttons) with a draggable ordered target list. Each row shows its rank +
label with move/delete; an add form picks format and, for lossless, bit
depth + min sample rate, or for lossy a minimum bitrate threshold (>=) so
VBR/mono files aren't falsely rejected. Persists v3 ranked_targets via the
existing /api/quality-profile. Presets + fallback toggle retained; help
text and tooltip rewritten for the new top-down source-gating model.

Verified: v3 profile round-trips UI shape -> DB -> load_profile_targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-14 12:41:38 +02:00
parent 71f7c4a5e1
commit e0c55342bc
4 changed files with 310 additions and 332 deletions

View file

@ -5087,144 +5087,41 @@
</div>
</div>
<!-- FLAC Quality -->
<div class="quality-tier">
<div class="quality-tier-header">
<label class="checkbox-label">
<input type="checkbox" id="quality-flac-enabled" checked
onchange="toggleQuality('flac')">
<span class="quality-tier-name">FLAC (Lossless)</span>
</label>
<span class="quality-tier-priority" id="priority-flac">Priority: 1</span>
<!-- Ranked target priority list (v3) -->
<div class="ranked-targets-editor">
<label class="ranked-targets-label">Quality priority (drag to reorder — 1st = most preferred):</label>
<div id="ranked-targets-list" class="ranked-targets-list">
<!-- rows injected by renderRankedTargets() -->
</div>
<div class="quality-tier-sliders" id="sliders-flac">
<div class="slider-group">
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min" id="flac-min"
min="0" max="10000" value="500" step="100"
oninput="updateQualityRange('flac')">
<input type="range" class="range-slider range-slider-max" id="flac-max"
min="0" max="10000" value="10000" step="100"
oninput="updateQualityRange('flac')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="flac-min-value">500 kbps</span>
<span>-</span>
<span id="flac-max-value">10000 kbps</span>
</div>
</div>
</div>
<div class="flac-bit-depth-selector" id="flac-bit-depth-selector">
<label>Bit Depth:</label>
<div class="bit-depth-buttons">
<button class="bit-depth-btn active" data-value="any" onclick="setFlacBitDepth('any')">Any</button>
<button class="bit-depth-btn" data-value="16" onclick="setFlacBitDepth('16')">16-bit</button>
<button class="bit-depth-btn" data-value="24" onclick="setFlacBitDepth('24')">24-bit</button>
</div>
<div class="flac-fallback-toggle" id="flac-fallback-toggle" style="display: none; margin-top: 6px;">
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.8em; color: #ccc;">
<input type="checkbox" id="flac-bit-depth-fallback" checked onchange="setFlacBitDepthFallback(this.checked)">
Accept other bit depths as fallback
<!-- Add a new target -->
<div class="ranked-target-add">
<select id="rt-add-format" onchange="onRtAddFormatChange()">
<option value="flac">FLAC (lossless)</option>
<option value="mp3">MP3</option>
<option value="aac">AAC</option>
</select>
<span class="rt-lossless-fields">
<select id="rt-add-bitdepth" title="Minimum bit depth">
<option value="">Any bit depth</option>
<option value="16">16-bit</option>
<option value="24">24-bit</option>
</select>
<select id="rt-add-samplerate" title="Minimum sample rate">
<option value="">Any sample rate</option>
<option value="44100">≥ 44.1 kHz</option>
<option value="48000">≥ 48 kHz</option>
<option value="96000">≥ 96 kHz</option>
<option value="192000">≥ 192 kHz</option>
</select>
</span>
<span class="rt-lossy-fields" style="display:none;">
<label class="rt-inline-label">
<input type="number" id="rt-add-bitrate" min="0" max="2000" step="1" value="320" style="width:70px;"
autocomplete="off" data-bwignore="" data-1p-ignore="" data-lpignore="true"> kbps
</label>
<div style="color: #888; font-size: 0.75em; margin-top: 2px;">
When enabled, accepts any FLAC rather than rejecting on bit depth mismatch
</div>
</div>
</div>
</div>
<!-- MP3 320 Quality -->
<div class="quality-tier">
<div class="quality-tier-header">
<label class="checkbox-label">
<input type="checkbox" id="quality-mp3_320-enabled" checked
onchange="toggleQuality('mp3_320')">
<span class="quality-tier-name">MP3 320 kbps</span>
</label>
<span class="quality-tier-priority" id="priority-mp3_320">Priority: 2</span>
</div>
<div class="quality-tier-sliders" id="sliders-mp3_320">
<div class="slider-group">
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min"
id="mp3_320-min" min="0" max="500" value="280" step="10"
oninput="updateQualityRange('mp3_320')">
<input type="range" class="range-slider range-slider-max"
id="mp3_320-max" min="0" max="500" value="500" step="10"
oninput="updateQualityRange('mp3_320')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_320-min-value">280 kbps</span>
<span>-</span>
<span id="mp3_320-max-value">500 kbps</span>
</div>
</div>
</div>
</div>
<!-- MP3 256 Quality -->
<div class="quality-tier">
<div class="quality-tier-header">
<label class="checkbox-label">
<input type="checkbox" id="quality-mp3_256-enabled" checked
onchange="toggleQuality('mp3_256')">
<span class="quality-tier-name">MP3 256 kbps</span>
</label>
<span class="quality-tier-priority" id="priority-mp3_256">Priority: 3</span>
</div>
<div class="quality-tier-sliders" id="sliders-mp3_256">
<div class="slider-group">
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min"
id="mp3_256-min" min="0" max="400" value="200" step="10"
oninput="updateQualityRange('mp3_256')">
<input type="range" class="range-slider range-slider-max"
id="mp3_256-max" min="0" max="400" value="400" step="10"
oninput="updateQualityRange('mp3_256')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_256-min-value">200 kbps</span>
<span>-</span>
<span id="mp3_256-max-value">400 kbps</span>
</div>
</div>
</div>
</div>
<!-- MP3 192 Quality -->
<div class="quality-tier">
<div class="quality-tier-header">
<label class="checkbox-label">
<input type="checkbox" id="quality-mp3_192-enabled"
onchange="toggleQuality('mp3_192')">
<span class="quality-tier-name">MP3 192 kbps</span>
</label>
<span class="quality-tier-priority" id="priority-mp3_192">Priority: 4</span>
</div>
<div class="quality-tier-sliders disabled" id="sliders-mp3_192">
<div class="slider-group">
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min"
id="mp3_192-min" min="0" max="300" value="150" step="10"
oninput="updateQualityRange('mp3_192')">
<input type="range" class="range-slider range-slider-max"
id="mp3_192-max" min="0" max="300" value="300" step="10"
oninput="updateQualityRange('mp3_192')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_192-min-value">150 kbps</span>
<span>-</span>
<span id="mp3_192-max-value">300 kbps</span>
</div>
</div>
</span>
<button type="button" class="rt-add-btn" onclick="addRankedTarget()">+ Add target</button>
</div>
</div>
@ -5232,16 +5129,18 @@
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="quality-fallback-enabled" checked>
Allow fallback to any quality if preferred qualities unavailable
Allow fallback to any quality if no target is available
</label>
</div>
<div class="help-text">
<strong>How it works:</strong> Downloads try each enabled quality in priority order
(1 = highest).
MIN bitrate catches fake/transcoded files (e.g., FLAC below 500 kbps is likely a
re-encoded MP3). MAX bitrate limits hi-res files if you want to save space.
When track duration is unavailable, a generous file-size safety net is used instead.
<strong>How it works:</strong> Each download source is checked against this list
top-down. The first target a source can satisfy wins; a source that meets no target
is skipped for the next one (source priority still decides between sources that can).
For lossless, bit depth + sample rate decide the match. For MP3/AAC the bitrate is a
<em>minimum</em> threshold (≥), so VBR and mono files aren't falsely rejected. After
download the real file is verified against the same list. With fallback off, a track
is left missing rather than accepting a quality below every target.
</div>
</div>

View file

@ -1872,9 +1872,9 @@ const HELPER_CONTENT = {
title: 'Quality Preset',
description: 'One-click quality configuration. Presets set all format enables, priorities, and bitrate ranges at once.',
},
'.bit-depth-btn': {
title: 'FLAC Bit Depth',
description: 'Prefer 16-bit (CD quality, smaller), 24-bit (hi-res, larger), or Any. When a specific depth is chosen, the fallback toggle controls whether other depths are accepted.',
'.ranked-targets-editor': {
title: 'Quality Priority List',
description: 'Ordered list of acceptable qualities (1st = most preferred). Each source is checked top-down; the first target it can satisfy wins. Lossless matches on bit depth + sample rate; MP3/AAC use a minimum bitrate (≥) so VBR/mono files aren\'t falsely rejected. Drag to reorder.',
docsId: 'set-quality'
},
'#quality-fallback-enabled': {

View file

@ -1904,175 +1904,132 @@ async function loadQualityProfile() {
}
}
// v3: the working copy of the ordered target list. Mirrors the DOM rows
// and is the single source of truth that collectQualityProfileFromUI reads.
let currentRankedTargets = [];
function rtLabel(t) {
const fmt = (t.format || 'any').toUpperCase();
if (t.format === 'flac' || t.format === 'wav') {
const bd = t.bit_depth ? `${t.bit_depth}-bit` : '';
const sr = t.min_sample_rate ? `${t.min_sample_rate / 1000}kHz` : '';
const detail = [bd, sr].filter(Boolean).join('/');
return detail ? `${fmt} ${detail}` : fmt;
}
return t.min_bitrate ? `${fmt}${t.min_bitrate}kbps` : fmt;
}
function populateQualityProfileUI(profile) {
// Update preset buttons
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.preset-button').forEach(btn => btn.classList.remove('active'));
const activePresetBtn = document.querySelector(`.preset-button[onclick*="${profile.preset}"]`);
if (activePresetBtn) {
activePresetBtn.classList.add('active');
}
if (activePresetBtn) activePresetBtn.classList.add('active');
// Populate each quality tier
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
qualities.forEach(quality => {
const config = profile.qualities[quality];
if (config) {
// Set enabled checkbox
const enabledCheckbox = document.getElementById(`quality-${quality}-enabled`);
if (enabledCheckbox) {
enabledCheckbox.checked = config.enabled;
}
// The API migrates v2 → v3, so ranked_targets is always present.
currentRankedTargets = Array.isArray(profile.ranked_targets)
? profile.ranked_targets.map(t => ({ ...t }))
: [];
renderRankedTargets();
// Set min/max sliders
const minSlider = document.getElementById(`${quality}-min`);
const maxSlider = document.getElementById(`${quality}-max`);
if (minSlider && maxSlider) {
minSlider.value = config.min_kbps;
maxSlider.value = config.max_kbps;
updateQualityRange(quality);
}
// Set priority display
const prioritySpan = document.getElementById(`priority-${quality}`);
if (prioritySpan) {
prioritySpan.textContent = `Priority: ${config.priority}`;
}
// Toggle sliders visibility
const sliders = document.getElementById(`sliders-${quality}`);
if (sliders) {
if (config.enabled) {
sliders.classList.remove('disabled');
} else {
sliders.classList.add('disabled');
}
}
// FLAC-specific: restore bit depth selector and fallback toggle
if (quality === 'flac') {
const bitDepthValue = config.bit_depth || 'any';
document.querySelectorAll('.bit-depth-btn').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-value') === bitDepthValue);
});
const bitDepthSelector = document.getElementById('flac-bit-depth-selector');
if (bitDepthSelector) {
if (config.enabled) {
bitDepthSelector.classList.remove('disabled');
} else {
bitDepthSelector.classList.add('disabled');
}
}
// Show/hide and restore fallback toggle
const fallbackToggle = document.getElementById('flac-fallback-toggle');
if (fallbackToggle) {
fallbackToggle.style.display = bitDepthValue === 'any' ? 'none' : 'block';
}
const fallbackCb = document.getElementById('flac-bit-depth-fallback');
if (fallbackCb) {
fallbackCb.checked = config.bit_depth_fallback !== false;
}
}
}
});
// Set fallback checkbox
const fallbackCheckbox = document.getElementById('quality-fallback-enabled');
if (fallbackCheckbox) {
fallbackCheckbox.checked = profile.fallback_enabled;
}
if (fallbackCheckbox) fallbackCheckbox.checked = profile.fallback_enabled !== false;
}
function updateQualityRange(quality) {
const minSlider = document.getElementById(`${quality}-min`);
const maxSlider = document.getElementById(`${quality}-max`);
const minValue = document.getElementById(`${quality}-min-value`);
const maxValue = document.getElementById(`${quality}-max-value`);
function renderRankedTargets() {
const list = document.getElementById('ranked-targets-list');
if (!list) return;
list.innerHTML = '';
if (!minSlider || !maxSlider || !minValue || !maxValue) return;
let min = parseInt(minSlider.value);
let max = parseInt(maxSlider.value);
// Ensure min doesn't exceed max
if (min > max) {
min = max;
minSlider.value = min;
if (currentRankedTargets.length === 0) {
list.innerHTML = '<div class="ranked-targets-empty">No targets yet — add one below. '
+ 'With fallback off this would reject every download.</div>';
return;
}
// Ensure max doesn't go below min
if (max < min) {
max = min;
maxSlider.value = max;
}
minValue.textContent = `${min} kbps`;
maxValue.textContent = `${max} kbps`;
}
function toggleQuality(quality) {
const checkbox = document.getElementById(`quality-${quality}-enabled`);
const sliders = document.getElementById(`sliders-${quality}`);
if (checkbox && sliders) {
if (checkbox.checked) {
sliders.classList.remove('disabled');
} else {
sliders.classList.add('disabled');
}
}
// Also toggle FLAC bit depth selector
if (quality === 'flac') {
const bitDepthSelector = document.getElementById('flac-bit-depth-selector');
if (bitDepthSelector && checkbox) {
if (checkbox.checked) {
bitDepthSelector.classList.remove('disabled');
} else {
bitDepthSelector.classList.add('disabled');
}
}
}
// Mark preset as custom when manually changing
if (currentQualityProfile) {
currentQualityProfile.preset = 'custom';
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
currentRankedTargets.forEach((t, i) => {
const row = document.createElement('div');
row.className = 'ranked-target-row';
row.draggable = true;
row.dataset.index = String(i);
row.innerHTML = `
<span class="rt-handle" title="Drag to reorder"></span>
<span class="rt-rank">${i + 1}</span>
<span class="rt-label">${rtLabel(t)}</span>
<span class="rt-spacer"></span>
<button type="button" class="rt-move" title="Move up" onclick="moveRankedTarget(${i}, -1)"></button>
<button type="button" class="rt-move" title="Move down" onclick="moveRankedTarget(${i}, 1)"></button>
<button type="button" class="rt-del" title="Remove" onclick="deleteRankedTarget(${i})">🗑</button>
`;
row.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', String(i));
row.classList.add('rt-dragging');
});
}
}
function setFlacBitDepth(value) {
document.querySelectorAll('.bit-depth-btn').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-value') === value);
row.addEventListener('dragend', () => row.classList.remove('rt-dragging'));
row.addEventListener('dragover', e => { e.preventDefault(); row.classList.add('rt-dragover'); });
row.addEventListener('dragleave', () => row.classList.remove('rt-dragover'));
row.addEventListener('drop', e => {
e.preventDefault();
row.classList.remove('rt-dragover');
const from = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (!Number.isNaN(from) && from !== i) reorderRankedTarget(from, i);
});
list.appendChild(row);
});
}
// Show/hide fallback toggle — only relevant when a specific bit depth is selected
const fallbackToggle = document.getElementById('flac-fallback-toggle');
if (fallbackToggle) {
fallbackToggle.style.display = value === 'any' ? 'none' : 'block';
}
// Mark preset as custom when manually changing
if (currentQualityProfile) {
currentQualityProfile.preset = 'custom';
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
}
function markQualityProfileCustom() {
if (currentQualityProfile) currentQualityProfile.preset = 'custom';
document.querySelectorAll('.preset-button').forEach(btn => btn.classList.remove('active'));
}
function reorderRankedTarget(from, to) {
const [moved] = currentRankedTargets.splice(from, 1);
currentRankedTargets.splice(to, 0, moved);
markQualityProfileCustom();
renderRankedTargets();
debouncedAutoSaveSettings();
}
function setFlacBitDepthFallback(enabled) {
if (currentQualityProfile) {
currentQualityProfile.preset = 'custom';
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
function moveRankedTarget(i, dir) {
const j = i + dir;
if (j < 0 || j >= currentRankedTargets.length) return;
[currentRankedTargets[i], currentRankedTargets[j]] = [currentRankedTargets[j], currentRankedTargets[i]];
markQualityProfileCustom();
renderRankedTargets();
debouncedAutoSaveSettings();
}
function deleteRankedTarget(i) {
currentRankedTargets.splice(i, 1);
markQualityProfileCustom();
renderRankedTargets();
debouncedAutoSaveSettings();
}
function onRtAddFormatChange() {
const lossless = document.getElementById('rt-add-format')?.value === 'flac';
const llFields = document.querySelector('.rt-lossless-fields');
const lyFields = document.querySelector('.rt-lossy-fields');
if (llFields) llFields.style.display = lossless ? '' : 'none';
if (lyFields) lyFields.style.display = lossless ? 'none' : '';
}
function addRankedTarget() {
const fmt = document.getElementById('rt-add-format')?.value || 'flac';
const t = { format: fmt };
if (fmt === 'flac') {
const bd = document.getElementById('rt-add-bitdepth')?.value;
const sr = document.getElementById('rt-add-samplerate')?.value;
if (bd) t.bit_depth = parseInt(bd, 10);
if (sr) t.min_sample_rate = parseInt(sr, 10);
} else {
const br = document.getElementById('rt-add-bitrate')?.value;
if (br) t.min_bitrate = parseInt(br, 10);
}
t.label = rtLabel(t);
currentRankedTargets.push(t);
markQualityProfileCustom();
renderRankedTargets();
debouncedAutoSaveSettings();
}
@ -2102,45 +2059,23 @@ async function applyQualityPreset(presetName) {
}
function collectQualityProfileFromUI() {
const profile = {
version: 2,
preset: 'custom', // Will be overridden if a preset is active
qualities: {},
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true
};
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
qualities.forEach((quality, index) => {
const enabled = document.getElementById(`quality-${quality}-enabled`)?.checked || false;
const minSlider = document.getElementById(`${quality}-min`);
const maxSlider = document.getElementById(`${quality}-max`);
// Preserve priority from the currently loaded profile instead of using array order
const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? (index + 1);
profile.qualities[quality] = {
enabled: enabled,
min_kbps: parseInt(minSlider?.value || 0),
max_kbps: parseInt(maxSlider?.value || 99999),
priority: existingPriority
};
// Add FLAC-specific bit_depth and fallback settings
if (quality === 'flac') {
const activeBtn = document.querySelector('.bit-depth-btn.active');
profile.qualities[quality].bit_depth = activeBtn ? activeBtn.getAttribute('data-value') : 'any';
const fallbackCb = document.getElementById('flac-bit-depth-fallback');
profile.qualities[quality].bit_depth_fallback = fallbackCb ? fallbackCb.checked : true;
}
// v3: ordered target list. Drop empty/None fields so each target stays
// minimal (matches QualityTarget.to_dict on the backend).
const ranked_targets = currentRankedTargets.map(t => {
const out = { format: t.format };
if (t.label) out.label = t.label;
if (t.bit_depth) out.bit_depth = t.bit_depth;
if (t.min_sample_rate) out.min_sample_rate = t.min_sample_rate;
if (t.min_bitrate) out.min_bitrate = t.min_bitrate;
return out;
});
// Check if current profile matches a preset
if (currentQualityProfile && currentQualityProfile.preset !== 'custom') {
profile.preset = currentQualityProfile.preset;
}
return profile;
return {
version: 3,
preset: (currentQualityProfile && currentQualityProfile.preset) || 'custom',
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true,
ranked_targets,
};
}
async function saveQualityProfile() {

View file

@ -4090,6 +4090,150 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
/* Quality tier cards - upgraded inner cards */
/* ── Ranked-targets editor (v3 quality profile) ───────────────────────── */
.ranked-targets-editor {
margin-bottom: 16px;
}
.ranked-targets-label {
display: block;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
font-weight: 600;
margin-bottom: 8px;
}
.ranked-targets-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.ranked-targets-empty {
color: rgba(255, 255, 255, 0.4);
font-size: 11px;
font-style: italic;
padding: 10px 12px;
border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 10px;
}
.ranked-target-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
transition: border-color 0.15s ease, opacity 0.15s ease;
}
.ranked-target-row:hover {
border-color: rgba(255, 255, 255, 0.16);
}
.ranked-target-row.rt-dragging {
opacity: 0.4;
}
.ranked-target-row.rt-dragover {
border-color: rgba(99, 179, 237, 0.7);
}
.ranked-target-row .rt-handle {
cursor: grab;
color: rgba(255, 255, 255, 0.35);
font-size: 14px;
user-select: none;
}
.ranked-target-row .rt-rank {
min-width: 18px;
text-align: center;
color: rgba(255, 255, 255, 0.45);
font-size: 11px;
font-weight: 600;
}
.ranked-target-row .rt-label {
color: rgba(255, 255, 255, 0.95);
font-size: 12px;
font-weight: 600;
}
.ranked-target-row .rt-spacer {
flex: 1;
}
.ranked-target-row .rt-move,
.ranked-target-row .rt-del {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
font-size: 11px;
padding: 2px 7px;
transition: background 0.15s ease;
}
.ranked-target-row .rt-move:hover,
.ranked-target-row .rt-del:hover {
background: rgba(255, 255, 255, 0.12);
}
.ranked-target-row .rt-del:hover {
background: rgba(229, 62, 62, 0.25);
border-color: rgba(229, 62, 62, 0.5);
}
.ranked-target-add {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
}
.ranked-target-add select,
.ranked-target-add input[type="number"] {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: rgba(255, 255, 255, 0.9);
font-size: 11px;
padding: 4px 6px;
}
.ranked-target-add .rt-inline-label {
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.ranked-target-add .rt-add-btn {
background: rgba(99, 179, 237, 0.18);
border: 1px solid rgba(99, 179, 237, 0.4);
border-radius: 6px;
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
font-size: 11px;
font-weight: 600;
padding: 5px 12px;
transition: background 0.15s ease;
}
.ranked-target-add .rt-add-btn:hover {
background: rgba(99, 179, 237, 0.3);
}
.quality-tier {
margin-bottom: 16px;
padding: 12px;