Skip to content

Commit 3178338

Browse files
feat(taste): bold taste-card redesign + compact pill download button (#383)
* feat(taste): bold taste-card redesign + compact pill download button Redesign the shareable taste fingerprint card (api/taste_card.py) and the "Download Taste Card" button. Card (api/taste_card.py): - Larger, social-share aspect (1200x630). - Gradient header band with the title and an obscurity ring/gauge. - Ranked accent bars for top genres and top labels (length proportional to count; count shown). render_taste_card now takes (name, count) tuples. - Taste drift rendered as a sparkline with year labels and per-point genre tooltips, plus a "Now: <genre>" caption. - discogsography wordmark. - Graceful empty states; all user strings escaped (XSS-safe). Router (api/routers/taste.py): pass genre/label counts through to the card. Button (.taste-download-btn): compact centered gradient pill with hover lift + glow and an active/disabled state, replacing the flat full-width bar. Tests updated for the new signature, ring, sparkline, branding, and drift XSS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(taste): obscurity as a mainstream→obscure scale (card + Explore strip) Replace the bare obscurity percentage with a labeled gradient scale and a glowing marker, shown identically on the taste card and the Explore taste strip. Tiers (inclusive upper bound): Mainstream / Eclectic / Obscure / Deep Cuts at 0 / 0.25 / 0.5 / 0.75, with the percentage shown. - api/taste_card.py: swap the obscurity ring for a full-width gradient track (sky→indigo→purple→rose) with a glowing marker at the score, tier+% caption, and Mainstream/Obscure endpoint labels; reflow the card around it. - explore/static/js/user-panes.js: new _obscurityScale / _obscurityTier render the same component in the strip (replacing the plain 0.00 stat). - explore/static/css/styles.css: .taste-obscurity{,-track,-marker,-ends} styles. - Tests: updated card + strip tests for the tier/percentage/marker output. Tier thresholds are duplicated in Python and JS and annotated to stay in sync. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8d2a32d commit 3178338

6 files changed

Lines changed: 350 additions & 84 deletions

File tree

api/routers/taste.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,12 @@ async def taste_card(
172172
for c in cells:
173173
g = c["genre"]
174174
genre_counts[g] = genre_counts.get(g, 0) + c.get("count", 1)
175-
top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:5] # type: ignore[arg-type]
175+
top_genres = sorted(genre_counts.items(), key=lambda kv: kv[1], reverse=True)[:5]
176176
svg = render_taste_card(
177177
peak_decade=_peak_decade(cells),
178178
obscurity_score=obscurity_result["score"],
179179
top_genres=top_genres,
180-
top_labels=[lb["label"] for lb in labels_result],
180+
top_labels=[(lb["label"], lb["count"]) for lb in labels_result],
181181
drift=[TasteDriftYear(**d) for d in drift_result],
182182
)
183183
return Response(content=svg, media_type="image/svg+xml", headers={"Cache-Control": "no-store"})

api/taste_card.py

Lines changed: 168 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""SVG taste card generator.
22
3-
Produces a 600x400 SVG summarising a user's taste fingerprint.
3+
Produces a 1200x630 (social-share aspect) SVG summarising a user's taste
4+
fingerprint: an obscurity scale, ranked genre/label bars, and a drift sparkline.
45
All user-supplied strings are escaped via ``html.escape`` to prevent XSS.
56
"""
67

@@ -9,56 +10,175 @@
910
from api.models import TasteDriftYear
1011

1112

13+
# Card geometry
14+
_W = 1200
15+
_H = 630
16+
17+
# Palette (purple/violet on deep navy)
18+
_BG_TOP = "#1c1c2e"
19+
_BG_BOTTOM = "#101019"
20+
_HDR_FROM = "#7c3aed"
21+
_HDR_TO = "#a855f7"
22+
_ACCENT_FROM = "#6c5ce7"
23+
_ACCENT_TO = "#a855f7"
24+
_TEXT = "#f5f5fa"
25+
_MUTED = "#8b8ba7"
26+
_FAINT = "#6b6b85"
27+
28+
# Obscurity tiers (mainstream -> obscure). Boundary is inclusive on the upper
29+
# tier (``score >= threshold``), matching the project's tier convention.
30+
# Keep in sync with the Explore strip (explore/static/js/user-panes.js).
31+
_OBSCURITY_TIERS = [
32+
(0.75, "Deep Cuts", "#f43f5e"),
33+
(0.50, "Obscure", "#a855f7"),
34+
(0.25, "Eclectic", "#818cf8"),
35+
(0.0, "Mainstream", "#38bdf8"),
36+
]
37+
38+
39+
def _esc(value: str) -> str:
40+
return html.escape(value)
41+
42+
43+
def _obscurity_tier(score: float) -> tuple[str, str]:
44+
"""Return the (name, color) tier for an obscurity score in [0, 1]."""
45+
for threshold, name, color in _OBSCURITY_TIERS:
46+
if score >= threshold:
47+
return name, color
48+
return _OBSCURITY_TIERS[-1][1], _OBSCURITY_TIERS[-1][2]
49+
50+
51+
def _obscurity_scale(score: float, x: int, y: int, w: int) -> str:
52+
"""A mainstream->obscure gradient track with a glowing marker at ``score``."""
53+
s = max(0.0, min(1.0, score))
54+
name, color = _obscurity_tier(s)
55+
pct = f"{score:.0%}"
56+
mx = x + s * w
57+
track_y = y + 15
58+
h = 12
59+
dot_cy = track_y + h / 2
60+
return "\n ".join(
61+
[
62+
f'<text x="{x}" y="{y}" font-size="16" font-weight="bold" fill="{_MUTED}" letter-spacing="2">OBSCURITY</text>',
63+
f'<text x="{x + w}" y="{y}" font-size="18" font-weight="bold" fill="{color}" text-anchor="end">{_esc(name)} · {pct}</text>',
64+
f'<rect x="{x}" y="{track_y}" width="{w}" height="{h}" rx="6" fill="url(#obscGrad)"/>',
65+
f'<circle cx="{mx:.1f}" cy="{dot_cy:.1f}" r="16" fill="{color}" opacity="0.35"/>',
66+
f'<circle cx="{mx:.1f}" cy="{dot_cy:.1f}" r="9" fill="#fff" stroke="{color}" stroke-width="3"/>',
67+
f'<text x="{x}" y="{track_y + h + 22}" font-size="13" fill="{_FAINT}">Mainstream</text>',
68+
f'<text x="{x + w}" y="{track_y + h + 22}" font-size="13" fill="{_FAINT}" text-anchor="end">Obscure</text>',
69+
]
70+
)
71+
72+
73+
def _ranked_bars(items: list[tuple[str, int]], x0: int, y_start: int, col_w: int, row_h: int) -> str:
74+
"""A vertical list of ranked horizontal bars (length proportional to count)."""
75+
if not items:
76+
return f'<text x="{x0}" y="{y_start + 4}" font-size="17" fill="{_FAINT}">No data yet</text>'
77+
max_count = max((c for _, c in items), default=0) or 1
78+
parts: list[str] = []
79+
for i, (name, count) in enumerate(items[:5]):
80+
y = y_start + i * row_h
81+
bar_w = max(6, round(col_w * (count / max_count)))
82+
parts.append(f'<text x="{x0}" y="{y}" font-size="21" fill="{_TEXT}">{_esc(name)}</text>')
83+
parts.append(f'<text x="{x0 + col_w}" y="{y}" font-size="15" fill="{_MUTED}" text-anchor="end">{count:,}</text>')
84+
parts.append(f'<rect x="{x0}" y="{y + 11}" width="{col_w}" height="9" rx="4.5" fill="#ffffff14"/>')
85+
parts.append(f'<rect x="{x0}" y="{y + 11}" width="{bar_w}" height="9" rx="4.5" fill="url(#barGrad)"/>')
86+
return "\n ".join(parts)
87+
88+
89+
def _drift_sparkline(drift: list[TasteDriftYear], x: int, y: int, w: int, h: int) -> str:
90+
"""A small line chart of additions-per-year, with year labels and genre tooltips."""
91+
pts = drift[-8:]
92+
if not pts:
93+
return f'<text x="{x}" y="{y + h // 2}" font-size="17" fill="{_FAINT}">No drift data yet</text>'
94+
max_count = max((d.count for d in pts), default=0) or 1
95+
n = len(pts)
96+
step = w / (n - 1) if n > 1 else 0.0
97+
coords: list[tuple[float, float, TasteDriftYear]] = []
98+
for i, d in enumerate(pts):
99+
px = x + i * step if n > 1 else x + w / 2
100+
py = y + h - (d.count / max_count) * h
101+
coords.append((px, py, d))
102+
103+
parts: list[str] = []
104+
if n > 1:
105+
line = " ".join(f"{px:.1f},{py:.1f}" for px, py, _ in coords)
106+
parts.append(f'<polyline points="{line}" fill="none" stroke="url(#barGrad)" stroke-width="3" stroke-linejoin="round"/>')
107+
for idx, (px, py, d) in enumerate(coords):
108+
last = idx == n - 1
109+
parts.append(
110+
f'<circle cx="{px:.1f}" cy="{py:.1f}" r="{5 if last else 4}" fill="{_ACCENT_TO if last else _ACCENT_FROM}">'
111+
f"<title>{_esc(d.year)}: {_esc(d.top_genre)} ({d.count:,})</title></circle>"
112+
)
113+
parts.append(f'<text x="{px:.1f}" y="{y + h + 20}" font-size="13" fill="{_MUTED}" text-anchor="middle">{_esc(d.year)}</text>')
114+
# Caption: the most recent year's top genre.
115+
parts.append(f'<text x="{x}" y="{y - 14}" font-size="15" fill="{_FAINT}">Now: {_esc(coords[-1][2].top_genre)}</text>')
116+
return "\n ".join(parts)
117+
118+
12119
def render_taste_card(
13120
*,
14121
peak_decade: int | None,
15122
obscurity_score: float,
16-
top_genres: list[str],
17-
top_labels: list[str],
123+
top_genres: list[tuple[str, int]],
124+
top_labels: list[tuple[str, int]],
18125
drift: list[TasteDriftYear],
19126
) -> str:
20-
"""Return a 600x400 SVG string summarising the user's taste fingerprint."""
21-
decade_text = f"{html.escape(str(peak_decade))}s" if peak_decade is not None else "N/A"
22-
bar_width = max(4, min(200, int(obscurity_score * 200)))
23-
24-
genre_lines = ""
25-
for i, genre in enumerate(top_genres[:5]):
26-
y = 155 + i * 20
27-
genre_lines += f' <text x="30" y="{y}" font-size="13" fill="#ccc">{html.escape(genre)}</text>\n'
28-
29-
label_lines = ""
30-
for i, label in enumerate(top_labels[:5]):
31-
y = 155 + i * 20
32-
label_lines += f' <text x="320" y="{y}" font-size="13" fill="#ccc">{html.escape(label)}</text>\n'
33-
34-
drift_lines = ""
35-
for i, d in enumerate(drift[-5:]):
36-
y = 320 + i * 18
37-
drift_lines += f' <text x="30" y="{y}" font-size="12" fill="#aaa">{html.escape(d.year)}: {html.escape(d.top_genre)}</text>\n'
38-
39-
svg = f"""\
40-
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400" viewBox="0 0 600 400">
41-
<rect width="600" height="400" rx="12" fill="#1a1a2e"/>
42-
<text x="300" y="35" font-size="18" font-weight="bold" fill="#e0e0e0" text-anchor="middle">Taste Fingerprint</text>
43-
44-
<!-- Peak Decade -->
45-
<text x="30" y="70" font-size="14" fill="#888">Peak Decade</text>
46-
<text x="30" y="92" font-size="22" font-weight="bold" fill="#fff">{decade_text}</text>
47-
48-
<!-- Obscurity -->
49-
<text x="320" y="70" font-size="14" fill="#888">Obscurity</text>
50-
<rect x="320" y="78" width="200" height="16" rx="4" fill="#333"/>
51-
<rect x="320" y="78" width="{bar_width}" height="16" rx="4" fill="#6c5ce7"/>
52-
<text x="530" y="91" font-size="12" fill="#ccc">{obscurity_score:.0%}</text>
53-
54-
<!-- Top Genres -->
55-
<text x="30" y="130" font-size="14" fill="#888">Top Genres</text>
56-
{genre_lines}
57-
<!-- Top Labels -->
58-
<text x="320" y="130" font-size="14" fill="#888">Top Labels</text>
59-
{label_lines}
60-
<!-- Drift -->
61-
<text x="30" y="300" font-size="14" fill="#888">Taste Drift</text>
62-
{drift_lines}</svg>"""
63-
64-
return svg
127+
"""Return a 1200x630 SVG string summarising the user's taste fingerprint."""
128+
decade_text = f"{_esc(str(peak_decade))}s" if peak_decade is not None else "N/A"
129+
130+
obscurity = _obscurity_scale(obscurity_score, x=50, y=190, w=1100)
131+
genre_bars = _ranked_bars(top_genres, x0=50, y_start=330, col_w=480, row_h=48)
132+
label_bars = _ranked_bars(top_labels, x0=640, y_start=330, col_w=480, row_h=48)
133+
sparkline = _drift_sparkline(drift, x=360, y=584, w=560, h=26)
134+
135+
return f"""\
136+
<svg xmlns="http://www.w3.org/2000/svg" width="{_W}" height="{_H}" viewBox="0 0 {_W} {_H}">
137+
<defs>
138+
<linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1">
139+
<stop offset="0" stop-color="{_BG_TOP}"/>
140+
<stop offset="1" stop-color="{_BG_BOTTOM}"/>
141+
</linearGradient>
142+
<linearGradient id="hdrGrad" x1="0" y1="0" x2="1" y2="0">
143+
<stop offset="0" stop-color="{_HDR_FROM}"/>
144+
<stop offset="1" stop-color="{_HDR_TO}"/>
145+
</linearGradient>
146+
<linearGradient id="barGrad" x1="0" y1="0" x2="1" y2="0">
147+
<stop offset="0" stop-color="{_ACCENT_FROM}"/>
148+
<stop offset="1" stop-color="{_ACCENT_TO}"/>
149+
</linearGradient>
150+
<linearGradient id="obscGrad" x1="0" y1="0" x2="1" y2="0">
151+
<stop offset="0" stop-color="#38bdf8"/>
152+
<stop offset="0.33" stop-color="#818cf8"/>
153+
<stop offset="0.66" stop-color="#a855f7"/>
154+
<stop offset="1" stop-color="#f43f5e"/>
155+
</linearGradient>
156+
<clipPath id="card"><rect x="0" y="0" width="{_W}" height="{_H}" rx="28"/></clipPath>
157+
</defs>
158+
159+
<g clip-path="url(#card)">
160+
<rect x="0" y="0" width="{_W}" height="{_H}" fill="url(#bgGrad)"/>
161+
<rect x="0" y="0" width="{_W}" height="150" fill="url(#hdrGrad)"/>
162+
</g>
163+
164+
<!-- Header -->
165+
<text x="50" y="70" font-size="40" font-weight="bold" fill="#ffffff" letter-spacing="1">TASTE FINGERPRINT</text>
166+
<text x="50" y="108" font-size="20" fill="#ffffffcc">Peak decade <tspan font-weight="bold" fill="#ffffff">{decade_text}</tspan></text>
167+
168+
<!-- Obscurity scale -->
169+
{obscurity}
170+
171+
<!-- Section headers -->
172+
<text x="50" y="300" font-size="18" font-weight="bold" fill="{_MUTED}" letter-spacing="2">TOP GENRES</text>
173+
<text x="640" y="300" font-size="18" font-weight="bold" fill="{_MUTED}" letter-spacing="2">TOP LABELS</text>
174+
175+
<!-- Ranked bars -->
176+
{genre_bars}
177+
{label_bars}
178+
179+
<!-- Footer band: drift sparkline + branding -->
180+
<line x1="50" y1="556" x2="1150" y2="556" stroke="#ffffff14" stroke-width="1"/>
181+
<text x="50" y="586" font-size="14" font-weight="bold" fill="{_MUTED}" letter-spacing="2">TASTE DRIFT</text>
182+
{sparkline}
183+
<text x="1150" y="604" font-size="20" font-weight="bold" fill="url(#barGrad)" text-anchor="end">discogsography</text>
184+
</svg>"""

explore/__tests__/user-panes.test.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,7 +1371,7 @@ describe('UserPanes', () => {
13711371
expect(cols).toHaveLength(3);
13721372
});
13731373

1374-
it('should render obscurity score', () => {
1374+
it('should render obscurity score as a tier + percentage scale', () => {
13751375
const strip = document.getElementById('tasteStrip');
13761376
userPanes._renderTasteStrip({
13771377
obscurity: { score: 0.42 },
@@ -1381,7 +1381,13 @@ describe('UserPanes', () => {
13811381
blind_spots: [],
13821382
});
13831383

1384-
expect(strip.textContent).toContain('0.42');
1384+
// 0.42 → "Eclectic" tier (0.25–0.5), shown with the percentage.
1385+
expect(strip.textContent).toContain('Eclectic');
1386+
expect(strip.textContent).toContain('42%');
1387+
expect(strip.querySelector('.taste-obscurity-track')).not.toBeNull();
1388+
const marker = strip.querySelector('.taste-obscurity-marker');
1389+
expect(marker).not.toBeNull();
1390+
expect(marker.style.left).toBe('42%');
13851391
});
13861392

13871393
it('should render dash for null obscurity', () => {

explore/static/css/styles.css

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1843,6 +1843,55 @@ select option {
18431843
.taste-stat-value.blue { color: var(--blue-accent); }
18441844
.taste-stat-value.green { color: var(--accent-green); }
18451845

1846+
/* Obscurity scale: mainstream → obscure gradient with a marker. */
1847+
.taste-obscurity {
1848+
display: flex;
1849+
flex-direction: column;
1850+
gap: 5px;
1851+
margin-bottom: 10px;
1852+
}
1853+
1854+
.taste-obscurity-head {
1855+
display: flex;
1856+
justify-content: space-between;
1857+
align-items: baseline;
1858+
}
1859+
1860+
.taste-obscurity-label {
1861+
font-size: 12px;
1862+
color: var(--text-mid);
1863+
}
1864+
1865+
.taste-obscurity-value {
1866+
font-size: 12px;
1867+
font-weight: 700;
1868+
}
1869+
1870+
.taste-obscurity-track {
1871+
position: relative;
1872+
height: 8px;
1873+
border-radius: 4px;
1874+
background: linear-gradient(90deg, #38bdf8 0%, #818cf8 33%, #a855f7 66%, #f43f5e 100%);
1875+
}
1876+
1877+
.taste-obscurity-marker {
1878+
position: absolute;
1879+
top: 50%;
1880+
width: 12px;
1881+
height: 12px;
1882+
border: 2px solid #fff;
1883+
border-radius: 50%;
1884+
background: #fff;
1885+
transform: translate(-50%, -50%);
1886+
}
1887+
1888+
.taste-obscurity-ends {
1889+
display: flex;
1890+
justify-content: space-between;
1891+
font-size: 9px;
1892+
color: var(--text-dim);
1893+
}
1894+
18461895
.taste-heatmap-grid {
18471896
display: grid;
18481897
gap: 2px;
@@ -1892,24 +1941,41 @@ select option {
18921941

18931942
.taste-download-btn {
18941943
margin-top: auto;
1895-
padding: 6px 12px;
1896-
background: var(--purple-accent);
1944+
align-self: center;
1945+
display: inline-flex;
1946+
align-items: center;
1947+
gap: 6px;
1948+
padding: 9px 20px;
1949+
background: linear-gradient(135deg, #6c5ce7, #a855f7);
18971950
color: #fff;
18981951
border: none;
1899-
border-radius: 4px;
1900-
font-size: 11px;
1952+
border-radius: 999px;
1953+
font-size: 12px;
1954+
font-weight: 600;
1955+
letter-spacing: 0.3px;
1956+
white-space: nowrap;
19011957
cursor: pointer;
1902-
text-align: center;
1903-
transition: opacity 0.15s;
1958+
box-shadow: 0 2px 8px rgba(108, 92, 231, 0.35);
1959+
transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;
19041960
}
19051961

19061962
.taste-download-btn:hover {
1907-
opacity: 0.85;
1963+
transform: translateY(-1px);
1964+
filter: brightness(1.08);
1965+
box-shadow: 0 6px 18px rgba(108, 92, 231, 0.55);
1966+
}
1967+
1968+
.taste-download-btn:active {
1969+
transform: translateY(0);
1970+
box-shadow: 0 2px 8px rgba(108, 92, 231, 0.4);
19081971
}
19091972

19101973
.taste-download-btn:disabled {
19111974
opacity: 0.5;
19121975
cursor: not-allowed;
1976+
transform: none;
1977+
filter: none;
1978+
box-shadow: none;
19131979
}
19141980

19151981
.taste-empty {

0 commit comments

Comments
 (0)