3 Commits

Author SHA1 Message Date
ranger f796fe8223 chore: v0.4.0 — bump version + CHANGELOG entry
manifest.json version 0.3.0 → 0.4.0. CHANGELOG.md entry for v0.4.0
documents the first-run UX hint feature (merged in b82f14e via
v0.4.0-prep branch). Zero new permissions, zero new dependencies,
zero behaviour change for existing users.

Shipped the same day v0.3.0 went LIVE on Chrome Web Store
(~15.5h after submission). Same-account update review window is
~24-48h per the reference_chrome_web_store_rules memory.
2026-06-09 22:15:17 +01:00
ranger afaff271a4 Merge v0.4.0-prep — first-run UX hint 2026-06-09 22:14:15 +01:00
ranger b82f14ee7b feat(v0.4.0-prep): first-run UX hint — pulse + bouncing arrow
The 'pick a station to begin' state was too subtle on first install
(David's own 30-second panic moment when he uninstalled the dev build
and reinstalled from the Web Store, 2026-06-09 evening).

Two layered cues, both pure CSS driven by a body.is-first-run class:

1. Subtle accent-green glow pulses around:
   - popup: the station list section
   - newtab: the Quick Stations chip row
   (rgba alpha 0.18-0.25, 2.4s ease-in-out infinite — visible but not noisy)

2. Bouncing ↓ arrow appended to the 'Pick a station to begin' text in
   both surfaces (after-pseudo with translateY animation).

The is-first-run class is toggled in popup.js + newtab.js via a new
reflectFirstRunHint() function called from:
  - init() once stations + currentStation are resolved
  - onPickStation() the moment a user picks
  - the chrome.storage.onChanged listener when another surface picks
    (so the hint disappears on both surfaces simultaneously)

Existing users with stored currentStationId never see either cue —
the class only attaches when currentStation is null.

Working on branch v0.4.0-prep so the live main (= what shipped to
Web Store v0.3.0) is unchanged. Merge to main when ready to bump the
manifest version + readme.txt + CHANGELOG.md for v0.4.0 release.
2026-06-09 22:05:55 +01:00
6 changed files with 147 additions and 6 deletions
+54 -5
View File
@@ -7,11 +7,60 @@ Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — versi
## [Unreleased] ## [Unreleased]
### Planned — Tier 3 (Chrome Web Store submission) ### Planned — Tier 2 polish (next)
- Listing assets: 1280x800 screenshots, 440x280 promo tile - Light mode support (`prefers-color-scheme` + flipped palette across popup / NewTab / Options)
- Privacy policy URL published at `davidtkeane.com/rangerhq-tuner/privacy` - `.m3u` parser alongside `.pls` to widen future-adapter compatibility
- 2-Step Verification on Google account - Station artwork lazy-load + fallback to family helmet
- $5 dev fee + submission - Better error UI for failed streams ("Stream unavailable, try another")
- `.crx` packaging instructions in README for sideload users
---
## [0.4.0] — 2026-06-09 — First-run UX hint
First post-launch update, shipped the same day v0.3.0 went LIVE on the Chrome Web Store. Pure UX polish — zero new permissions, zero new code dependencies, zero behaviour change for existing users.
### Added — Discoverable "pick a station to begin" affordance
Triggered by David's own 30-second panic on first Web Store install (uninstalled the dev build, installed from the Web Store fresh, hit Play, got nothing, then realised "ah, I need to pick a station first"). The product worked correctly — the dev build had a seeded station from hours of testing, the fresh install does not — but the 30-second panic exposed a real first-run-UX gap.
**Two layered cues, both pure CSS driven by a `body.is-first-run` class:**
1. **Subtle accent-green glow pulses** around the station list (popup) and the Quick Stations chip row (NewTab). Uses a 2.4-second `box-shadow` keyframe at low alpha (0.18-0.25) — visible but not noisy.
2. **Bouncing ↓ arrow** appended to the "Pick a station to begin" text in both surfaces. Uses an `::after` pseudo-element with a 1.8-second `translateY` keyframe.
The `is-first-run` class is toggled by a tiny `reflectFirstRunHint()` function called from:
- `init()` once stations + `currentStation` are resolved
- `onPickStation()` the moment a user picks
- the `chrome.storage.onChanged` listener when another surface picks (so the hint disappears on both surfaces simultaneously via cross-surface sync)
Existing users with a stored `tuner.currentStationId` never see either cue — the class only attaches when `currentStation` is null.
### Files touched
- `src/popup/popup.css` (+35 lines — keyframes + `.is-first-run` rules)
- `src/popup/popup.js` (+11 lines — `reflectFirstRunHint()` + 3 call sites)
- `src/newtab/newtab.css` (+36 lines — same idea, NewTab-namespaced)
- `src/newtab/newtab.js` (+10 lines — same pattern)
Total: 4 files, +92 lines, 0 deletions.
### Not changed
- No new permissions
- No new host_permissions
- No new external libraries
- No change to `manifest.json` beyond the version bump and CHANGELOG-referenced URL
- No data migration required (no storage shape change)
### Same-day context
This update ships the same day:
- v0.3.0 went LIVE on the Chrome Web Store (~17:08 Dublin, ~15.5h after submission)
- RangerHQ Radio v1.0.0 stability milestone went LIVE on WordPress.org (~21:51 Dublin)
- David received a PhD-prep signal from his Research in Computing lecturer at NCI Dublin
A solid day.
--- ---
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "RangerHQ Tuner", "name": "RangerHQ Tuner",
"version": "0.3.0", "version": "0.4.0",
"description": "Lightweight SomaFM radio player. Logs what plays. One-click search to Spotify, YouTube, Apple Music, Bandcamp. No telemetry.", "description": "Lightweight SomaFM radio player. Logs what plays. One-click search to Spotify, YouTube, Apple Music, Bandcamp. No telemetry.",
"author": "David Keane", "author": "David Keane",
"homepage_url": "https://davidtkeane.com/rangerhq-tuner", "homepage_url": "https://davidtkeane.com/rangerhq-tuner",
+36
View File
@@ -585,3 +585,39 @@ body::before {
.nt-station { font-size: 22px; } .nt-station { font-size: 22px; }
.nt-station-list { max-height: 220px; } .nt-station-list { max-height: 220px; }
} }
/* ──────────────────────────────────────────────────────────────────────
First-run UX hint (v0.4.0)
Subtle accent-green pulse around the Quick Stations row + a bouncing
↓ arrow on the now-playing area. Both disappear the moment a station
is picked. Existing users never see them.
────────────────────────────────────────────────────────────────────── */
@keyframes nt-pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(109, 191, 122, 0); }
50% { box-shadow: 0 0 0 6px rgba(109, 191, 122, 0.18); }
}
@keyframes nt-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(4px); }
}
body.is-first-run .nt-quick {
animation: nt-pulse-glow 2.4s ease-in-out infinite;
border-radius: var(--radius);
padding: 14px;
margin: 0 -14px;
}
body.is-first-run .nt-track {
color: var(--accent);
font-weight: 500;
}
body.is-first-run .nt-track::after {
content: " ↓";
display: inline-block;
animation: nt-bounce 1.8s ease-in-out infinite;
margin-left: 6px;
}
+10
View File
@@ -108,6 +108,7 @@ async function init() {
const currentId = stored[STORAGE_KEYS.currentId]; const currentId = stored[STORAGE_KEYS.currentId];
currentStation = stations.find(s => s.id === currentId) || null; currentStation = stations.find(s => s.id === currentId) || null;
reflectFirstRunHint();
renderNow(); renderNow();
renderQuick(); renderQuick();
renderList(); renderList();
@@ -184,6 +185,7 @@ async function init() {
const newId = changes[STORAGE_KEYS.currentId].newValue; const newId = changes[STORAGE_KEYS.currentId].newValue;
currentStation = stations.find(s => s.id === newId) || null; currentStation = stations.find(s => s.id === newId) || null;
els.play.disabled = !currentStation; els.play.disabled = !currentStation;
reflectFirstRunHint();
renderNow(); renderNow();
renderQuick(); renderQuick();
renderList(); renderList();
@@ -317,6 +319,7 @@ function renderList() {
async function onPickStation(station) { async function onPickStation(station) {
currentStation = station; currentStation = station;
els.play.disabled = false; els.play.disabled = false;
reflectFirstRunHint();
await chrome.storage.local.set({ [STORAGE_KEYS.currentId]: station.id }); await chrome.storage.local.set({ [STORAGE_KEYS.currentId]: station.id });
renderNow(); renderNow();
renderQuick(); renderQuick();
@@ -364,6 +367,13 @@ async function onVolume() {
/* ---------- Helpers ---------- */ /* ---------- Helpers ---------- */
// v0.4.0 first-run UX hint — body class drives the pulse + arrow CSS in
// newtab.css. Active when no station has been picked yet; cleared the
// moment one is selected (here or via cross-surface storage sync).
function reflectFirstRunHint() {
document.body.classList.toggle('is-first-run', !currentStation);
}
function reflectPlayButton() { function reflectPlayButton() {
els.play.setAttribute('aria-pressed', playing ? 'true' : 'false'); els.play.setAttribute('aria-pressed', playing ? 'true' : 'false');
els.play.textContent = playing ? '❚❚ Pause' : '▶ Play'; els.play.textContent = playing ? '❚❚ Pause' : '▶ Play';
+35
View File
@@ -310,3 +310,38 @@ html, body {
.station-list::-webkit-scrollbar-track { .station-list::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
/* ──────────────────────────────────────────────────────────────────────
First-run UX hint (v0.4.0)
When body.is-first-run is set (no station picked), the station list
subtly pulses with an accent-green glow and the now-playing area gets
a bouncing ↓ arrow pointing the user at the list. Both removed the
moment a station is picked, so existing users never see them again.
────────────────────────────────────────────────────────────────────── */
@keyframes tuner-pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(109, 191, 122, 0); }
50% { box-shadow: 0 0 0 4px rgba(109, 191, 122, 0.25); }
}
@keyframes tuner-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
}
body.is-first-run .stations {
animation: tuner-pulse-glow 2.4s ease-in-out infinite;
border-radius: 6px;
}
body.is-first-run .np-track {
color: var(--accent);
font-weight: 500;
}
body.is-first-run .np-track::after {
content: " ↓";
display: inline-block;
animation: tuner-bounce 1.8s ease-in-out infinite;
margin-left: 4px;
}
+11
View File
@@ -69,6 +69,7 @@ async function init() {
// 3. Restore current station selection. // 3. Restore current station selection.
const currentId = stored[STORAGE_KEYS.currentId]; const currentId = stored[STORAGE_KEYS.currentId];
currentStation = stations.find(s => s.id === currentId) || null; currentStation = stations.find(s => s.id === currentId) || null;
reflectFirstRunHint();
renderNowPlaying(); renderNowPlaying();
// 4. Render station list. // 4. Render station list.
@@ -121,6 +122,7 @@ async function init() {
const newId = changes[STORAGE_KEYS.currentId].newValue; const newId = changes[STORAGE_KEYS.currentId].newValue;
currentStation = stations.find(s => s.id === newId) || null; currentStation = stations.find(s => s.id === newId) || null;
els.btnPlay.disabled = !currentStation; els.btnPlay.disabled = !currentStation;
reflectFirstRunHint();
renderNowPlaying(); renderNowPlaying();
renderStations(); renderStations();
} }
@@ -202,6 +204,7 @@ function renderNowPlaying() {
async function onPickStation(station) { async function onPickStation(station) {
currentStation = station; currentStation = station;
els.btnPlay.disabled = false; els.btnPlay.disabled = false;
reflectFirstRunHint();
await chrome.storage.local.set({ [STORAGE_KEYS.currentId]: station.id }); await chrome.storage.local.set({ [STORAGE_KEYS.currentId]: station.id });
renderNowPlaying(); renderNowPlaying();
renderStations(); renderStations();
@@ -256,6 +259,14 @@ function reflectPlayButton() {
els.btnPlay.textContent = playing ? '❚❚ Pause' : '▶ Play'; els.btnPlay.textContent = playing ? '❚❚ Pause' : '▶ Play';
} }
// v0.4.0 first-run UX hint — body class drives the pulse + arrow CSS in
// popup.css. Active when no station has been picked yet; cleared the
// moment a station is selected (here or in another surface via storage
// sync).
function reflectFirstRunHint() {
document.body.classList.toggle('is-first-run', !currentStation);
}
function setState(state) { function setState(state) {
els.statePill.dataset.state = state; els.statePill.dataset.state = state;
els.statePill.textContent = state; els.statePill.textContent = state;