ML-142: Accepted mockup and plan
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
<style>
|
||||
:root {
|
||||
--z-50: #fafafa;
|
||||
--z-100: #f4f4f5;
|
||||
--z-200: #e4e4e7;
|
||||
--z-300: #d4d4d8;
|
||||
--z-400: #a1a1aa;
|
||||
--z-500: #71717a;
|
||||
--z-600: #52525b;
|
||||
--z-700: #3f3f46;
|
||||
--z-800: #27272a;
|
||||
--z-900: #18181b;
|
||||
--z-950: #09090b;
|
||||
--blue-500: #3b82f6;
|
||||
--blue-600: #2563eb;
|
||||
--blue-700: #1d4ed8;
|
||||
--blue-soft: #dbeafe;
|
||||
|
||||
--surface: #ffffff;
|
||||
--surface-muted: var(--z-50);
|
||||
--surface-elevated: var(--z-100);
|
||||
--border-c: var(--z-200);
|
||||
--text: var(--z-900);
|
||||
--text-muted: var(--z-500);
|
||||
--text-subtle: var(--z-400);
|
||||
--primary: var(--blue-600);
|
||||
--primary-soft-bg: var(--blue-soft);
|
||||
--primary-soft-text: var(--blue-700);
|
||||
--accent-row: #eff6ff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--surface: var(--z-900);
|
||||
--surface-muted: var(--z-800);
|
||||
--surface-elevated: #1f1f22;
|
||||
--border-c: var(--z-700);
|
||||
--text: var(--z-100);
|
||||
--text-muted: var(--z-400);
|
||||
--text-subtle: var(--z-500);
|
||||
--primary: #60a5fa;
|
||||
--primary-soft-bg: rgba(59, 130, 246, 0.15);
|
||||
--primary-soft-text: #93c5fd;
|
||||
--accent-row: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.ml-sheet {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-c);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06);
|
||||
}
|
||||
.ml-sheet-header {
|
||||
padding: 0.9rem 1rem;
|
||||
border-bottom: 1px solid var(--border-c);
|
||||
background: var(--surface);
|
||||
}
|
||||
.ml-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.ml-title { font-weight: 600; font-size: 0.98rem; color: var(--text); line-height: 1.2; }
|
||||
.ml-subtitle { color: var(--text-muted); font-size: 0.75rem; margin-top: 0.15rem; }
|
||||
.ml-actions { display: flex; align-items: center; gap: 0.4rem; flex-wrap: nowrap; }
|
||||
|
||||
.ml-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.6rem 0.35rem 0.5rem;
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border-c);
|
||||
border-radius: 8px;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text);
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ml-picker svg { color: var(--text-muted); flex-shrink: 0; }
|
||||
.ml-picker-label { color: var(--text-muted); font-size: 0.7rem; }
|
||||
.ml-picker-value { font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.ml-picker-caret { color: var(--text-subtle); margin-left: 0.1rem; }
|
||||
|
||||
.ml-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ml-btn svg { width: 14px; height: 14px; }
|
||||
.ml-btn-solid { background: var(--primary); color: white; border-color: var(--primary); }
|
||||
.ml-btn-soft { background: var(--primary-soft-bg); color: var(--primary-soft-text); border-color: transparent; }
|
||||
.ml-btn-outline{ background: var(--surface); color: var(--text); border-color: var(--border-c); }
|
||||
.ml-btn-ghost { background: transparent; color: var(--text-muted); }
|
||||
.ml-btn.icon-only { padding: 0; width: 30px; height: 30px; }
|
||||
.ml-btn.disabled { opacity: 0.45; }
|
||||
|
||||
.ml-btn-group { display: inline-flex; gap: 0.35rem; }
|
||||
|
||||
/* Medium header */
|
||||
.ml-medium-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--surface-muted);
|
||||
border-bottom: 1px solid var(--border-c);
|
||||
}
|
||||
.ml-medium-title { display: flex; align-items: center; gap: 0.55rem; font-weight: 600; font-size: 0.82rem; }
|
||||
.ml-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-c);
|
||||
border-radius: 999px;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.ml-chk {
|
||||
appearance: none;
|
||||
width: 15px; height: 15px;
|
||||
border: 1.5px solid var(--z-400);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ml-chk.checked { background: var(--primary); border-color: var(--primary); }
|
||||
.ml-chk.checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px; top: 0px;
|
||||
width: 5px; height: 9px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Track row */
|
||||
.ml-track {
|
||||
padding: 0.55rem 1rem 0.55rem 1rem;
|
||||
border-bottom: 1px solid var(--border-c);
|
||||
display: grid;
|
||||
grid-template-columns: 15px 22px 1fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.ml-track:last-child { border-bottom: none; }
|
||||
.ml-track.selected { background: var(--accent-row); }
|
||||
.ml-track-num { color: var(--text-subtle); font-size: 0.72rem; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.ml-track-title { color: var(--text); }
|
||||
.ml-track-dur { color: var(--text-muted); font-size: 0.72rem; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Sticky selection bar */
|
||||
.ml-sticky {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border-c);
|
||||
box-shadow: 0 -6px 18px rgba(0,0,0,0.08);
|
||||
padding: 0.65rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.ml-sticky-info { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.ml-sticky-count { font-size: 0.82rem; font-weight: 600; color: var(--text); }
|
||||
.ml-sticky-meta { font-size: 0.7rem; color: var(--text-muted); }
|
||||
|
||||
.ml-body { max-height: 520px; overflow-y: auto; scrollbar-width: thin; }
|
||||
|
||||
/* Viewport frames */
|
||||
.viewport-frame {
|
||||
border: 1px solid var(--border-c);
|
||||
border-radius: 14px;
|
||||
background: var(--surface-muted);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
.viewport-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex; justify-content: space-between;
|
||||
}
|
||||
.viewport-label .dim { color: var(--text-subtle); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.grid-two { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.annotation {
|
||||
background: var(--primary-soft-bg);
|
||||
color: var(--primary-soft-text);
|
||||
border-left: 3px solid var(--primary);
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.75rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.annotation strong { font-weight: 600; }
|
||||
|
||||
.hierarchy-diagram {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-c);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.h-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.4rem 0; }
|
||||
.h-label { font-weight: 600; width: 130px; color: var(--text); }
|
||||
.h-arrow { color: var(--text-subtle); }
|
||||
.h-scope { color: var(--text-muted); }
|
||||
|
||||
/* Mobile-specific overrides on narrow viewport mock */
|
||||
.m-narrow .ml-title-row { flex-direction: column; align-items: stretch; }
|
||||
.m-narrow .ml-title-row .ml-actions-top { justify-content: space-between; }
|
||||
.m-narrow .ml-picker { flex: 1; justify-content: flex-start; }
|
||||
.m-narrow .ml-btn-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.m-narrow .ml-btn-row .ml-picker { flex: 1; }
|
||||
.m-narrow .ml-btn-row .ml-btn-solid { flex: 0 0 auto; }
|
||||
</style>
|
||||
|
||||
<h2>Option A — v2, app-styled + mobile layout + explicit "Scrobble release"</h2>
|
||||
<p class="subtitle">Addresses the three iteration asks: using the app's zinc/blue palette and Fluxon button variants, dedicated mobile layout, and a clear top-level "Scrobble release" action.</p>
|
||||
|
||||
<div class="hierarchy-diagram">
|
||||
<div style="font-size: 0.72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.4rem;">Action hierarchy</div>
|
||||
<div class="h-row">
|
||||
<span class="h-label">Header action</span>
|
||||
<span class="h-arrow">→</span>
|
||||
<span class="ml-btn ml-btn-solid" style="padding: 0.2rem 0.5rem;"><svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path d="M6 4l10 6-10 6V4z"/></svg> Scrobble release</span>
|
||||
<span class="h-scope">all tracks, one submission</span>
|
||||
</div>
|
||||
<div class="h-row">
|
||||
<span class="h-label">Per-medium action</span>
|
||||
<span class="h-arrow">→</span>
|
||||
<span class="ml-btn ml-btn-soft" style="padding: 0.2rem 0.5rem;"><svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path d="M6 4l10 6-10 6V4z"/></svg> Scrobble disc</span>
|
||||
<span class="h-scope">tracks of that medium</span>
|
||||
</div>
|
||||
<div class="h-row">
|
||||
<span class="h-label">Selection action</span>
|
||||
<span class="h-arrow">→</span>
|
||||
<span class="ml-btn ml-btn-solid" style="padding: 0.2rem 0.5rem;"><svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path d="M6 4l10 6-10 6V4z"/></svg> Scrobble selected</span>
|
||||
<span class="h-scope">only ticked rows — appears only when something is ticked</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-two" style="margin-top: 1.5rem;">
|
||||
|
||||
<!-- DESKTOP -->
|
||||
<div>
|
||||
<div class="viewport-label">
|
||||
<span>Desktop · sheet width ~520px</span>
|
||||
<span class="dim">Fluxon variants: solid primary / soft primary / outline</span>
|
||||
</div>
|
||||
<div class="viewport-frame">
|
||||
<div class="ml-sheet">
|
||||
<div class="ml-sheet-header">
|
||||
<div class="ml-title-row">
|
||||
<div>
|
||||
<div class="ml-title">Kid A Mnesia</div>
|
||||
<div class="ml-subtitle">Radiohead · 2 CDs · 2021</div>
|
||||
</div>
|
||||
<div class="ml-actions">
|
||||
<span class="ml-picker" title="Click to change">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .2.08.39.22.53l3 3a.75.75 0 101.06-1.06l-2.78-2.78V5z" clip-rule="evenodd"/></svg>
|
||||
<span class="ml-picker-label">Finished at</span>
|
||||
<span class="ml-picker-value">Now · Apr 22, 14:30</span>
|
||||
<span class="ml-picker-caret">▾</span>
|
||||
</span>
|
||||
<button class="ml-btn ml-btn-solid">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
|
||||
Scrobble release
|
||||
</button>
|
||||
<button class="ml-btn ml-btn-outline icon-only" title="More">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-body">
|
||||
<div class="ml-medium-header">
|
||||
<div class="ml-medium-title">
|
||||
<span class="ml-chk"></span>
|
||||
Disc 1
|
||||
<span class="ml-badge">CD</span>
|
||||
</div>
|
||||
<div class="ml-btn-group">
|
||||
<button class="ml-btn ml-btn-soft">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
|
||||
Scrobble disc
|
||||
</button>
|
||||
<button class="ml-btn ml-btn-outline icon-only">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-track selected">
|
||||
<span class="ml-chk checked"></span>
|
||||
<span class="ml-track-num">1</span>
|
||||
<span class="ml-track-title">How to Disappear Completely</span>
|
||||
<span class="ml-track-dur">5:56</span>
|
||||
</div>
|
||||
<div class="ml-track">
|
||||
<span class="ml-chk"></span>
|
||||
<span class="ml-track-num">2</span>
|
||||
<span class="ml-track-title">The National Anthem</span>
|
||||
<span class="ml-track-dur">5:51</span>
|
||||
</div>
|
||||
<div class="ml-track">
|
||||
<span class="ml-chk"></span>
|
||||
<span class="ml-track-num">3</span>
|
||||
<span class="ml-track-title">Everything in Its Right Place</span>
|
||||
<span class="ml-track-dur">4:11</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-medium-header">
|
||||
<div class="ml-medium-title">
|
||||
<span class="ml-chk"></span>
|
||||
Disc 2
|
||||
<span class="ml-badge">CD</span>
|
||||
</div>
|
||||
<div class="ml-btn-group">
|
||||
<button class="ml-btn ml-btn-soft">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
|
||||
Scrobble disc
|
||||
</button>
|
||||
<button class="ml-btn ml-btn-outline icon-only">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-track selected">
|
||||
<span class="ml-chk checked"></span>
|
||||
<span class="ml-track-num">1</span>
|
||||
<span class="ml-track-title">Pyramid Song</span>
|
||||
<span class="ml-track-dur">4:49</span>
|
||||
</div>
|
||||
<div class="ml-track">
|
||||
<span class="ml-chk"></span>
|
||||
<span class="ml-track-num">2</span>
|
||||
<span class="ml-track-title">Idioteque</span>
|
||||
<span class="ml-track-dur">5:09</span>
|
||||
</div>
|
||||
<div class="ml-track">
|
||||
<span class="ml-chk"></span>
|
||||
<span class="ml-track-num">3</span>
|
||||
<span class="ml-track-title">Like Spinning Plates</span>
|
||||
<span class="ml-track-dur">3:57</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-sticky">
|
||||
<div class="ml-sticky-info">
|
||||
<span class="ml-sticky-count">2 tracks selected</span>
|
||||
<span class="ml-sticky-meta">across 2 discs · 10:45</span>
|
||||
</div>
|
||||
<button class="ml-btn ml-btn-solid">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
|
||||
Scrobble selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="annotation">
|
||||
<strong>Scrobble release</strong> sits in the header beside the "Finished at" picker. Solid primary to mark the default action. Per-medium <strong>Scrobble disc</strong> uses the soft variant to stay a visible shortcut without competing with the primary.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOBILE -->
|
||||
<div>
|
||||
<div class="viewport-label">
|
||||
<span>Mobile · 360px width</span>
|
||||
<span class="dim">iPhone SE class</span>
|
||||
</div>
|
||||
<div class="viewport-frame" style="padding: 0.5rem;">
|
||||
<div class="ml-sheet m-narrow" style="font-size: 0.8rem;">
|
||||
<div class="ml-sheet-header" style="padding: 0.8rem 0.75rem;">
|
||||
<div class="ml-title-row">
|
||||
<div class="ml-actions-top" style="display: flex; justify-content: space-between; align-items: start; gap: 0.5rem;">
|
||||
<div>
|
||||
<div class="ml-title" style="font-size: 0.92rem;">Kid A Mnesia</div>
|
||||
<div class="ml-subtitle">Radiohead · 2 CDs</div>
|
||||
</div>
|
||||
<button class="ml-btn ml-btn-outline icon-only" title="More">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ml-btn-row">
|
||||
<span class="ml-picker" style="flex: 1; justify-content: space-between;">
|
||||
<span style="display: inline-flex; align-items: center; gap: 0.4rem;">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .2.08.39.22.53l3 3a.75.75 0 101.06-1.06l-2.78-2.78V5z" clip-rule="evenodd"/></svg>
|
||||
<span>
|
||||
<span class="ml-picker-label">Finished at</span>
|
||||
<span class="ml-picker-value" style="margin-left: 0.25rem;">Now</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ml-picker-caret">▾</span>
|
||||
</span>
|
||||
<button class="ml-btn ml-btn-solid" style="padding: 0.4rem 0.65rem;">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
|
||||
Release
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-body" style="max-height: 360px;">
|
||||
<div class="ml-medium-header" style="padding: 0.55rem 0.75rem;">
|
||||
<div class="ml-medium-title" style="font-size: 0.78rem;">
|
||||
<span class="ml-chk"></span>
|
||||
Disc 1
|
||||
<span class="ml-badge">CD</span>
|
||||
</div>
|
||||
<div class="ml-btn-group">
|
||||
<button class="ml-btn ml-btn-soft icon-only" title="Scrobble disc">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M6 4l10 6-10 6V4z"/></svg>
|
||||
</button>
|
||||
<button class="ml-btn ml-btn-outline icon-only">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-track selected" style="padding: 0.5rem 0.75rem; grid-template-columns: 15px 20px 1fr auto;">
|
||||
<span class="ml-chk checked"></span>
|
||||
<span class="ml-track-num">1</span>
|
||||
<span class="ml-track-title">How to Disappear Completely</span>
|
||||
<span class="ml-track-dur">5:56</span>
|
||||
</div>
|
||||
<div class="ml-track" style="padding: 0.5rem 0.75rem; grid-template-columns: 15px 20px 1fr auto;">
|
||||
<span class="ml-chk"></span>
|
||||
<span class="ml-track-num">2</span>
|
||||
<span class="ml-track-title">The National Anthem</span>
|
||||
<span class="ml-track-dur">5:51</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-medium-header" style="padding: 0.55rem 0.75rem;">
|
||||
<div class="ml-medium-title" style="font-size: 0.78rem;">
|
||||
<span class="ml-chk"></span>
|
||||
Disc 2
|
||||
<span class="ml-badge">CD</span>
|
||||
</div>
|
||||
<div class="ml-btn-group">
|
||||
<button class="ml-btn ml-btn-soft icon-only" title="Scrobble disc">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M6 4l10 6-10 6V4z"/></svg>
|
||||
</button>
|
||||
<button class="ml-btn ml-btn-outline icon-only">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-track selected" style="padding: 0.5rem 0.75rem; grid-template-columns: 15px 20px 1fr auto;">
|
||||
<span class="ml-chk checked"></span>
|
||||
<span class="ml-track-num">1</span>
|
||||
<span class="ml-track-title">Pyramid Song</span>
|
||||
<span class="ml-track-dur">4:49</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-sticky" style="padding: 0.55rem 0.75rem;">
|
||||
<div class="ml-sticky-info">
|
||||
<span class="ml-sticky-count" style="font-size: 0.78rem;">2 selected</span>
|
||||
<span class="ml-sticky-meta">10:45</span>
|
||||
</div>
|
||||
<button class="ml-btn ml-btn-solid" style="padding: 0.4rem 0.65rem;">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
|
||||
Scrobble
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="annotation">
|
||||
<strong>Mobile reflow:</strong> title row stacks title + ⋯; the picker and "Release" button share a full-width row (picker grows, button hugs). Per-medium scrobble becomes icon-only to fit. Sticky bar label shortens to "Scrobble" + count + duration.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.5rem; padding: 0.9rem 1rem; background: var(--surface); border-radius: 10px; border: 1px solid var(--border-c); font-size: 0.8rem; color: var(--text);">
|
||||
<strong>Design language notes</strong><br>
|
||||
<ul style="margin-top: 0.4rem; padding-left: 1.2rem; line-height: 1.6;">
|
||||
<li>Surfaces use the zinc palette (zinc-50 / zinc-200 / zinc-800) seen across the app.</li>
|
||||
<li>Buttons use Fluxon variants: solid primary (release + selection), soft primary (per-medium shortcut), outline (menu & picker).</li>
|
||||
<li>Icons are hero-style (outline-filled), matching existing `hero-play`, `hero-ellipsis-vertical`, `hero-clock`.</li>
|
||||
<li>Selected rows use a subtle blue-tinted background — matches the dimmed-accent pattern already in the Collection list.</li>
|
||||
<li>Dark mode swaps to zinc-900/800/700 and a lighter blue-400 primary; the mockup honours `prefers-color-scheme`.</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,183 @@
|
||||
# Improve scrobble UI in the Release component
|
||||
|
||||
## Context
|
||||
|
||||
The Release sheet's scrobble controls have three concrete friction points, confirmed on review:
|
||||
|
||||
1. **Navigation.** On a multi-medium release, the only way to scrobble a cross-medium selection is the top-level button — users have to scroll all the way back up after picking tracks on disc 3.
|
||||
2. **No picker for `finished_at`.** The timestamp is always `DateTime.utc_now()`; there is no UI to backfill a listening session. `ScrobbleActivity.scrobble_release/3`, `scrobble_medium/4`, and `scrobble_tracks/4` already accept a `DateTime`, so this is purely a UI gap.
|
||||
3. **Disabled buttons aren't visually distinct.** All scrobble buttons use `variant="soft"` with only `opacity-70` for disabled — hard to tell state apart. The per-medium button is *always* disabled when any track is selected anywhere, which is surprising.
|
||||
|
||||
A fourth issue surfaced in review: the **print** icon is omnipresent (release + every medium) but rarely used, taking up valuable primary-action space.
|
||||
|
||||
The accepted UX direction (Option A, see `backlog/ml-142/mockups.html`) is a three-tier action hierarchy:
|
||||
|
||||
- **Header → whole release** (primary action, solid button)
|
||||
- **Per-medium → that disc** (soft shortcut, always enabled)
|
||||
- **Sticky footer → selected tracks** (appears only when tracks are ticked)
|
||||
|
||||
A single "Finished at" picker in the header drives all three, defaulting to "Now" (nil) until changed. Print moves into a `⋯` overflow menu on both the release header and each medium header.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- `lib/music_library_web/components/release.ex` — the LiveComponent and the shared `.medium/1` / `.track_list/1` function components it exports.
|
||||
- `lib/music_library_web/live/scrobble_live/show.ex` — the standalone scrobble page, which imports `.medium/1`; behaviour must stay coherent with the new component contract (the selection-blocks-medium-scrobble bug is removed there too).
|
||||
- New user-facing strings wrapped in `gettext`; `.pot`/`.po` regenerated.
|
||||
- Tests: new coverage for the Release LiveComponent's event handlers and the sticky-bar rendering, plus an updated handler test for `ScrobbleLive.Show` confirming medium-scrobble now works with tracks selected.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- `MusicLibrary.ScrobbleActivity` — no context changes. All three functions already take a `DateTime`.
|
||||
- A full sticky-bar + picker port to `ScrobbleLive.Show`. That page uses its own layout (not the sheet) and can be iterated on separately once the LiveComponent pattern is settled.
|
||||
- Changes to `ScrobbleComponents` function module — it isn't used by the Release sheet.
|
||||
- No database, migration, Oban, or JS-hook work.
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Header restructure (`release.ex:89-164`)
|
||||
|
||||
Replace the current flex row ("Tracks" label + print + scrobble + auth) with a two-block structure that reflows on narrow viewports:
|
||||
|
||||
```
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
{release_title}
|
||||
{release_subtitle} (# of mediums · year)
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<.date_time_picker_pill ... />
|
||||
<.button variant="solid" phx-click="scrobble_release" ...>
|
||||
▶ Scrobble release
|
||||
</.button>
|
||||
<.dropdown>
|
||||
<.button variant="outline" size="sm">⋯</.button>
|
||||
<:item><.dropdown_link phx-click="print_tracklist">Print tracklist</.dropdown_link></:item>
|
||||
<:item :if={!@can_scrobble?}><.dropdown_link href={LastFm.auth_url()}>Connect Last.fm</.dropdown_link></:item>
|
||||
</.dropdown>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- Drop the existing single-medium "Tracks" master checkbox (it duplicates the medium-level checkbox that's always rendered now).
|
||||
- Mobile (`< sm:`): the wrapping grid collapses so the title row holds title + `⋯`, and the second row holds the picker (flex-1) + "Scrobble release" (hug contents).
|
||||
- The "Scrobble release" button is `variant="solid"` (Fluxon's primary colour) — this is the contrast fix mandated by AC#2 on the primary flow.
|
||||
|
||||
### 2. `.medium/1` function component (`release.ex:221-283`)
|
||||
|
||||
Three changes:
|
||||
|
||||
- **Remove the selection-based disable** on the scrobble button (`release.ex:260`): `disabled={@already_scrobbled || MapSet.size(@selected_tracks) > 0}` → `disabled={@already_scrobbled}`. This is the AC#3 unblock.
|
||||
- **Add a label** to the scrobble button ("Scrobble disc" / "Scrobble side" via `medium_scrobble_label/1`, which already exists). Keep `variant="soft"`, `color="primary"` — visually secondary to the release button but with colour anchoring it as "this is scrobbling".
|
||||
- **Replace the separate print button** with a `.dropdown` trigger identical in shape to the header's `⋯` menu; print lives inside. Keeps one row per medium without losing the feature.
|
||||
|
||||
The function-component signature stays backward-compatible: the existing `myself` / `already_scrobbled` / `selected_tracks` attrs are retained. `ScrobbleLive.Show` renders this component unchanged, so the fix propagates there automatically.
|
||||
|
||||
### 3. Sticky selection bar (new, inside `release.ex` render)
|
||||
|
||||
Rendered at the bottom of the sheet body, conditional on `MapSet.size(@selected_tracks) > 0`:
|
||||
|
||||
```
|
||||
<div class="sticky bottom-0 bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-700
|
||||
shadow-[0_-4px_14px_rgba(0,0,0,0.06)] px-4 py-2 flex items-center justify-between gap-3">
|
||||
<div class="flex flex-col leading-tight">
|
||||
<span class="text-sm font-semibold">{count} tracks selected</span>
|
||||
<span class="text-xs text-zinc-500 dark:text-zinc-400">across {medium_count} discs · {duration}</span>
|
||||
</div>
|
||||
<.button variant="solid" phx-click="scrobble_selected_tracks" ...>
|
||||
▶ Scrobble selected
|
||||
</.button>
|
||||
</div>
|
||||
```
|
||||
|
||||
Helpers (private, in `release.ex`):
|
||||
|
||||
- `selected_tracks_summary/2` — takes `%{release, tracks: [...]}`and `MapSet` → returns `{count, medium_count, total_duration_ms}`. Reuses the existing `Release.tracks/1` flattening.
|
||||
- Duration formatted via the existing `MusicLibraryWeb.Duration.format_milliseconds/1`.
|
||||
|
||||
The sticky element sits inside the scrollable sheet body so it pins to the bottom of the visible area, not the viewport.
|
||||
|
||||
### 4. `finished_at` wiring
|
||||
|
||||
Add a `finished_at` field to the form data in `release.ex`:
|
||||
|
||||
- `mount/1`: initial form is `to_form(%{"selected_tracks" => [], "toggle_medium" => [], "finished_at" => nil})`.
|
||||
- `handle_event("validate", params, socket)` (already exists, lines 408-423): extend `apply_form_params/3` to parse `params["release"]["finished_at"]` into `DateTime.t() | nil` (ISO8601 via `DateTime.from_iso8601/1`, nil on blank or parse failure) and assign under `:finished_at`.
|
||||
- The three scrobble handlers (`scrobble_release`, `scrobble_medium`, `scrobble_selected_tracks`) resolve the timestamp at call time:
|
||||
|
||||
```elixir
|
||||
finished_at = socket.assigns.finished_at || DateTime.utc_now()
|
||||
```
|
||||
|
||||
Then pass it as the existing `:finished_at` argument to `ScrobbleActivity.*`.
|
||||
|
||||
Rendering the control: use Fluxon's `<.date_time_picker field={@form[:finished_at]} label={gettext("Finished at")} display_format="%b %-d, %H:%M" />`, already used in `record_form.ex:57-63`. When the field is nil, the picker shows its placeholder; rendering "Now" as the displayed value when `@finished_at` is nil is done via a small wrapper (`finished_at_display/1`) rather than the picker internals, so the behaviour is obvious in the template.
|
||||
|
||||
A **Reset to now** affordance: if Fluxon's picker exposes a clear action (likely via its popover footer — confirm in browser), use it; otherwise mimic pattern used in `MusicLibraryWeb.StatsLive.Index` for the `on_this_day/1` component, which adds a button next to the picker that pushes a `"clear_finished_at"` event which `assign(:finished_at, nil)` and updates the form.
|
||||
|
||||
### 5. Event handlers — summary of changes
|
||||
|
||||
| Event | Before | After |
|
||||
|---|---|---|
|
||||
| `scrobble_release` | `DateTime.utc_now()` hardcoded | `@finished_at || DateTime.utc_now()` |
|
||||
| `scrobble_medium` | disabled when selection present; always `utc_now()` | always enabled; uses `@finished_at || utc_now()` |
|
||||
| `scrobble_selected_tracks` | `DateTime.utc_now()` hardcoded | `@finished_at || DateTime.utc_now()` |
|
||||
| `clear_finished_at` | — | new; resets form field + assign |
|
||||
| `validate` | parses `selected_tracks` + `toggle_medium` | also parses `finished_at` |
|
||||
|
||||
The `already_scrobbled` 3-second lockout stays as-is.
|
||||
|
||||
### 6. `ScrobbleLive.Show` — minimal updates
|
||||
|
||||
`lib/music_library_web/live/scrobble_live/show.ex`:
|
||||
|
||||
- The imported `.medium/1` renders the new "Scrobble disc" label + `⋯` dropdown; since this page has its own print handlers already (`"print_medium_tracklist"`), wire the dropdown `phx-click` to those.
|
||||
- `handle_event("scrobble_medium", ...)` loses no behaviour — it already doesn't gate on selection (the guard was only in the component). Verify and add a regression test.
|
||||
- No picker, no sticky bar added in this pass — those stay scoped to the LiveComponent. The page continues to use `DateTime.utc_now()`.
|
||||
|
||||
### 7. Gettext
|
||||
|
||||
New strings (all under existing `MusicLibraryWeb.Gettext`):
|
||||
|
||||
- `"Finished at"`, `"Now"`, `"Reset to now"`
|
||||
- `"Scrobble release"`, `"Scrobble disc"`, `"Scrobble selected"`
|
||||
- ngettext: `"%{count} track selected"` / `"%{count} tracks selected"`
|
||||
- `"across %{count} discs"` — may already exist; reuse if so
|
||||
- `"More actions"` — for the `⋯` button's screen-reader label
|
||||
|
||||
Run `mix gettext.extract --merge` after implementation.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `lib/music_library_web/components/release.ex` — the primary change (render, handlers, helpers).
|
||||
- `lib/music_library_web/live/scrobble_live/show.ex` — dropdown wiring for print; regression test for medium-scrobble-with-selection.
|
||||
- `test/music_library_web/components/release_test.exs` — **new file**. Covers:
|
||||
- Picker: default "Now" render; setting a value persists; `clear_finished_at` resets to nil.
|
||||
- Sticky bar: hidden when no selection; visible with accurate count/duration when tracks ticked.
|
||||
- `scrobble_release`, `scrobble_medium`, `scrobble_selected_tracks` each respect the picker value (assert the arg passed to `ScrobbleActivity` via a `Mox`-style expect or by stubbing `LastFm` via `Req.Test` and asserting on the scrobbled timestamps).
|
||||
- Medium button is clickable (not disabled) when cross-medium selection is present.
|
||||
- `test/music_library_web/live/scrobble_live/show_test.exs` — update / add regression asserting `scrobble_medium` works with selection.
|
||||
- `priv/gettext/*.pot`, `priv/gettext/*/LC_MESSAGES/*.po` — regenerated by `mix gettext.extract --merge`.
|
||||
|
||||
No other files expected. Confirmed: `scrobble_components.ex` is not used by the Release sheet.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `mise run dev:precommit` — format, Credo, Sobelow, ExSlop, gettext check, tests. Must pass.
|
||||
2. Manual browser checks with the dev server running. Required for UI work per project convention:
|
||||
- **Desktop (~1200px, light + dark mode):** open a 2-CD release in Collection. Header shows picker + "Scrobble release" + `⋯`. Per-medium rows show "Scrobble disc" + `⋯`. Print lives only inside `⋯` menus. Contrast: solid release button is obviously different from disabled state (3-second lockout after scrobble).
|
||||
- **Mobile (360px):** header stacks — title+⋯ row, then picker+"Release" button row. Per-medium button collapses to icon-only. Sticky bar still readable (no horizontal overflow).
|
||||
- **Cross-medium scrobble:** tick one track on disc 1, one on disc 2 → sticky bar appears "2 tracks selected across 2 discs · 10:45" → click "Scrobble selected" → toast, sticky bar disappears.
|
||||
- **Per-medium scrobble with selection present:** tick a track on disc 1 → click "Scrobble disc" on disc 2 → disc 2 scrobbles (unaffected by selection). Verify via `/dev/oban` or Last.fm.
|
||||
- **Custom finished_at:** pick a time yesterday → click "Scrobble release" → verify the scrobbled timestamp in Last.fm matches the picked time (minus release duration for started_at).
|
||||
- **Reset to now:** set a custom time → click reset → picker shows "Now" → scrobble uses `utc_now()`.
|
||||
3. `mix test test/music_library_web/components/release_test.exs` — new LiveComponent tests pass.
|
||||
4. `mix test test/music_library_web/live/scrobble_live/show_test.exs` — regression test for medium-scrobble-with-selection passes.
|
||||
5. `grep -n "MapSet.size(@selected_tracks) > 0" lib/music_library_web/components/release.ex` — should return nothing except the sticky-bar visibility guard (which is intended).
|
||||
|
||||
## Open items deferred to implementation
|
||||
|
||||
- Exact Tailwind classes for the sticky bar shadow/padding — start with the mockup values, tune in the browser.
|
||||
- Pluralisation of "across N discs" — if the release is single-medium, the subtitle should drop the "across 1 discs" clause and show just the duration. Small conditional in `selected_tracks_summary/2`.
|
||||
- Whether to show the picker on releases where `@can_scrobble?` is false. Recommend hiding it in that case (no point picking a time if you can't submit) — to confirm in review.
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: ML-142
|
||||
title: Improve scrobble UI in the Release component
|
||||
status: To Do
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-04-20 09:32'
|
||||
updated_date: '2026-04-20 09:35'
|
||||
updated_date: '2026-04-22 12:42'
|
||||
labels:
|
||||
- ui
|
||||
- scrobble
|
||||
@@ -12,7 +12,13 @@ dependencies: []
|
||||
references:
|
||||
- lib/music_library_web/live/components/release.ex
|
||||
- lib/music_library_web/components/scrobble_components.ex
|
||||
- backlog/ml-142/mockups.html
|
||||
- backlog/ml-142/plan.md
|
||||
documentation:
|
||||
- backlog/ml-142/plan.md
|
||||
- backlog/ml-142/mockups.html
|
||||
priority: medium
|
||||
ordinal: 1000
|
||||
---
|
||||
|
||||
## Description
|
||||
@@ -23,7 +29,20 @@ The Release component's scrobble interface has several usability gaps that make
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Users can set a custom `finished_at` datetime before scrobbling, defaulting to the current time when not set
|
||||
- [ ] #2 Scrobble buttons have sufficient contrast in both their enabled and disabled states so users can clearly distinguish between the two
|
||||
- [ ] #3 Enable scrobbling of selected tracks from the closest scrabble button (i.e. the medium button) instead of always having to scrobble to the top one.
|
||||
- [ ] #1 The release-sheet header renders a `Finished at` date/time picker that displays 'Now' when unset and an explicit time when set
|
||||
- [ ] #2 The release-sheet header renders a solid-primary `Scrobble release` button that scrobbles the whole release using the picker value or `DateTime.utc_now()` when unset
|
||||
- [ ] #3 The release-sheet header renders a `⋯` overflow menu containing `Print tracklist`, plus `Connect Last.fm` when the session key is missing
|
||||
- [ ] #4 The duplicate top-level `Tracks` master checkbox shown on single-medium releases is removed
|
||||
- [ ] #5 Each medium header renders a soft-primary `Scrobble disc` button that is enabled regardless of cross-medium track selection
|
||||
- [ ] #6 Each medium header renders a `⋯` overflow menu containing `Print tracklist` for that medium
|
||||
- [ ] #7 A sticky bar appears at the bottom of the sheet body whenever `MapSet.size(@selected_tracks) > 0`, showing track count, medium count, aggregate duration, and a `Scrobble selected` button
|
||||
- [ ] #8 The sticky bar is not rendered when no tracks are selected
|
||||
- [ ] #9 `Scrobble selected` scrobbles only the ticked tracks using the picker value or `DateTime.utc_now()` when unset
|
||||
- [ ] #10 Clicking a per-medium `Scrobble disc` submits that medium regardless of selection elsewhere, using the picker value or `DateTime.utc_now()`
|
||||
- [ ] #11 The picker has a reset affordance that clears the value back to 'Now'
|
||||
- [ ] #12 Disabled scrobble buttons are visibly distinct from enabled ones in both light and dark mode (verified in browser)
|
||||
- [ ] #13 On viewports ≤ 380px the header reflows: title row above a second row containing the picker (flex-1) and the `Scrobble release` button; per-medium scrobble collapses to icon-only; sticky bar stays legible
|
||||
- [ ] #14 `ScrobbleLive.Show` per-medium scrobble handler works with tracks selected (regression test)
|
||||
- [ ] #15 All new user-facing strings wrapped in gettext; `.pot`/`.po` files regenerated via `mix gettext.extract --merge`
|
||||
- [ ] #16 New LiveComponent tests cover: default picker state, picker value propagating to all three scrobble handlers, sticky-bar rendering, and medium-button enabled-with-selection
|
||||
<!-- AC:END -->
|
||||
|
||||
Reference in New Issue
Block a user