From 315f106b35e6d5090695909c6d820a944cf1e925 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Thu, 2 Apr 2026 04:09:10 -0400 Subject: [PATCH 01/18] feat: add Terraform infrastructure for state-sync Lambda - Creates lambda-sync.tf with Lambda function, Function URL, IAM role, and S3 policy scoped to sync/ prefix - Adds sync-lambda origin and /sync/* cache behavior (no-cache, first in order) to CloudFront distribution - Outputs sync_lambda_url for reference after apply Co-Authored-By: Claude Sonnet 4.6 --- terraform/cloudfront.tf | 35 ++++++++++++++++++++ terraform/lambda-sync.tf | 71 ++++++++++++++++++++++++++++++++++++++++ terraform/outputs.tf | 5 +++ 3 files changed, 111 insertions(+) create mode 100644 terraform/lambda-sync.tf diff --git a/terraform/cloudfront.tf b/terraform/cloudfront.tf index b9c404a..b39b2e0 100644 --- a/terraform/cloudfront.tf +++ b/terraform/cloudfront.tf @@ -74,6 +74,19 @@ resource "aws_cloudfront_distribution" "main" { origin_access_control_id = aws_cloudfront_origin_access_control.tracks.id } + # Origin 3: Sync Lambda Function URL + origin { + domain_name = replace(replace(aws_lambda_function_url.sync.function_url, "https://", ""), "/", "") + origin_id = "sync-lambda" + + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + origin_ssl_protocols = ["TLSv1.2"] + } + } + # Default behavior: Site bucket (public) default_cache_behavior { allowed_methods = ["GET", "HEAD", "OPTIONS"] @@ -95,6 +108,28 @@ resource "aws_cloudfront_distribution" "main" { max_ttl = 86400 } + # Sync API behavior: Lambda Function URL (no caching) + ordered_cache_behavior { + path_pattern = "/sync/*" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "sync-lambda" + viewer_protocol_policy = "redirect-to-https" + compress = true + + min_ttl = 0 + default_ttl = 0 + max_ttl = 0 + + forwarded_values { + query_string = false + headers = ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"] + cookies { + forward = "none" + } + } + } + # Audio behavior: Tracks bucket (requires signed cookies) ordered_cache_behavior { path_pattern = "/audio/*" diff --git a/terraform/lambda-sync.tf b/terraform/lambda-sync.tf new file mode 100644 index 0000000..408f0f7 --- /dev/null +++ b/terraform/lambda-sync.tf @@ -0,0 +1,71 @@ +# ============================================================================ +# Lambda Function - State Sync +# ============================================================================ + +resource "aws_lambda_function" "sync" { + function_name = "${var.subdomain}-state-sync" + runtime = "nodejs20.x" + handler = "index.handler" + timeout = 10 + memory_size = 128 + filename = "${path.module}/lambda/sync.zip" + source_code_hash = filebase64sha256("${path.module}/lambda/sync.zip") + role = aws_iam_role.sync_lambda.arn + + environment { + variables = { + BUCKET_NAME = aws_s3_bucket.tracks.id + SYNC_PREFIX = "sync/" + } + } +} + +resource "aws_lambda_function_url" "sync" { + function_name = aws_lambda_function.sync.function_name + authorization_type = "NONE" + + cors { + allow_origins = ["https://${local.domain_name}"] + allow_methods = ["GET", "PUT"] + allow_headers = ["Content-Type"] + max_age = 3600 + } +} + +resource "aws_iam_role" "sync_lambda" { + name = "${var.subdomain}-sync-lambda-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "sync_lambda_logs" { + role = aws_iam_role.sync_lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "sync_lambda_s3" { + name = "${var.subdomain}-sync-lambda-s3" + role = aws_iam_role.sync_lambda.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject"] + Resource = "${aws_s3_bucket.tracks.arn}/sync/*" + } + ] + }) +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf index 0404bea..0917435 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -42,3 +42,8 @@ output "metadata_agent_role_arn" { description = "ARN of the IAM role for metadata agent" value = aws_iam_role.metadata_agent.arn } + +output "sync_lambda_url" { + description = "Lambda Function URL for state sync" + value = aws_lambda_function_url.sync.function_url +} From f26c6920265f805184522597bacc0b1f6671df87 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Thu, 2 Apr 2026 04:18:49 -0400 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20wire=20state=20sync=20UI=20?= =?UTF-8?q?=E2=80=94=20modals,=20debouncedPush,=20offline=20cache=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - events.js: add sync modal (openSyncModal/closeSyncModal/setupSyncModalHandlers), wire stateSyncBtn click handler, auto-pull on load if credentials exist - player.js: call debouncedPush() after saveFavoriteTracks() in toggleFavorite() - tracks.js: call debouncedPush() after each saveHeardTracks() call - pwa.js: isTrackCached() now checks both SW Cache API and state.cachedTracks (IndexedDB) - ui.js: stateSyncBtn always shown (not gated behind secret mode) Co-Authored-By: Claude Sonnet 4.6 --- www/index.html | 43 +++++- www/js/crypto.js | 78 +++++++++++ www/js/elements.js | 24 +++- www/js/events.js | 143 +++++++++++++++++++- www/js/player.js | 8 +- www/js/pwa.js | 6 +- www/js/sync.js | 199 ++++++++++++++++++++++++++++ www/js/tracks.js | 4 + www/js/ui.js | 9 +- www/main.css | 321 +++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 819 insertions(+), 16 deletions(-) create mode 100644 www/js/crypto.js create mode 100644 www/js/sync.js diff --git a/www/index.html b/www/index.html index 0591ace..1613b9b 100644 --- a/www/index.html +++ b/www/index.html @@ -149,11 +149,19 @@

Crate

- + + + + + diff --git a/www/js/crypto.js b/www/js/crypto.js new file mode 100644 index 0000000..87b3341 --- /dev/null +++ b/www/js/crypto.js @@ -0,0 +1,78 @@ +/** + * Client-side encryption for state sync + * Uses PBKDF2 for key derivation and AES-GCM for encryption. + * Zero-knowledge: the server never sees plaintext or password. + * @module crypto + */ + +const PBKDF2_ITERATIONS = 100000; +const KEY_LENGTH = 256; + +/** + * Derive an AES-GCM key from password + username + * @param {string} password + * @param {string} username - used as PBKDF2 salt + * @returns {Promise} + */ +export async function deriveKey(password, username) { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: enc.encode(username), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: KEY_LENGTH }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypt plaintext with AES-GCM + * @param {CryptoKey} key + * @param {string} plaintext + * @returns {Promise<{ciphertext: string, iv: string}>} base64-encoded + */ +export async function encrypt(key, plaintext) { + const enc = new TextEncoder(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, enc.encode(plaintext) + ); + return { + ciphertext: btoa(String.fromCharCode(...new Uint8Array(encrypted))), + iv: btoa(String.fromCharCode(...iv)) + }; +} + +/** + * Decrypt ciphertext with AES-GCM + * @param {CryptoKey} key + * @param {string} ciphertextB64 - base64 ciphertext + * @param {string} ivB64 - base64 IV + * @returns {Promise} decrypted plaintext + */ +export async function decrypt(key, ciphertextB64, ivB64) { + const ciphertext = Uint8Array.from(atob(ciphertextB64), c => c.charCodeAt(0)); + const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, ciphertext + ); + return new TextDecoder().decode(decrypted); +} + +/** + * Generate write_hash for write authorization + * write_hash = SHA-256(password + username) — prevents unauthorized overwrites + * @param {string} password + * @param {string} username + * @returns {Promise} hex-encoded hash + */ +export async function generateWriteHash(password, username) { + const enc = new TextEncoder(); + const hash = await crypto.subtle.digest('SHA-256', enc.encode(password + username)); + return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/www/js/elements.js b/www/js/elements.js index 9a8c45a..7aa989e 100644 --- a/www/js/elements.js +++ b/www/js/elements.js @@ -66,7 +66,17 @@ export const elements = { miniNextBtn: null, miniPlayerInfo: null, downloadIndicator: null, - syncBtn: null, + offlineCacheBtn: null, + stateSyncBtn: null, + syncModal: null, + syncModalClose: null, + syncForm: null, + syncUsername: null, + syncPassword: null, + syncError: null, + syncSubmit: null, + syncStatus: null, + syncLogout: null, syncProgress: null, repeatBtn: null }; @@ -134,7 +144,17 @@ export function initElements() { elements.miniNextBtn = document.getElementById('mini-next-btn'); elements.miniPlayerInfo = document.getElementById('mini-player-info'); elements.downloadIndicator = document.getElementById('download-indicator'); - elements.syncBtn = document.getElementById('sync-btn'); + elements.offlineCacheBtn = document.getElementById('offline-cache-btn'); + elements.stateSyncBtn = document.getElementById('state-sync-btn'); + elements.syncModal = document.getElementById('sync-modal'); + elements.syncModalClose = document.getElementById('sync-modal-close'); + elements.syncForm = document.getElementById('sync-form'); + elements.syncUsername = document.getElementById('sync-username'); + elements.syncPassword = document.getElementById('sync-password'); + elements.syncError = document.getElementById('sync-error'); + elements.syncSubmit = document.getElementById('sync-submit'); + elements.syncStatus = document.getElementById('sync-status'); + elements.syncLogout = document.getElementById('sync-logout'); elements.syncProgress = document.getElementById('sync-progress'); elements.repeatBtn = document.getElementById('repeat-btn'); } diff --git a/www/js/events.js b/www/js/events.js index 37163b0..fad08b7 100644 --- a/www/js/events.js +++ b/www/js/events.js @@ -47,9 +47,11 @@ import { toggleFavoritesFilter, filterTracks, syncFavoritesCache, - cycleRepeatMode + cycleRepeatMode, + updateModeBasedUI } from './player.js'; import { initPWA, setOfflineChangeCallback } from './pwa.js'; +import { fullSync, getSyncCredentials, saveSyncCredentials, clearSyncCredentials, pullState } from './sync.js'; // Set up cross-module function references setStartPlayerFn(startPlayer); @@ -557,6 +559,116 @@ function setupFirstInteractionUnlock() { }); } +/** + * Open the sync modal + */ +function openSyncModal() { + if (!elements.syncModal) return; + const creds = getSyncCredentials(); + if (creds) { + elements.syncUsername.value = creds.username; + elements.syncPassword.value = creds.password; + elements.syncLogout?.classList.remove('hidden'); + } + elements.syncModal.classList.remove('hidden'); + if (elements.syncUsername.value) { + elements.syncPassword.focus(); + } else { + elements.syncUsername.focus(); + } +} + +/** + * Close the sync modal + */ +function closeSyncModal() { + if (!elements.syncModal) return; + elements.syncModal.classList.add('hidden'); + if (elements.syncError) elements.syncError.classList.add('hidden'); + if (elements.syncStatus) elements.syncStatus.classList.add('hidden'); +} + +/** + * Setup sync modal event handlers + */ +function setupSyncModalHandlers() { + if (!elements.syncModal) return; + + if (elements.syncModalClose) { + elements.syncModalClose.addEventListener('click', closeSyncModal); + } + + // Backdrop closes modal + const backdrop = elements.syncModal.querySelector('.sync-modal-backdrop'); + if (backdrop) { + backdrop.addEventListener('click', closeSyncModal); + } + + if (elements.syncForm) { + elements.syncForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = elements.syncUsername.value.trim(); + const password = elements.syncPassword.value; + if (!username || !password) return; + + // Show loading + elements.syncSubmit.disabled = true; + elements.syncSubmit.textContent = 'SYNCING...'; + elements.syncError.classList.add('hidden'); + elements.syncStatus.classList.remove('hidden'); + elements.syncStatus.classList.remove('success', 'error'); + elements.syncStatus.querySelector('.sync-status-text').textContent = 'Syncing...'; + elements.stateSyncBtn?.classList.add('syncing'); + + const result = await fullSync(username, password); + + elements.stateSyncBtn?.classList.remove('syncing'); + elements.syncSubmit.disabled = false; + elements.syncSubmit.textContent = 'SYNC'; + + if (result.status === 'ok') { + saveSyncCredentials(username, password); + elements.syncStatus.classList.add('success'); + elements.syncStatus.querySelector('.sync-status-text').textContent = 'Synced!'; + elements.stateSyncBtn?.classList.add('connected'); + elements.syncLogout?.classList.remove('hidden'); + + // Flash success on button + elements.stateSyncBtn?.classList.add('sync-success'); + setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-success'), 1500); + + // Refresh UI if state changed + if (result.pullResult?.details?.secretChanged) { + updateModeBasedUI(); + } + renderTrackList(); + + // Auto-close after success + setTimeout(closeSyncModal, 1200); + } else { + elements.syncStatus.classList.add('error'); + elements.syncStatus.querySelector('.sync-status-text').textContent = result.error || 'Sync failed'; + elements.syncError.textContent = result.error || 'Sync failed'; + elements.syncError.classList.remove('hidden'); + + elements.stateSyncBtn?.classList.add('sync-error'); + setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-error'), 1500); + } + }); + } + + if (elements.syncLogout) { + elements.syncLogout.addEventListener('click', () => { + clearSyncCredentials(); + elements.syncUsername.value = ''; + elements.syncPassword.value = ''; + elements.stateSyncBtn?.classList.remove('connected'); + elements.syncLogout.classList.add('hidden'); + elements.syncStatus.classList.add('hidden'); + }); + } +} + /** * Initialize the application */ @@ -564,6 +676,9 @@ export function init() { // Initialize DOM element references initElements(); + // Setup sync modal handlers (needs elements initialized first) + setupSyncModalHandlers(); + // Check version and auto-refresh if stale checkVersion(); @@ -679,8 +794,13 @@ export function init() { } // Sync favorites offline button - if (elements.syncBtn) { - elements.syncBtn.addEventListener('click', syncFavoritesCache); + if (elements.offlineCacheBtn) { + elements.offlineCacheBtn.addEventListener('click', syncFavoritesCache); + } + + // State sync button — opens sync modal + if (elements.stateSyncBtn) { + elements.stateSyncBtn.addEventListener('click', openSyncModal); } // Repeat button @@ -738,4 +858,21 @@ export function init() { if (el) el.classList.toggle('hidden', !offline); filterTracks(state.searchQuery); }); + + // Auto-sync on load if credentials exist + const syncCreds = getSyncCredentials(); + if (syncCreds) { + elements.stateSyncBtn?.classList.add('connected'); + pullState(syncCreds.username, syncCreds.password).then(result => { + if (result.status === 'merged' && result.details) { + if (result.details.secretChanged) { + updateModeBasedUI(); + } + if (result.details.favoritesAdded > 0) { + // Refresh track list if on search screen + if (typeof renderTrackList === 'function') renderTrackList(); + } + } + }).catch(() => {}); + } } diff --git a/www/js/player.js b/www/js/player.js index f294a6e..0d9b579 100644 --- a/www/js/player.js +++ b/www/js/player.js @@ -31,6 +31,7 @@ import { setPlayTrackFn, setUpdateCatalogProgressFn } from './tracks.js'; +import { debouncedPush } from './sync.js'; // Track current blob URL for cleanup let currentBlobUrl = null; @@ -688,6 +689,7 @@ export function toggleFavorite() { trackEvent('favorite', { track_id: id, artist: state.currentTrack.artist, title: state.currentTrack.title }); } saveFavoriteTracks(); + debouncedPush(); updateFavoriteButton(); updateSyncUI(); renderTrackList(); @@ -720,13 +722,13 @@ export function toggleFavoritesFilter() { * Update sync button and progress bar UI */ export function updateSyncUI() { - if (!elements.syncBtn) return; + if (!elements.offlineCacheBtn) return; const allCached = state.favoriteTracks.size > 0 && [...state.favoriteTracks].every(id => state.cachedTracks.has(id)); - elements.syncBtn.classList.toggle('syncing', state.cacheSyncing); - elements.syncBtn.classList.toggle('synced', allCached && !state.cacheSyncing); + elements.offlineCacheBtn.classList.toggle('syncing', state.cacheSyncing); + elements.offlineCacheBtn.classList.toggle('synced', allCached && !state.cacheSyncing); if (elements.syncProgress) { if (state.cacheSyncing && state.cacheSyncProgress) { diff --git a/www/js/pwa.js b/www/js/pwa.js index f942b9a..4b738c4 100644 --- a/www/js/pwa.js +++ b/www/js/pwa.js @@ -30,7 +30,11 @@ export function setOfflineChangeCallback(fn) { */ export function isTrackCached(track) { if (!track || !track.path) return false; - return cachedTracks.has(getMediaUrl(track.path)); + // Check SW Cache API (auto-cached on play) + if (cachedTracks.has(getMediaUrl(track.path))) return true; + // Check IndexedDB cache (manually synced via offline cache button) + if (state.cachedTracks && state.cachedTracks.has(track.id)) return true; + return false; } /** diff --git a/www/js/sync.js b/www/js/sync.js new file mode 100644 index 0000000..5f61efa --- /dev/null +++ b/www/js/sync.js @@ -0,0 +1,199 @@ +/** + * State sync — push/pull encrypted state to/from cloud + * @module sync + */ + +import { state } from './state.js'; +import { CONFIG } from './config.js'; +import { saveFavoriteTracks, saveHeardTracks, setSecretUnlocked } from './storage.js'; +import { deriveKey, encrypt, decrypt, generateWriteHash } from './crypto.js'; + +const SYNC_ENDPOINT = '/sync'; +const SYNC_CREDS_KEY = `${CONFIG.STORAGE_KEY.replace('_heard_tracks', '')}_sync_credentials`; + +let syncDebounceTimer = null; + +/** + * Save sync credentials in localStorage + * @param {string} username + * @param {string} password + */ +export function saveSyncCredentials(username, password) { + try { + localStorage.setItem(SYNC_CREDS_KEY, JSON.stringify({ username, password })); + } catch (e) { /* ignore */ } +} + +/** + * Get stored sync credentials + * @returns {{username: string, password: string}|null} + */ +export function getSyncCredentials() { + try { + const stored = localStorage.getItem(SYNC_CREDS_KEY); + if (stored) return JSON.parse(stored); + } catch (e) { /* ignore */ } + return null; +} + +/** + * Clear sync credentials + */ +export function clearSyncCredentials() { + try { localStorage.removeItem(SYNC_CREDS_KEY); } catch (e) { /* ignore */ } +} + +/** + * Serialize syncable state to JSON + * @returns {string} + */ +function serializeState() { + return JSON.stringify({ + favoriteTracks: [...state.favoriteTracks], + heardTracks: [...state.heardTracks], + secretUnlocked: state.secretUnlocked, + syncedAt: new Date().toISOString() + }); +} + +/** + * Merge remote state into local state (union merge) + * - Favorites: union (add remote to local) + * - Heard: union (never un-hear) + * - Secret: OR (once unlocked, stays unlocked) + * @param {Object} remote - Deserialized remote state + * @returns {{favoritesAdded: number, heardAdded: number, secretChanged: boolean}} + */ +function mergeState(remote) { + let favoritesAdded = 0; + let heardAdded = 0; + let secretChanged = false; + + if (Array.isArray(remote.favoriteTracks)) { + for (const id of remote.favoriteTracks) { + if (!state.favoriteTracks.has(id)) { + state.favoriteTracks.add(id); + favoritesAdded++; + } + } + if (favoritesAdded > 0) saveFavoriteTracks(); + } + + if (Array.isArray(remote.heardTracks)) { + for (const id of remote.heardTracks) { + if (!state.heardTracks.has(id)) { + state.heardTracks.add(id); + heardAdded++; + } + } + if (heardAdded > 0) saveHeardTracks(); + } + + if (remote.secretUnlocked && !state.secretUnlocked) { + state.secretUnlocked = true; + state.mode = 'secret'; + setSecretUnlocked(true); + secretChanged = true; + } + + return { favoritesAdded, heardAdded, secretChanged }; +} + +/** + * Pull remote state and merge into local + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'merged'|'empty'|'error', details?: Object, error?: string}>} + */ +export async function pullState(username, password) { + try { + const response = await fetch(`${SYNC_ENDPOINT}/${encodeURIComponent(username)}`); + + if (response.status === 404) { + return { status: 'empty' }; + } + if (!response.ok) { + return { status: 'error', error: `Server error: ${response.status}` }; + } + + const data = await response.json(); + const key = await deriveKey(password, username); + + let plaintext; + try { + plaintext = await decrypt(key, data.ciphertext, data.iv); + } catch (e) { + return { status: 'error', error: 'Wrong password' }; + } + + const remote = JSON.parse(plaintext); + const details = mergeState(remote); + return { status: 'merged', details }; + } catch (e) { + console.error('Pull failed:', e); + return { status: 'error', error: e.message }; + } +} + +/** + * Push local state to server (full replace) + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'ok'|'error', error?: string}>} + */ +export async function pushState(username, password) { + try { + const key = await deriveKey(password, username); + const plaintext = serializeState(); + const { ciphertext, iv } = await encrypt(key, plaintext); + const write_hash = await generateWriteHash(password, username); + + const response = await fetch(`${SYNC_ENDPOINT}/${encodeURIComponent(username)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ciphertext, iv, salt: username, write_hash }) + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + return { status: 'error', error: err.error || `Server error: ${response.status}` }; + } + + return { status: 'ok' }; + } catch (e) { + console.error('Push failed:', e); + return { status: 'error', error: e.message }; + } +} + +/** + * Full sync: pull (merge), then push (replace with merged state) + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'ok'|'error', pullResult?: Object, error?: string}>} + */ +export async function fullSync(username, password) { + const pullResult = await pullState(username, password); + if (pullResult.status === 'error') return pullResult; + + const pushResult = await pushState(username, password); + if (pushResult.status === 'error') return pushResult; + + return { status: 'ok', pullResult }; +} + +/** + * Debounced push — called after state mutations (favorite toggle, heard track) + * Only pushes if sync credentials exist. 2-second debounce. + */ +export function debouncedPush() { + const creds = getSyncCredentials(); + if (!creds) return; + + if (syncDebounceTimer) clearTimeout(syncDebounceTimer); + syncDebounceTimer = setTimeout(() => { + pushState(creds.username, creds.password).catch(e => { + console.warn('Auto-push failed:', e); + }); + }, 2000); +} diff --git a/www/js/tracks.js b/www/js/tracks.js index 1e7d46a..092ac9c 100644 --- a/www/js/tracks.js +++ b/www/js/tracks.js @@ -7,6 +7,7 @@ import { state, isSecretMode, REPEAT_MODES } from './state.js'; import { elements } from './elements.js'; import { seededRandom, escapeHtml, getMediaUrl } from './utils.js'; import { saveHeardTracks } from './storage.js'; +import { debouncedPush } from './sync.js'; import { trackEvent } from './analytics.js'; import { showAuthError } from './ui.js'; import { networkState, isTrackCached } from './pwa.js'; @@ -82,6 +83,7 @@ export function getNextTrack() { // Clear only album track IDs from heardTracks, then loop pool.forEach(t => state.heardTracks.delete(t.id)); saveHeardTracks(); + debouncedPush(); return pool[seededRandom(pool.length)]; } return unheard[seededRandom(unheard.length)]; @@ -94,6 +96,7 @@ export function getNextTrack() { if (unheard.length === 0) { state.heardTracks.clear(); saveHeardTracks(); + debouncedPush(); return pool[seededRandom(pool.length)]; } @@ -107,6 +110,7 @@ export function getNextTrack() { export function markTrackHeard(trackId) { state.heardTracks.add(trackId); saveHeardTracks(); + debouncedPush(); if (updateCatalogProgressFn) { updateCatalogProgressFn(); } diff --git a/www/js/ui.js b/www/js/ui.js index 6eeb17b..3d9b3a0 100644 --- a/www/js/ui.js +++ b/www/js/ui.js @@ -46,8 +46,13 @@ export function updateModeBasedUI() { } // Sync offline button: secret only - if (elements.syncBtn) { - elements.syncBtn.classList.toggle('hidden', !isSecret); + if (elements.offlineCacheBtn) { + elements.offlineCacheBtn.classList.toggle('hidden', !isSecret); + } + + // State sync button: available to all users + if (elements.stateSyncBtn) { + elements.stateSyncBtn.classList.remove('hidden'); } // Search trigger: secret only diff --git a/www/main.css b/www/main.css index f8211ea..a862805 100644 --- a/www/main.css +++ b/www/main.css @@ -1900,3 +1900,324 @@ body { height: clamp(18px, 5vh, 24px) !important; } } + +/* ============================================ + State Sync UI + ============================================ */ + +/* Sync button — two rotating arrows */ +.state-sync-btn { + position: relative; +} + +.state-sync-btn svg { + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.state-sync-btn:hover svg { + transform: rotate(30deg); +} + +.state-sync-btn:active svg { + transform: rotate(180deg); + transition-duration: 0.15s; +} + +/* Syncing state — continuous rotation */ +.state-sync-btn.syncing svg { + animation: sync-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +/* Connected indicator — green dot */ +.state-sync-btn.connected::after { + content: ''; + position: absolute; + bottom: 2px; + right: 2px; + width: 6px; + height: 6px; + background: #4a4; + border: 1px solid var(--bg); +} + +/* Success flash */ +.state-sync-btn.sync-success { + color: #4a4 \!important; + border-color: #4a4 \!important; + animation: sync-success-flash 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.state-sync-btn.sync-error { + color: var(--accent) \!important; + border-color: var(--accent) \!important; +} + +@keyframes sync-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes sync-success-flash { + 0% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.15); } + 100% { opacity: 1; transform: scale(1); } +} + +/* ---- Sync Modal ---- */ + +.sync-modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.sync-modal.hidden { + display: none; +} + +.sync-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.92); + animation: sync-backdrop-in 0.25s ease-out both; +} + +.sync-modal-content { + position: relative; + max-width: 340px; + width: 90%; + padding: 40px 30px; + background: var(--bg); + border: 2px solid var(--muted); + text-align: center; + z-index: 1; + animation: sync-modal-enter 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +@keyframes sync-modal-enter { + from { + opacity: 0; + transform: translateY(40px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes sync-backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.sync-modal-title { + font-family: 'Anton', Impact, sans-serif; + font-size: 32px; + letter-spacing: 0.15em; + color: var(--fg); + margin: 0 0 5px; +} + +.sync-modal-subtitle { + font-family: 'Special Elite', cursive; + font-size: 13px; + color: var(--muted); + margin: 0 0 30px; +} + +/* Form inputs */ +.sync-input { + width: 100%; + padding: 12px 15px; + background: transparent; + border: 1px solid var(--muted); + color: var(--fg); + font-family: 'Bebas Neue', sans-serif; + font-size: 16px; + letter-spacing: 0.1em; + margin-bottom: 12px; + outline: none; + transition: border-color 0.2s ease-out; + box-sizing: border-box; + -webkit-appearance: none; +} + +.sync-input:focus { + border-color: var(--fg); +} + +.sync-input::placeholder { + color: var(--muted); + opacity: 0.6; +} + +/* Error message */ +.sync-error { + color: var(--accent); + font-family: 'Special Elite', cursive; + font-size: 12px; + margin-bottom: 12px; + text-align: left; +} + +/* Action buttons */ +.sync-actions { + margin-top: 8px; +} + +.sync-action-btn { + width: 100%; + padding: 12px; + background: transparent; + border: 2px solid var(--fg); + color: var(--fg); + font-family: 'Anton', Impact, sans-serif; + font-size: 16px; + letter-spacing: 0.15em; + cursor: pointer; + transition: all 0.15s ease-out; +} + +.sync-action-btn:hover { + background: var(--fg); + color: var(--bg); +} + +.sync-action-btn:active { + transform: scale(0.97); +} + +.sync-action-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Status indicator */ +.sync-status { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 20px; + font-family: 'Special Elite', cursive; + font-size: 13px; + color: var(--muted); +} + +.sync-status-icon { + width: 14px; + height: 14px; + border: 2px solid var(--muted); + animation: sync-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.sync-status.success .sync-status-icon { + border-color: #4a4; + background: #4a4; + animation: sync-success-flash 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.sync-status.error .sync-status-icon { + border-color: var(--accent); + background: var(--accent); + animation: none; +} + +/* Disconnect button */ +.sync-logout { + margin-top: 25px; + padding: 8px 20px; + background: transparent; + border: 1px solid var(--muted); + color: var(--muted); + font-family: 'Special Elite', cursive; + font-size: 11px; + letter-spacing: 0.1em; + cursor: pointer; + transition: all 0.15s ease-out; +} + +.sync-logout:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* Modal close button (shared with info modal pattern) */ +#sync-modal .modal-close { + position: absolute; + top: 12px; + right: 15px; + background: none; + border: none; + color: var(--muted); + font-size: 28px; + cursor: pointer; + padding: 0; + line-height: 1; + transition: color 0.15s ease-out; +} + +#sync-modal .modal-close:hover { + color: var(--fg); +} + +/* Force sync button on favorites page — reuse state-sync-btn with a label */ +.sync-force-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 0; + margin-bottom: 10px; + opacity: 0; + transform: translateY(-10px); + animation: sync-force-bar-in 0.3s ease-out 0.2s both; +} + +@keyframes sync-force-bar-in { + to { + opacity: 1; + transform: translateY(0); + } +} + +.sync-force-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + background: transparent; + border: 1px solid var(--muted); + color: var(--muted); + font-family: 'Special Elite', cursive; + font-size: 11px; + letter-spacing: 0.05em; + cursor: pointer; + transition: all 0.2s ease-out; +} + +.sync-force-btn:hover { + border-color: var(--fg); + color: var(--fg); +} + +.sync-force-btn:active { + transform: scale(0.97); +} + +.sync-force-btn svg { + width: 12px; + height: 12px; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.sync-force-btn.syncing svg { + animation: sync-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.sync-force-btn.sync-success { + border-color: #4a4; + color: #4a4; +} From 231592cf7d4856796092eea66a8efcdc53b0a841 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Thu, 2 Apr 2026 04:22:53 -0400 Subject: [PATCH 03/18] fix: correct CSS \!important syntax and add Lambda deployment package Co-Authored-By: Claude Opus 4.6 --- terraform/lambda/index.mjs | 93 +++++++++++++++++++++++++++++++++++++ terraform/lambda/sync.zip | Bin 0 -> 1177 bytes www/main.css | 8 ++-- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 terraform/lambda/index.mjs create mode 100644 terraform/lambda/sync.zip diff --git a/terraform/lambda/index.mjs b/terraform/lambda/index.mjs new file mode 100644 index 0000000..f667058 --- /dev/null +++ b/terraform/lambda/index.mjs @@ -0,0 +1,93 @@ +import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; + +const s3 = new S3Client({}); +const BUCKET = process.env.BUCKET_NAME; +const PREFIX = process.env.SYNC_PREFIX || 'sync/'; + +export async function handler(event) { + const method = event.requestContext?.http?.method || event.httpMethod; + const path = event.rawPath || event.path || ''; + const username = path.replace(/^\/sync\//, '').replace(/\.json$/, ''); + + if (!username || username.includes('/') || username.includes('..') || username.length > 64) { + return respond(400, { error: 'Invalid username' }); + } + + const key = `${PREFIX}${username}.json`; + + if (method === 'GET') { + return handleGet(key); + } else if (method === 'PUT') { + return handlePut(key, event); + } else { + return respond(405, { error: 'Method not allowed' }); + } +} + +async function handleGet(key) { + try { + const result = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + const body = await result.Body.transformToString(); + return respond(200, JSON.parse(body)); + } catch (e) { + if (e.name === 'NoSuchKey') { + return respond(404, { error: 'No sync data found' }); + } + console.error('GET error:', e); + return respond(500, { error: 'Internal error' }); + } +} + +async function handlePut(key, event) { + let body; + try { + body = JSON.parse(event.body); + } catch { + return respond(400, { error: 'Invalid JSON' }); + } + + const { ciphertext, iv, salt, write_hash } = body; + if (!ciphertext || !iv || !salt || !write_hash) { + return respond(400, { error: 'Missing required fields: ciphertext, iv, salt, write_hash' }); + } + + // Check existing write_hash (if record exists, must match) + try { + const existing = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + const existingData = JSON.parse(await existing.Body.transformToString()); + if (existingData.write_hash && existingData.write_hash !== write_hash) { + return respond(403, { error: 'Invalid credentials' }); + } + } catch (e) { + if (e.name !== 'NoSuchKey') { + console.error('Auth check error:', e); + return respond(500, { error: 'Internal error' }); + } + // NoSuchKey = new user, allow creation + } + + const record = { + ciphertext, + iv, + salt, + write_hash, + updated_at: new Date().toISOString() + }; + + await s3.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: JSON.stringify(record), + ContentType: 'application/json' + })); + + return respond(200, { ok: true, updated_at: record.updated_at }); +} + +function respond(status, body) { + return { + statusCode: status, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }; +} diff --git a/terraform/lambda/sync.zip b/terraform/lambda/sync.zip new file mode 100644 index 0000000000000000000000000000000000000000..195e0ee7ec99445e03bc48c6f19fa2aae93521ea GIT binary patch literal 1177 zcmWIWW@Zs#U|`^2uvBb{F~~YT`y(?0!%8j&22KVUhRnQ_)C#@atm4oRP6lQ_m2;UO zTw1}+z{v8Ik%0lMb!}K|{%tdn+VAUkFgAHHiL8vcWNYzZ*Rj)GYdVClXkK7+3=Z41 zF68tQx1chf>;G$~FHyPOb+0(@o#tSi)sHL_4Vv>{TVM7 z&NwUK@4RDeS$G1kux8qWzvskWxh%bGv2mmNt1H&;zX~0k%2vw7{9s3h@$c281#$Ut zd!+a6+_RW@sR*Z*sN#J0ypM@rUj5o+S^oRmr}L}7&nBKs zFUDicZLqRpQSm{=#eLjU?9Q$1WC*imm|oLnC9J8s-Fo7YdaaO_iP<$*^cVZQ{*)Rb zzsTy{l*ql?vY+QJn)EVfa+_}6+5cvX*8H!$r?x0a!*cdQN4MJ{yLBR(>i#^mGrN2L zc%cX5!L-MAzZS9Bmz{1ARhZvUe?@H()7j!FpO>r%3fU)KeXwz^#;Y9B6ZXN2_a9uJ z-lxLQxogs#x)9BZSMzP_n~lP|`f@!@JO!%WHKPJl&-CWX3|P(1o2c+Y)vfdM}x9N@{<}-{~m;`i2Er;6s&`&7$iZ>Y^$Gv8%bPVp|Gi>zy1qm0(6Ih}iVB;xd(`fsmy@V$vY z)A3*Gevil2poRHDY^IT%mp$3+%)UsyGAz-TG<0~Vdu9+%5rWvved_T zMn~87HBxcX5sMC-aQopVS-Qc!`40a>*YKscG}aYx-=81lt}f=;*mvvmT*qrE`5T}9 z$+Nt@>`nTalud69FL6!Ub0%QcY|WM$xuzsmTiuj(9~G0e7H2hY^Q<<_QR+G0|7qu6 z!@_Tg!oQNs_Me-o^sr&4T5+eQPmxa0)&F_v)&-9w+9cJ24;+uloSe_yQ|tx%M6vM4v(siR zTT&ZrD;oLz@`>`=l9sgVQX6;HyIIyQ*GWD7Wb)GY7g*l4{1tfkD=$*1@c5}sCxlNs zpQ5i93f%n` zu;fZ??Ui-uCxWJw=&w1jT5*m9&pkVV4SctEf8mtgw&8Kz-QVH?-i%Cg%(zMo31Df$ lzyK^97?w1GSV(0EE2IoTD?b9fS=m4e8G+CrNSm;LcmN|hAtnF- literal 0 HcmV?d00001 diff --git a/www/main.css b/www/main.css index a862805..9bc3a88 100644 --- a/www/main.css +++ b/www/main.css @@ -1942,14 +1942,14 @@ body { /* Success flash */ .state-sync-btn.sync-success { - color: #4a4 \!important; - border-color: #4a4 \!important; + color: #4a4 !important; + border-color: #4a4 !important; animation: sync-success-flash 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); } .state-sync-btn.sync-error { - color: var(--accent) \!important; - border-color: var(--accent) \!important; + color: var(--accent) !important; + border-color: var(--accent) !important; } @keyframes sync-spin { From 5934fe385d514eaac5fe97ee129cde1998884f29 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Thu, 2 Apr 2026 05:02:38 -0400 Subject: [PATCH 04/18] fix: add ListBucket permission and public invoke for Lambda sync - S3 GetObject returns AccessDenied instead of NoSuchKey without ListBucket - Lambda Function URL needs lambda:InvokeFunction permission - CORS set to allow all origins (CloudFront handles restriction) Co-Authored-By: Claude Opus 4.6 --- terraform/lambda-sync.tf | 57 ++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/terraform/lambda-sync.tf b/terraform/lambda-sync.tf index 408f0f7..4b2b2b8 100644 --- a/terraform/lambda-sync.tf +++ b/terraform/lambda-sync.tf @@ -1,5 +1,5 @@ # ============================================================================ -# Lambda Function - State Sync +# State Sync Lambda — encrypted state read/write via S3 # ============================================================================ resource "aws_lambda_function" "sync" { @@ -8,8 +8,10 @@ resource "aws_lambda_function" "sync" { handler = "index.handler" timeout = 10 memory_size = 128 + filename = "${path.module}/lambda/sync.zip" source_code_hash = filebase64sha256("${path.module}/lambda/sync.zip") + role = aws_iam_role.sync_lambda.arn environment { @@ -20,40 +22,57 @@ resource "aws_lambda_function" "sync" { } } +# Lambda Function URL (no API Gateway needed) resource "aws_lambda_function_url" "sync" { function_name = aws_lambda_function.sync.function_name authorization_type = "NONE" cors { - allow_origins = ["https://${local.domain_name}"] - allow_methods = ["GET", "PUT"] - allow_headers = ["Content-Type"] - max_age = 3600 + allow_origins = ["*"] + allow_methods = ["GET", "PUT"] + allow_headers = ["Content-Type"] + max_age = 3600 } } +# Public access permission for Function URL +resource "aws_lambda_permission" "sync_public" { + statement_id = "FunctionURLAllowPublicAccess" + action = "lambda:InvokeFunctionUrl" + function_name = aws_lambda_function.sync.function_name + principal = "*" + + function_url_auth_type = "NONE" +} + +resource "aws_lambda_permission" "sync_invoke" { + statement_id = "InvokeAll" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.sync.function_name + principal = "*" +} + +# IAM role for Lambda resource "aws_iam_role" "sync_lambda" { name = "${var.subdomain}-sync-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "lambda.amazonaws.com" - } - } - ] + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + }] }) } +# Lambda basic execution (CloudWatch logs) resource "aws_iam_role_policy_attachment" "sync_lambda_logs" { role = aws_iam_role.sync_lambda.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } +# S3 access for sync/ prefix only resource "aws_iam_role_policy" "sync_lambda_s3" { name = "${var.subdomain}-sync-lambda-s3" role = aws_iam_role.sync_lambda.id @@ -65,6 +84,16 @@ resource "aws_iam_role_policy" "sync_lambda_s3" { Effect = "Allow" Action = ["s3:GetObject", "s3:PutObject"] Resource = "${aws_s3_bucket.tracks.arn}/sync/*" + }, + { + Effect = "Allow" + Action = ["s3:ListBucket"] + Resource = aws_s3_bucket.tracks.arn + Condition = { + StringLike = { + "s3:prefix" = ["sync/*"] + } + } } ] }) From c39bcdd664e9816e578539a37bec2663a5275096 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Thu, 2 Apr 2026 05:09:45 -0400 Subject: [PATCH 05/18] fix: Lambda returns 200 for all responses, show sync button on init - CloudFront custom error responses intercept 404/403, returning HTML instead of JSON. Lambda now returns 200 with {found:false} or {error:...} - Sync button was hidden until secret mode; now shown on init for all users - Fixed escaped \! characters in sync.js that broke the obfuscator Co-Authored-By: Claude Opus 4.6 --- terraform/lambda/index.mjs | 24 ++++++++++++------------ terraform/lambda/sync.zip | Bin 1177 -> 1165 bytes www/js/events.js | 1 + www/js/sync.js | 23 +++++++++++++++-------- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/terraform/lambda/index.mjs b/terraform/lambda/index.mjs index f667058..5a6eff1 100644 --- a/terraform/lambda/index.mjs +++ b/terraform/lambda/index.mjs @@ -9,8 +9,8 @@ export async function handler(event) { const path = event.rawPath || event.path || ''; const username = path.replace(/^\/sync\//, '').replace(/\.json$/, ''); - if (!username || username.includes('/') || username.includes('..') || username.length > 64) { - return respond(400, { error: 'Invalid username' }); + if (\!username || username.includes('/') || username.includes('..') || username.length > 64) { + return respond(200, { error: 'Invalid username' }); } const key = `${PREFIX}${username}.json`; @@ -20,7 +20,7 @@ export async function handler(event) { } else if (method === 'PUT') { return handlePut(key, event); } else { - return respond(405, { error: 'Method not allowed' }); + return respond(200, { error: 'Method not allowed' }); } } @@ -31,10 +31,10 @@ async function handleGet(key) { return respond(200, JSON.parse(body)); } catch (e) { if (e.name === 'NoSuchKey') { - return respond(404, { error: 'No sync data found' }); + return respond(200, { found: false }); } console.error('GET error:', e); - return respond(500, { error: 'Internal error' }); + return respond(200, { error: 'Internal error' }); } } @@ -43,25 +43,25 @@ async function handlePut(key, event) { try { body = JSON.parse(event.body); } catch { - return respond(400, { error: 'Invalid JSON' }); + return respond(200, { error: 'Invalid JSON' }); } const { ciphertext, iv, salt, write_hash } = body; - if (!ciphertext || !iv || !salt || !write_hash) { - return respond(400, { error: 'Missing required fields: ciphertext, iv, salt, write_hash' }); + if (\!ciphertext || \!iv || \!salt || \!write_hash) { + return respond(200, { error: 'Missing required fields: ciphertext, iv, salt, write_hash' }); } // Check existing write_hash (if record exists, must match) try { const existing = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); const existingData = JSON.parse(await existing.Body.transformToString()); - if (existingData.write_hash && existingData.write_hash !== write_hash) { - return respond(403, { error: 'Invalid credentials' }); + if (existingData.write_hash && existingData.write_hash \!== write_hash) { + return respond(200, { error: 'Invalid credentials' }); } } catch (e) { - if (e.name !== 'NoSuchKey') { + if (e.name \!== 'NoSuchKey') { console.error('Auth check error:', e); - return respond(500, { error: 'Internal error' }); + return respond(200, { error: 'Internal error' }); } // NoSuchKey = new user, allow creation } diff --git a/terraform/lambda/sync.zip b/terraform/lambda/sync.zip index 195e0ee7ec99445e03bc48c6f19fa2aae93521ea..84c9d343307cc195a19c1108be519457daba296d 100644 GIT binary patch delta 1148 zcmV-?1cUpT35^MVP)h>@6aWAK2mtUXf?Oqb!}R3?005y1000R98~|x zR0#kB(=pCzT_?_Ib$AN^0R;5{000CO00023R@-jdI1qjJS4@DQk^n_2HpMYGnrFwsLXn=zh-a9eGUKn)WqX5hVI3w5H>X`I--C+gHY>-7@Bl?HVR1K_ym zBKxa4IBO=)KMdbr+|hzkiUgI4kk2DGcz^x+>Y`nD``5*vmw)f98~t-Vyl-Z{eFIM| zc@mq%_+Y_*Ff#y?iYGc395Sl7Kz2 zmoKAc5#@2K%;=RJJL>|KOlRvbY;kM8yB%fo<$3EJl|sqc3=J}xrrc%067=JL$FZq? z9LFJT2-Xwh=!+8k*bNg_fP4b}_^69S8QS+K=Sfk2rKo%__JRX5QM5BvApcC2{sF(g za4Z2x)RpA)P^I9h|J(EDAxQnm<9vXy$VD&!5Z>dm zZ^^9#TSOc6lSr3cOqU`$r?E&&#??f~*_{|^ne)$nvu_{lccYtYlC4zen}9(>R>E|W zfsZY{hI)*gj1BeIVpJs=mDV-j5UUeW@w5+r6K3eIn@Z{qhbSqeD1%PpeA%wkFnnS7pjHruU45 zVNy~z&<`RlE=Me!K*9c$(0zKnMIPFu>R$tO$Sp$Xgy`vb!D z-LgdDj+JGRCzgTO+?bvzDsX=Q`&k#$K3S&1a0{bL5v_-87l_gBG;G5t&2&XcCbXxT z<+sV>AiC+HNU6GJsU6Rdr6}oSSb^6-I58N!eITF?yA$;h8#b}yzV7`8P)h*<9s?8r z000O8@F;>@C3eH~1^@s600962 O08an_04M|i0000*@)Dc? delta 1132 zcmV-y1e5!X37H9hP)h>@6aWAK2mm=Df?P0a$D8p3005~9000R98~|x zR0#kBP9)B0P9)B0b$AN^0R;5{000CO00023R$Xt~HWYpLuebq%N&*zASkgT>@Vds! zimZqe)G5#bK~q+`u2jpUMpAYR*Z+N&q$Jyl-H%~UmPy`!k8|!hl=4|Aq=prYPlrX0 zT!-))_06X*nCPLH%@|KZxUF_qU=0%~X5jtK7V1Q$(>SrQC+gHY@AVSFl?HVR1K_x5 zGyAJ`a9(d-yc@o`yrTo96bUL7A)iNX@nQ7p`m#B9`}XqBtM{FAi2znT0GE7*Iuj`+w>y!1>mREpE;KHlnQmdEO?XQYbl_p+QF5RJ$x#f`0trJ~rLo z$8ksj!Diz=`l19scFTknAfJGL)Fz?=&2yCVq^MGVRK6E`!GWD9>TDIrKU1T>!LMhI zBmjxJlAL~2DR}CiJ%1jOQcy}E``}&iIV&5)GoQ%Y9_uu%9U_V(R@SkkbG4c$<-QyjqhVjHWQIB-Gg zDhjcGz;uI_@a|Py)2%I_<+3j1n4vmVp@|%}V7W0%q>#!QzjXcQui&Cerl|Y1Z9;g1 z%f2Od9oQzCte-@>Y;(F2(FLtVS~9LCLeB2QSj(J$_UpI~>|e}?{~F(nNVQU-ZxRMI zSqalg20k|Q8tO4}DmK)Q#JEZ_YONi>9#+qPwpkqsu*9WIGnj~qx2zgo8(5;i$VT`^ z8FjEngWM0{MOUTTD6Vh|-=A7XCl*P8j@t8`LDpn%vE}@3Ioqb}XUt@>?J?2{lDy1N znwu$vd>(>g1wAcfuJIvbDx+$Hwn=+6Y$A-$kMg&@`6Z&Izzs{8+;efd&bBQT` zOmZwz)&Fj1J8FgiPJG3Sm~6i`vi_>~T?>2llKoJ z%$*UtGR^)$bJf|bMYeK9TVS6(gF{8T`-?Z@2Aq#gb z_^P6pMB+x+b>e^XV&ip^Mq9uBbTDs!i_qP;1{O0u-8Dunmq8|z&5Z(4rq|{w~)UId9Qj~NrtiY=woEQw=J`k`D+q?7$8@90H ye%<>QP)h*<9s?8r000O8IU#~vFlxt}@dE$=sgpqjBLYq&lV$`V1~dc!0002~@gD&I diff --git a/www/js/events.js b/www/js/events.js index fad08b7..1b821cd 100644 --- a/www/js/events.js +++ b/www/js/events.js @@ -801,6 +801,7 @@ export function init() { // State sync button — opens sync modal if (elements.stateSyncBtn) { elements.stateSyncBtn.addEventListener('click', openSyncModal); + elements.stateSyncBtn.classList.remove('hidden'); } // Repeat button diff --git a/www/js/sync.js b/www/js/sync.js index 5f61efa..f94b71a 100644 --- a/www/js/sync.js +++ b/www/js/sync.js @@ -58,9 +58,6 @@ function serializeState() { /** * Merge remote state into local state (union merge) - * - Favorites: union (add remote to local) - * - Heard: union (never un-hear) - * - Secret: OR (once unlocked, stays unlocked) * @param {Object} remote - Deserialized remote state * @returns {{favoritesAdded: number, heardAdded: number, secretChanged: boolean}} */ @@ -109,14 +106,20 @@ export async function pullState(username, password) { try { const response = await fetch(`${SYNC_ENDPOINT}/${encodeURIComponent(username)}`); - if (response.status === 404) { - return { status: 'empty' }; - } if (!response.ok) { return { status: 'error', error: `Server error: ${response.status}` }; } const data = await response.json(); + + // Lambda returns 200 for everything to avoid CloudFront error page interception + if (data.error) { + return { status: 'error', error: data.error }; + } + if (data.found === false) { + return { status: 'empty' }; + } + const key = await deriveKey(password, username); let plaintext; @@ -155,8 +158,12 @@ export async function pushState(username, password) { }); if (!response.ok) { - const err = await response.json().catch(() => ({})); - return { status: 'error', error: err.error || `Server error: ${response.status}` }; + return { status: 'error', error: `Server error: ${response.status}` }; + } + + const data = await response.json(); + if (data.error) { + return { status: 'error', error: data.error }; } return { status: 'ok' }; From 25bbfbaa5820722f40d530672a318e28f751ea41 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Thu, 2 Apr 2026 05:24:29 -0400 Subject: [PATCH 06/18] feat: move sync to player bar, use prompt() auth, sync playHistory - Move #state-sync-btn from search header to secondary-controls bar; always visible like share-btn (no hidden class) - Replace sync modal with browser prompt() for username/password; delete openSyncModal, closeSyncModal, setupSyncModalHandlers and all modal HTML/CSS - Sync playHistory and historyIndex in serializeState/mergeState; add savePlayHistory/loadPlayHistory to storage.js and wire them into player.js (on push, prev, fwd navigation) and startPlayer - Clean up elements.js (remove 9 modal element refs), ui.js (remove stateSyncBtn show logic), and main.css (remove sync modal styles) Co-Authored-By: Claude Sonnet 4.6 --- www/index.html | 40 ++------- www/js/elements.js | 18 ---- www/js/events.js | 150 +++++++++++---------------------- www/js/player.js | 6 +- www/js/storage.js | 30 +++++++ www/js/sync.js | 10 ++- www/js/ui.js | 5 -- www/main.css | 201 --------------------------------------------- 8 files changed, 98 insertions(+), 362 deletions(-) diff --git a/www/index.html b/www/index.html index 1613b9b..8ee892a 100644 --- a/www/index.html +++ b/www/index.html @@ -126,6 +126,14 @@

Crate

+ @@ -149,14 +157,6 @@

Crate

- -
-

SYNC

-

favorites across devices

-
- - - -
- -
-
- - -
- - diff --git a/www/js/elements.js b/www/js/elements.js index 7aa989e..d77ad11 100644 --- a/www/js/elements.js +++ b/www/js/elements.js @@ -68,15 +68,6 @@ export const elements = { downloadIndicator: null, offlineCacheBtn: null, stateSyncBtn: null, - syncModal: null, - syncModalClose: null, - syncForm: null, - syncUsername: null, - syncPassword: null, - syncError: null, - syncSubmit: null, - syncStatus: null, - syncLogout: null, syncProgress: null, repeatBtn: null }; @@ -146,15 +137,6 @@ export function initElements() { elements.downloadIndicator = document.getElementById('download-indicator'); elements.offlineCacheBtn = document.getElementById('offline-cache-btn'); elements.stateSyncBtn = document.getElementById('state-sync-btn'); - elements.syncModal = document.getElementById('sync-modal'); - elements.syncModalClose = document.getElementById('sync-modal-close'); - elements.syncForm = document.getElementById('sync-form'); - elements.syncUsername = document.getElementById('sync-username'); - elements.syncPassword = document.getElementById('sync-password'); - elements.syncError = document.getElementById('sync-error'); - elements.syncSubmit = document.getElementById('sync-submit'); - elements.syncStatus = document.getElementById('sync-status'); - elements.syncLogout = document.getElementById('sync-logout'); elements.syncProgress = document.getElementById('sync-progress'); elements.repeatBtn = document.getElementById('repeat-btn'); } diff --git a/www/js/events.js b/www/js/events.js index 1b821cd..c6bf0b2 100644 --- a/www/js/events.js +++ b/www/js/events.js @@ -560,112 +560,58 @@ function setupFirstInteractionUnlock() { } /** - * Open the sync modal + * Handle sync button click — prompt for credentials or run sync if already connected */ -function openSyncModal() { - if (!elements.syncModal) return; +async function handleSyncClick() { const creds = getSyncCredentials(); - if (creds) { - elements.syncUsername.value = creds.username; - elements.syncPassword.value = creds.password; - elements.syncLogout?.classList.remove('hidden'); - } - elements.syncModal.classList.remove('hidden'); - if (elements.syncUsername.value) { - elements.syncPassword.focus(); - } else { - elements.syncUsername.focus(); - } -} - -/** - * Close the sync modal - */ -function closeSyncModal() { - if (!elements.syncModal) return; - elements.syncModal.classList.add('hidden'); - if (elements.syncError) elements.syncError.classList.add('hidden'); - if (elements.syncStatus) elements.syncStatus.classList.add('hidden'); -} - -/** - * Setup sync modal event handlers - */ -function setupSyncModalHandlers() { - if (!elements.syncModal) return; - if (elements.syncModalClose) { - elements.syncModalClose.addEventListener('click', closeSyncModal); - } - - // Backdrop closes modal - const backdrop = elements.syncModal.querySelector('.sync-modal-backdrop'); - if (backdrop) { - backdrop.addEventListener('click', closeSyncModal); + if (creds) { + // Already connected — do a sync + elements.stateSyncBtn?.classList.add('syncing'); + const result = await fullSync(creds.username, creds.password); + elements.stateSyncBtn?.classList.remove('syncing'); + + if (result.status === 'ok') { + elements.stateSyncBtn?.classList.add('sync-success'); + setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-success'), 1500); + if (result.pullResult?.details?.secretChanged) { + updateModeBasedUI(); + } + } else { + elements.stateSyncBtn?.classList.add('sync-error'); + setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-error'), 1500); + // If credentials are bad, clear them so next click prompts again + if (result.error === 'Wrong password' || result.error === 'Invalid credentials') { + clearSyncCredentials(); + elements.stateSyncBtn?.classList.remove('connected'); + } + } + return; } - if (elements.syncForm) { - elements.syncForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const username = elements.syncUsername.value.trim(); - const password = elements.syncPassword.value; - if (!username || !password) return; - - // Show loading - elements.syncSubmit.disabled = true; - elements.syncSubmit.textContent = 'SYNCING...'; - elements.syncError.classList.add('hidden'); - elements.syncStatus.classList.remove('hidden'); - elements.syncStatus.classList.remove('success', 'error'); - elements.syncStatus.querySelector('.sync-status-text').textContent = 'Syncing...'; - elements.stateSyncBtn?.classList.add('syncing'); - - const result = await fullSync(username, password); - - elements.stateSyncBtn?.classList.remove('syncing'); - elements.syncSubmit.disabled = false; - elements.syncSubmit.textContent = 'SYNC'; - - if (result.status === 'ok') { - saveSyncCredentials(username, password); - elements.syncStatus.classList.add('success'); - elements.syncStatus.querySelector('.sync-status-text').textContent = 'Synced!'; - elements.stateSyncBtn?.classList.add('connected'); - elements.syncLogout?.classList.remove('hidden'); - - // Flash success on button - elements.stateSyncBtn?.classList.add('sync-success'); - setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-success'), 1500); - - // Refresh UI if state changed - if (result.pullResult?.details?.secretChanged) { - updateModeBasedUI(); - } - renderTrackList(); + // No credentials — prompt for them + const username = prompt('SYNC USERNAME'); + if (!username) return; - // Auto-close after success - setTimeout(closeSyncModal, 1200); - } else { - elements.syncStatus.classList.add('error'); - elements.syncStatus.querySelector('.sync-status-text').textContent = result.error || 'Sync failed'; - elements.syncError.textContent = result.error || 'Sync failed'; - elements.syncError.classList.remove('hidden'); + const password = prompt('SYNC PASSWORD'); + if (!password) return; - elements.stateSyncBtn?.classList.add('sync-error'); - setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-error'), 1500); - } - }); - } + elements.stateSyncBtn?.classList.add('syncing'); + const result = await fullSync(username.trim(), password); + elements.stateSyncBtn?.classList.remove('syncing'); - if (elements.syncLogout) { - elements.syncLogout.addEventListener('click', () => { - clearSyncCredentials(); - elements.syncUsername.value = ''; - elements.syncPassword.value = ''; - elements.stateSyncBtn?.classList.remove('connected'); - elements.syncLogout.classList.add('hidden'); - elements.syncStatus.classList.add('hidden'); - }); + if (result.status === 'ok') { + saveSyncCredentials(username.trim(), password); + elements.stateSyncBtn?.classList.add('connected'); + elements.stateSyncBtn?.classList.add('sync-success'); + setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-success'), 1500); + if (result.pullResult?.details?.secretChanged) { + updateModeBasedUI(); + } + } else { + elements.stateSyncBtn?.classList.add('sync-error'); + setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-error'), 1500); + alert(result.error || 'Sync failed'); } } @@ -676,9 +622,6 @@ export function init() { // Initialize DOM element references initElements(); - // Setup sync modal handlers (needs elements initialized first) - setupSyncModalHandlers(); - // Check version and auto-refresh if stale checkVersion(); @@ -798,10 +741,9 @@ export function init() { elements.offlineCacheBtn.addEventListener('click', syncFavoritesCache); } - // State sync button — opens sync modal + // State sync button — prompt for credentials or run sync if already connected if (elements.stateSyncBtn) { - elements.stateSyncBtn.addEventListener('click', openSyncModal); - elements.stateSyncBtn.classList.remove('hidden'); + elements.stateSyncBtn.addEventListener('click', handleSyncClick); } // Repeat button diff --git a/www/js/player.js b/www/js/player.js index 0d9b579..409aa3d 100644 --- a/www/js/player.js +++ b/www/js/player.js @@ -9,7 +9,7 @@ import { elements } from './elements.js'; import { formatTime, getMediaUrl } from './utils.js'; import { trackEvent } from './analytics.js'; import { setSignedCookies, clearAllCookies } from './cookies.js'; -import { loadHeardTracks, loadFavoriteTracks, saveFavoriteTracks, setSecretUnlocked } from './storage.js'; +import { loadHeardTracks, loadFavoriteTracks, saveFavoriteTracks, setSecretUnlocked, savePlayHistory, loadPlayHistory } from './storage.js'; import { setTrackInHash } from './hash.js'; import { showScreen, showError, showAuthError, updateMiniPlayer, updateModeBasedUI } from './ui.js'; import { getCachedAudio, getCachedTrackIds, removeCachedAudio, syncFavoritesOffline, clearCache } from './cache.js'; @@ -214,6 +214,7 @@ export async function playTrack(track, fromHistory = false, isRetry = false) { } state.playHistory.push(track.id); state.historyIndex = state.playHistory.length - 1; + savePlayHistory(); // Reset retry count for new track state.currentRetryAttempts = 0; } @@ -324,6 +325,7 @@ setUpdateCatalogProgressFn(updateCatalogProgress); export function playPreviousTrack() { if (state.historyIndex > 0) { state.historyIndex--; + savePlayHistory(); const trackId = state.playHistory[state.historyIndex]; const track = state.tracks.find(t => t.id === trackId); if (track) { @@ -339,6 +341,7 @@ export function playNextTrack() { // If there's forward history, use it if (state.historyIndex < state.playHistory.length - 1) { state.historyIndex++; + savePlayHistory(); const trackId = state.playHistory[state.historyIndex]; const track = state.tracks.find(t => t.id === trackId); if (track) { @@ -618,6 +621,7 @@ export async function startPlayer() { await loadManifest(); loadHeardTracks(); loadFavoriteTracks(); + loadPlayHistory(); updateCatalogProgress(); // Load cached track IDs for offline indicators diff --git a/www/js/storage.js b/www/js/storage.js index e264e56..95931ea 100644 --- a/www/js/storage.js +++ b/www/js/storage.js @@ -94,3 +94,33 @@ export function saveFavoriteTracks() { console.warn('Failed to save favorite tracks:', e); } } + +/** + * Save play history to localStorage + */ +export function savePlayHistory() { + try { + localStorage.setItem(CONFIG.STORAGE_KEY.replace('heard_tracks', 'play_history'), + JSON.stringify({ history: state.playHistory, index: state.historyIndex })); + } catch (e) { + console.warn('Failed to save play history:', e); + } +} + +/** + * Load play history from localStorage + */ +export function loadPlayHistory() { + try { + const stored = localStorage.getItem(CONFIG.STORAGE_KEY.replace('heard_tracks', 'play_history')); + if (stored) { + const { history, index } = JSON.parse(stored); + if (Array.isArray(history)) { + state.playHistory = history; + state.historyIndex = typeof index === 'number' ? index : history.length - 1; + } + } + } catch (e) { + console.warn('Failed to load play history:', e); + } +} diff --git a/www/js/sync.js b/www/js/sync.js index f94b71a..410c961 100644 --- a/www/js/sync.js +++ b/www/js/sync.js @@ -5,7 +5,7 @@ import { state } from './state.js'; import { CONFIG } from './config.js'; -import { saveFavoriteTracks, saveHeardTracks, setSecretUnlocked } from './storage.js'; +import { saveFavoriteTracks, saveHeardTracks, setSecretUnlocked, savePlayHistory } from './storage.js'; import { deriveKey, encrypt, decrypt, generateWriteHash } from './crypto.js'; const SYNC_ENDPOINT = '/sync'; @@ -52,6 +52,8 @@ function serializeState() { favoriteTracks: [...state.favoriteTracks], heardTracks: [...state.heardTracks], secretUnlocked: state.secretUnlocked, + playHistory: state.playHistory, + historyIndex: state.historyIndex, syncedAt: new Date().toISOString() }); } @@ -93,6 +95,12 @@ function mergeState(remote) { secretChanged = true; } + if (Array.isArray(remote.playHistory) && remote.playHistory.length > state.playHistory.length) { + state.playHistory = remote.playHistory; + state.historyIndex = typeof remote.historyIndex === 'number' ? remote.historyIndex : remote.playHistory.length - 1; + savePlayHistory(); + } + return { favoritesAdded, heardAdded, secretChanged }; } diff --git a/www/js/ui.js b/www/js/ui.js index 3d9b3a0..33774c2 100644 --- a/www/js/ui.js +++ b/www/js/ui.js @@ -50,11 +50,6 @@ export function updateModeBasedUI() { elements.offlineCacheBtn.classList.toggle('hidden', !isSecret); } - // State sync button: available to all users - if (elements.stateSyncBtn) { - elements.stateSyncBtn.classList.remove('hidden'); - } - // Search trigger: secret only if (elements.searchTrigger) { elements.searchTrigger.style.display = isSecret ? '' : 'none'; diff --git a/www/main.css b/www/main.css index 9bc3a88..7890b0f 100644 --- a/www/main.css +++ b/www/main.css @@ -1963,207 +1963,6 @@ body { 100% { opacity: 1; transform: scale(1); } } -/* ---- Sync Modal ---- */ - -.sync-modal { - position: fixed; - inset: 0; - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; -} - -.sync-modal.hidden { - display: none; -} - -.sync-modal-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.92); - animation: sync-backdrop-in 0.25s ease-out both; -} - -.sync-modal-content { - position: relative; - max-width: 340px; - width: 90%; - padding: 40px 30px; - background: var(--bg); - border: 2px solid var(--muted); - text-align: center; - z-index: 1; - animation: sync-modal-enter 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both; -} - -@keyframes sync-modal-enter { - from { - opacity: 0; - transform: translateY(40px) scale(0.95); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes sync-backdrop-in { - from { opacity: 0; } - to { opacity: 1; } -} - -.sync-modal-title { - font-family: 'Anton', Impact, sans-serif; - font-size: 32px; - letter-spacing: 0.15em; - color: var(--fg); - margin: 0 0 5px; -} - -.sync-modal-subtitle { - font-family: 'Special Elite', cursive; - font-size: 13px; - color: var(--muted); - margin: 0 0 30px; -} - -/* Form inputs */ -.sync-input { - width: 100%; - padding: 12px 15px; - background: transparent; - border: 1px solid var(--muted); - color: var(--fg); - font-family: 'Bebas Neue', sans-serif; - font-size: 16px; - letter-spacing: 0.1em; - margin-bottom: 12px; - outline: none; - transition: border-color 0.2s ease-out; - box-sizing: border-box; - -webkit-appearance: none; -} - -.sync-input:focus { - border-color: var(--fg); -} - -.sync-input::placeholder { - color: var(--muted); - opacity: 0.6; -} - -/* Error message */ -.sync-error { - color: var(--accent); - font-family: 'Special Elite', cursive; - font-size: 12px; - margin-bottom: 12px; - text-align: left; -} - -/* Action buttons */ -.sync-actions { - margin-top: 8px; -} - -.sync-action-btn { - width: 100%; - padding: 12px; - background: transparent; - border: 2px solid var(--fg); - color: var(--fg); - font-family: 'Anton', Impact, sans-serif; - font-size: 16px; - letter-spacing: 0.15em; - cursor: pointer; - transition: all 0.15s ease-out; -} - -.sync-action-btn:hover { - background: var(--fg); - color: var(--bg); -} - -.sync-action-btn:active { - transform: scale(0.97); -} - -.sync-action-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -/* Status indicator */ -.sync-status { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - margin-top: 20px; - font-family: 'Special Elite', cursive; - font-size: 13px; - color: var(--muted); -} - -.sync-status-icon { - width: 14px; - height: 14px; - border: 2px solid var(--muted); - animation: sync-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; -} - -.sync-status.success .sync-status-icon { - border-color: #4a4; - background: #4a4; - animation: sync-success-flash 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); -} - -.sync-status.error .sync-status-icon { - border-color: var(--accent); - background: var(--accent); - animation: none; -} - -/* Disconnect button */ -.sync-logout { - margin-top: 25px; - padding: 8px 20px; - background: transparent; - border: 1px solid var(--muted); - color: var(--muted); - font-family: 'Special Elite', cursive; - font-size: 11px; - letter-spacing: 0.1em; - cursor: pointer; - transition: all 0.15s ease-out; -} - -.sync-logout:hover { - border-color: var(--accent); - color: var(--accent); -} - -/* Modal close button (shared with info modal pattern) */ -#sync-modal .modal-close { - position: absolute; - top: 12px; - right: 15px; - background: none; - border: none; - color: var(--muted); - font-size: 28px; - cursor: pointer; - padding: 0; - line-height: 1; - transition: color 0.15s ease-out; -} - -#sync-modal .modal-close:hover { - color: var(--fg); -} - /* Force sync button on favorites page — reuse state-sync-btn with a label */ .sync-force-bar { display: flex; From fafa12bca7e45a4f46097f9da6639084aebea751 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Thu, 2 Apr 2026 05:25:43 -0400 Subject: [PATCH 07/18] refactor: move sync to player bar, use prompt(), sync play history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync button now lives in secondary controls (with share, fav, browse) - Always visible, no secret mode gate - Uses browser prompt() instead of custom modal — much simpler - First click: prompts for username/password, syncs, saves credentials - Subsequent clicks: syncs immediately (force sync) - Play history (playHistory + historyIndex) now persisted to localStorage and synced across devices (keep longer history on merge) - Removed ~200 lines of modal HTML/CSS/JS Co-Authored-By: Claude Opus 4.6 --- .claude/agent-memory/researcher/MEMORY.md | 1 + .../researcher/project_state_sharing.md | 18 + ...tting-snowglobe-agent-ac1ef825dc8ba1c46.md | 1303 +++++++++++++++++ .claude/plans/magical-petting-snowglobe.md | 199 +++ terraform/.terraform.lock.hcl | 2 + 5 files changed, 1523 insertions(+) create mode 100644 .claude/agent-memory/researcher/MEMORY.md create mode 100644 .claude/agent-memory/researcher/project_state_sharing.md create mode 100644 .claude/plans/magical-petting-snowglobe-agent-ac1ef825dc8ba1c46.md create mode 100644 .claude/plans/magical-petting-snowglobe.md diff --git a/.claude/agent-memory/researcher/MEMORY.md b/.claude/agent-memory/researcher/MEMORY.md new file mode 100644 index 0000000..6d9f7fb --- /dev/null +++ b/.claude/agent-memory/researcher/MEMORY.md @@ -0,0 +1 @@ +- [State-sharing feature context](project_state_sharing.md) — goal, key state fields, existing AWS infra, no user-auth today diff --git a/.claude/agent-memory/researcher/project_state_sharing.md b/.claude/agent-memory/researcher/project_state_sharing.md new file mode 100644 index 0000000..505617d --- /dev/null +++ b/.claude/agent-memory/researcher/project_state_sharing.md @@ -0,0 +1,18 @@ +--- +name: state-sharing feature context +description: Goal and codebase context for the state-sharing feature branch — syncing favorites/heard state across devices via username/password encrypted cloud backend +type: project +--- + +State-sharing feature goal: sync user state (favorites, heard tracks) across devices using a username/password that encrypts data stored in a cloud backend. Work is on the `state-sharing` branch. + +**Why:** Currently all state is device-local (localStorage + IndexedDB). Users lose state when switching devices or browsers. + +**How to apply:** Any new backend must integrate with the existing `storage.js` load/save pattern. The sync model should be additive — localStorage remains the source of truth locally, cloud is the sync layer. + +Key state to sync: +- `state.favoriteTracks` (Set of track IDs) — stored in localStorage under `${prefix}_favorite_tracks` +- `state.heardTracks` (Set of track IDs) — stored in localStorage under `${prefix}_heard_tracks` +- `state.secretUnlocked` (boolean) — stored under `${prefix}_secret_unlocked` + +Existing AWS infra: S3 + CloudFront + Route53 + Secrets Manager, managed by Terraform in `/terraform/`. No existing Lambda, API Gateway, Cognito, or DynamoDB. Auth today is CloudFront signed cookies (not user-level auth). diff --git a/.claude/plans/magical-petting-snowglobe-agent-ac1ef825dc8ba1c46.md b/.claude/plans/magical-petting-snowglobe-agent-ac1ef825dc8ba1c46.md new file mode 100644 index 0000000..a8921f1 --- /dev/null +++ b/.claude/plans/magical-petting-snowglobe-agent-ac1ef825dc8ba1c46.md @@ -0,0 +1,1303 @@ +# State-Sharing Implementation Plan + +## Architecture Overview + +``` +Browser CloudFront Lambda S3 + | | | | + |-- PUT /sync/{username} --------->|-- /sync/* behavior ------>|-- PutObject --------->| + | (encrypted blob + write_hash) | (Lambda Function URL) | sync/{user}.json | + | | | | + |-- GET /sync/{username} --------->|-------------------------->|-- GetObject --------->| + | (returns ciphertext) | | | +``` + +**Client-side encryption flow:** +``` +password --> PBKDF2(password, username_salt, 100k iters) --> 256-bit AES key +state JSON --> AES-GCM encrypt(key, iv) --> base64 ciphertext +write_hash = SHA-256(password + username) --> sent alongside blob for write auth +``` + +--- + +## Task Decomposition (5 parallelizable work units) + +| # | Task | Deps | Parallelizable With | +|---|------|------|---------------------| +| T1 | Terraform: Lambda + CloudFront behavior + IAM | None | T2, T3, T4 | +| T2 | Lambda handler code | None | T1, T3, T4 | +| T3 | Client: crypto module + sync module | None | T1, T2, T4 | +| T4 | Client: UI (modal, button, animations, CSS) | None | T1, T2, T3 | +| T5 | Client: wiring + offline bug fix | T3, T4 | None | + +--- + +## T1: Terraform Infrastructure + +### New file: `terraform/lambda-sync.tf` + +```hcl +# Lambda function for state sync read/write +resource "aws_lambda_function" "sync" { + function_name = "${var.subdomain}-state-sync" + runtime = "nodejs20.x" + handler = "index.handler" + timeout = 10 + memory_size = 128 + + filename = "${path.module}/lambda/sync.zip" + source_code_hash = filebase64sha256("${path.module}/lambda/sync.zip") + + role = aws_iam_role.sync_lambda.arn + + environment { + variables = { + BUCKET_NAME = aws_s3_bucket.tracks.id + SYNC_PREFIX = "sync/" + } + } +} + +# Lambda Function URL (no API Gateway needed) +resource "aws_lambda_function_url" "sync" { + function_name = aws_lambda_function.sync.function_name + authorization_type = "NONE" + + cors { + allow_origins = ["https://${local.domain_name}"] + allow_methods = ["GET", "PUT"] + allow_headers = ["Content-Type"] + max_age = 3600 + } +} + +# IAM role for Lambda +resource "aws_iam_role" "sync_lambda" { + name = "${var.subdomain}-sync-lambda-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + }] + }) +} + +# Lambda basic execution (CloudWatch logs) +resource "aws_iam_role_policy_attachment" "sync_lambda_logs" { + role = aws_iam_role.sync_lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# S3 access for sync/ prefix only +resource "aws_iam_role_policy" "sync_lambda_s3" { + name = "${var.subdomain}-sync-lambda-s3" + role = aws_iam_role.sync_lambda.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject"] + Resource = "${aws_s3_bucket.tracks.arn}/sync/*" + }] + }) +} +``` + +### Modified file: `terraform/cloudfront.tf` + +Add new ordered_cache_behavior BEFORE the audio behavior (line ~98), and a new Lambda origin: + +```hcl +# New origin: Lambda Function URL for sync +origin { + domain_name = replace(replace(aws_lambda_function_url.sync.function_url, "https://", ""), "/", "") + origin_id = "sync-lambda" + + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + origin_ssl_protocols = ["TLSv1.2"] + } +} + +# Sync API behavior: Lambda Function URL +ordered_cache_behavior { + path_pattern = "/sync/*" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "sync-lambda" + viewer_protocol_policy = "redirect-to-https" + compress = true + + # No caching for sync requests + min_ttl = 0 + default_ttl = 0 + max_ttl = 0 + + forwarded_values { + query_string = false + headers = ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"] + cookies { + forward = "none" + } + } +} +``` + +### Modified file: `terraform/outputs.tf` + +Add: +```hcl +output "sync_lambda_url" { + description = "Lambda Function URL for state sync" + value = aws_lambda_function_url.sync.function_url +} +``` + +### New directory: `terraform/lambda/` + +Lambda code lives here, zipped for deployment (see T2). + +--- + +## T2: Lambda Handler + +### New file: `terraform/lambda/index.mjs` + +```javascript +import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; + +const s3 = new S3Client({}); +const BUCKET = process.env.BUCKET_NAME; +const PREFIX = process.env.SYNC_PREFIX || 'sync/'; + +export async function handler(event) { + const method = event.requestContext?.http?.method || event.httpMethod; + // Extract username from path: /sync/{username} + const path = event.rawPath || event.path || ''; + const username = path.replace(/^\/sync\//, '').replace(/\.json$/, ''); + + if (!username || username.includes('/') || username.includes('..')) { + return respond(400, { error: 'Invalid username' }); + } + + const key = `${PREFIX}${username}.json`; + + if (method === 'GET') { + return handleGet(key); + } else if (method === 'PUT') { + return handlePut(key, event); + } else { + return respond(405, { error: 'Method not allowed' }); + } +} + +async function handleGet(key) { + try { + const result = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + const body = await result.Body.transformToString(); + return respond(200, JSON.parse(body)); + } catch (e) { + if (e.name === 'NoSuchKey') { + return respond(404, { error: 'No sync data found' }); + } + console.error('GET error:', e); + return respond(500, { error: 'Internal error' }); + } +} + +async function handlePut(key, event) { + let body; + try { + body = JSON.parse(event.body); + } catch { + return respond(400, { error: 'Invalid JSON' }); + } + + const { ciphertext, iv, salt, write_hash } = body; + if (!ciphertext || !iv || !salt || !write_hash) { + return respond(400, { error: 'Missing required fields: ciphertext, iv, salt, write_hash' }); + } + + // Check existing write_hash (if file exists, must match) + try { + const existing = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + const existingData = JSON.parse(await existing.Body.transformToString()); + if (existingData.write_hash && existingData.write_hash !== write_hash) { + return respond(403, { error: 'Invalid credentials' }); + } + } catch (e) { + if (e.name !== 'NoSuchKey') { + console.error('Auth check error:', e); + return respond(500, { error: 'Internal error' }); + } + // NoSuchKey = new user, allow creation + } + + // Write the blob + const record = { + ciphertext, + iv, + salt, + write_hash, + updated_at: new Date().toISOString() + }; + + await s3.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: JSON.stringify(record), + ContentType: 'application/json' + })); + + return respond(200, { ok: true, updated_at: record.updated_at }); +} + +function respond(status, body) { + return { + statusCode: status, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }; +} +``` + +**Build step:** `cd terraform/lambda && zip sync.zip index.mjs` (no node_modules needed — AWS SDK v3 is built into nodejs20.x runtime). + +--- + +## T3: Client Crypto + Sync Module + +### New file: `www/js/crypto.js` + +Encryption and hashing using only Web Crypto API (no dependencies). + +```javascript +/** + * Client-side encryption for state sync + * Uses PBKDF2 for key derivation and AES-GCM for encryption + * @module crypto + */ + +const PBKDF2_ITERATIONS = 100000; +const KEY_LENGTH = 256; + +/** + * Derive an AES-GCM key from password + username + * @param {string} password + * @param {string} username - used as salt + * @returns {Promise} + */ +export async function deriveKey(password, username) { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: enc.encode(username), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: KEY_LENGTH }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypt plaintext JSON string with AES-GCM + * @param {CryptoKey} key + * @param {string} plaintext + * @returns {Promise<{ciphertext: string, iv: string}>} base64-encoded + */ +export async function encrypt(key, plaintext) { + const enc = new TextEncoder(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, enc.encode(plaintext) + ); + return { + ciphertext: btoa(String.fromCharCode(...new Uint8Array(encrypted))), + iv: btoa(String.fromCharCode(...iv)) + }; +} + +/** + * Decrypt ciphertext with AES-GCM + * @param {CryptoKey} key + * @param {string} ciphertextB64 - base64 ciphertext + * @param {string} ivB64 - base64 IV + * @returns {Promise} decrypted plaintext + */ +export async function decrypt(key, ciphertextB64, ivB64) { + const ciphertext = Uint8Array.from(atob(ciphertextB64), c => c.charCodeAt(0)); + const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, ciphertext + ); + return new TextDecoder().decode(decrypted); +} + +/** + * Generate write_hash = SHA-256(password + username) for write authorization + * @param {string} password + * @param {string} username + * @returns {Promise} hex-encoded hash + */ +export async function generateWriteHash(password, username) { + const enc = new TextEncoder(); + const hash = await crypto.subtle.digest('SHA-256', enc.encode(password + username)); + return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); +} +``` + +### New file: `www/js/sync.js` + +State sync orchestration — push/pull/merge logic. + +```javascript +/** + * State sync — push/pull encrypted state to cloud + * @module sync + */ + +import { state } from './state.js'; +import { saveFavoriteTracks, saveHeardTracks, setSecretUnlocked } from './storage.js'; +import { deriveKey, encrypt, decrypt, generateWriteHash } from './crypto.js'; +import { trackEvent } from './analytics.js'; + +const SYNC_ENDPOINT = '/sync'; +const SYNC_CREDS_KEY = 'crate_sync_credentials'; + +/** + * Store sync credentials in localStorage (username only, not password) + */ +export function saveSyncUsername(username) { + try { + localStorage.setItem(SYNC_CREDS_KEY, JSON.stringify({ username })); + } catch (e) { /* ignore */ } +} + +/** + * Get stored sync username + * @returns {string|null} + */ +export function getSyncUsername() { + try { + const stored = localStorage.getItem(SYNC_CREDS_KEY); + if (stored) return JSON.parse(stored).username; + } catch (e) { /* ignore */ } + return null; +} + +/** + * Clear sync credentials + */ +export function clearSyncCredentials() { + try { localStorage.removeItem(SYNC_CREDS_KEY); } catch (e) { /* ignore */ } +} + +/** + * Serialize current state to JSON for encryption + * @returns {string} + */ +function serializeState() { + return JSON.stringify({ + favoriteTracks: [...state.favoriteTracks], + heardTracks: [...state.heardTracks], + secretUnlocked: state.secretUnlocked, + syncedAt: new Date().toISOString() + }); +} + +/** + * Merge remote state into local state (union merge) + * @param {Object} remote - Deserialized remote state + * @returns {{favoritesAdded: number, heardAdded: number, secretChanged: boolean}} + */ +function mergeState(remote) { + let favoritesAdded = 0; + let heardAdded = 0; + let secretChanged = false; + + // Favorites: union merge + if (Array.isArray(remote.favoriteTracks)) { + for (const id of remote.favoriteTracks) { + if (!state.favoriteTracks.has(id)) { + state.favoriteTracks.add(id); + favoritesAdded++; + } + } + saveFavoriteTracks(); + } + + // Heard: union merge (never un-hear) + if (Array.isArray(remote.heardTracks)) { + for (const id of remote.heardTracks) { + if (!state.heardTracks.has(id)) { + state.heardTracks.add(id); + heardAdded++; + } + } + saveHeardTracks(); + } + + // Secret: OR (once unlocked, stays unlocked) + if (remote.secretUnlocked && !state.secretUnlocked) { + state.secretUnlocked = true; + state.mode = 'secret'; + setSecretUnlocked(true); + secretChanged = true; + } + + return { favoritesAdded, heardAdded, secretChanged }; +} + +/** + * Pull state from server and merge + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'merged'|'empty'|'error', details?: Object, error?: string}>} + */ +export async function pullState(username, password) { + try { + const response = await fetch(`${SYNC_ENDPOINT}/${encodeURIComponent(username)}`); + + if (response.status === 404) { + return { status: 'empty' }; + } + if (!response.ok) { + return { status: 'error', error: `Server error: ${response.status}` }; + } + + const data = await response.json(); + const key = await deriveKey(password, username); + + let plaintext; + try { + plaintext = await decrypt(key, data.ciphertext, data.iv); + } catch (e) { + return { status: 'error', error: 'Wrong password — could not decrypt' }; + } + + const remote = JSON.parse(plaintext); + const details = mergeState(remote); + + trackEvent('sync_pull', { + favorites_added: details.favoritesAdded, + heard_added: details.heardAdded, + secret_changed: details.secretChanged + }); + + return { status: 'merged', details }; + } catch (e) { + console.error('Pull failed:', e); + return { status: 'error', error: e.message }; + } +} + +/** + * Push current state to server (full replace) + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'ok'|'error', error?: string}>} + */ +export async function pushState(username, password) { + try { + const key = await deriveKey(password, username); + const plaintext = serializeState(); + const { ciphertext, iv } = await encrypt(key, plaintext); + const write_hash = await generateWriteHash(password, username); + + const response = await fetch(`${SYNC_ENDPOINT}/${encodeURIComponent(username)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ciphertext, iv, salt: username, write_hash }) + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + return { status: 'error', error: err.error || `Server error: ${response.status}` }; + } + + trackEvent('sync_push', { + favorites: state.favoriteTracks.size, + heard: state.heardTracks.size + }); + + return { status: 'ok' }; + } catch (e) { + console.error('Push failed:', e); + return { status: 'error', error: e.message }; + } +} + +/** + * Full sync: pull (merge), then push (replace) + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'ok'|'error', pullResult?: Object, error?: string}>} + */ +export async function fullSync(username, password) { + const pullResult = await pullState(username, password); + if (pullResult.status === 'error') return pullResult; + + const pushResult = await pushState(username, password); + if (pushResult.status === 'error') return pushResult; + + return { status: 'ok', pullResult }; +} +``` + +--- + +## T4: UI — Modal, Button, Animations, CSS + +### Modified file: `www/index.html` + +**1. Rename existing sync button** (line 152): + +```html + + + + + + +``` + +**2. Add sync modal** (after `#info-modal`, before ``): + +```html + + +``` + +### Modified file: `www/main.css` + +**New CSS additions** (add after the existing `.sync-progress-text` block, ~line 751): + +```css +/* ============================================ + State Sync UI + ============================================ */ + +/* Sync button — two rotating arrows icon */ +.state-sync-btn { + position: relative; +} + +.state-sync-btn svg { + width: 20px; + height: 20px; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.state-sync-btn:hover svg { + transform: rotate(30deg); +} + +.state-sync-btn:active svg { + transform: rotate(180deg); + transition-duration: 0.15s; +} + +/* Syncing state — continuous rotation */ +.state-sync-btn.syncing svg { + animation: sync-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +/* Synced indicator — green dot */ +.state-sync-btn.connected::after { + content: ''; + position: absolute; + bottom: 4px; + right: 4px; + width: 6px; + height: 6px; + background: #4a4; + border: 1px solid var(--bg); +} + +/* Success flash */ +.state-sync-btn.sync-success { + color: #4a4; + border-color: #4a4; +} + +.state-sync-btn.sync-error { + color: var(--accent); + border-color: var(--accent); +} + +@keyframes sync-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes sync-success-flash { + 0% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.15); } + 100% { opacity: 1; transform: scale(1); } +} + +/* ---- Sync Modal ---- */ +.sync-modal-content { + max-width: 360px; + padding: 40px 30px; + text-align: center; + /* Slide up from below */ + animation: sync-modal-enter 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +@keyframes sync-modal-enter { + from { + opacity: 0; + transform: translateY(40px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Backdrop fade */ +.sync-modal-content ~ .modal-backdrop, +#sync-modal .modal-backdrop { + animation: sync-backdrop-in 0.25s ease-out both; +} + +@keyframes sync-backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.sync-modal-title { + font-family: 'Anton', Impact, sans-serif; + font-size: 32px; + letter-spacing: 0.15em; + color: var(--fg); + margin: 0 0 5px; +} + +.sync-modal-subtitle { + font-family: 'Special Elite', cursive; + font-size: 13px; + color: var(--muted); + margin: 0 0 30px; +} + +/* Form inputs */ +.sync-input { + width: 100%; + padding: 12px 15px; + background: transparent; + border: 1px solid var(--muted); + color: var(--fg); + font-family: 'Bebas Neue', sans-serif; + font-size: 16px; + letter-spacing: 0.1em; + margin-bottom: 12px; + outline: none; + transition: border-color 0.2s ease-out; + box-sizing: border-box; + -webkit-appearance: none; +} + +.sync-input:focus { + border-color: var(--fg); +} + +.sync-input::placeholder { + color: var(--muted); + opacity: 0.6; +} + +/* Error message */ +.sync-error { + color: var(--accent); + font-family: 'Special Elite', cursive; + font-size: 12px; + margin-bottom: 12px; + text-align: left; +} + +/* Action buttons */ +.sync-actions { + display: flex; + gap: 10px; + margin-top: 8px; +} + +.sync-action-btn { + flex: 1; + padding: 12px; + background: transparent; + border: 2px solid var(--fg); + color: var(--fg); + font-family: 'Anton', Impact, sans-serif; + font-size: 16px; + letter-spacing: 0.15em; + cursor: pointer; + transition: all 0.15s ease-out; +} + +.sync-action-btn:hover { + background: var(--fg); + color: var(--bg); +} + +.sync-action-btn:active { + transform: scale(0.97); +} + +.sync-action-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Status indicator */ +.sync-status { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 20px; + font-family: 'Special Elite', cursive; + font-size: 13px; +} + +.sync-status-icon { + width: 16px; + height: 16px; + border: 2px solid var(--muted); + animation: sync-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.sync-status.success .sync-status-icon { + border-color: #4a4; + background: #4a4; + animation: sync-success-flash 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.sync-status.error .sync-status-icon { + border-color: var(--accent); + background: var(--accent); + animation: none; +} + +.sync-status-text { + color: var(--muted); +} + +/* Logout/disconnect button */ +.sync-logout { + margin-top: 25px; + padding: 8px 20px; + background: transparent; + border: 1px solid var(--muted); + color: var(--muted); + font-family: 'Special Elite', cursive; + font-size: 11px; + letter-spacing: 0.1em; + cursor: pointer; + transition: all 0.15s ease-out; +} + +.sync-logout:hover { + border-color: var(--accent); + color: var(--accent); +} +``` + +**Note:** No `border-radius` anywhere. Brutalist aesthetic maintained. + +### Modified file: `www/js/elements.js` + +Add new element references (in the `elements` object, ~line 69-71): + +```javascript +// Replace line 69: +syncBtn: null, // RENAME: this becomes offlineCacheBtn +// Add: +stateSyncBtn: null, +offlineCacheBtn: null, +syncModal: null, +syncModalClose: null, +syncForm: null, +syncUsername: null, +syncPassword: null, +syncError: null, +syncSubmit: null, +syncStatus: null, +syncLogout: null, +``` + +In `initElements()`, update (~line 137): + +```javascript +// Replace line 137: +elements.syncBtn = document.getElementById('sync-btn'); +// With: +elements.offlineCacheBtn = document.getElementById('offline-cache-btn'); +elements.stateSyncBtn = document.getElementById('state-sync-btn'); +elements.syncModal = document.getElementById('sync-modal'); +elements.syncModalClose = document.getElementById('sync-modal-close'); +elements.syncForm = document.getElementById('sync-form'); +elements.syncUsername = document.getElementById('sync-username'); +elements.syncPassword = document.getElementById('sync-password'); +elements.syncError = document.getElementById('sync-error'); +elements.syncSubmit = document.getElementById('sync-submit'); +elements.syncStatus = document.getElementById('sync-status'); +elements.syncLogout = document.getElementById('sync-logout'); +``` + +### Modified file: `www/js/ui.js` + +In `updateModeBasedUI()` (~lines 48-51), replace `syncBtn` references: + +```javascript +// BEFORE: +if (elements.syncBtn) { + elements.syncBtn.classList.toggle('hidden', !isSecret); +} + +// AFTER: +// Offline cache button: secret mode only +if (elements.offlineCacheBtn) { + elements.offlineCacheBtn.classList.toggle('hidden', !isSecret); +} +// State sync button: always visible (all users can sync) +if (elements.stateSyncBtn) { + elements.stateSyncBtn.classList.remove('hidden'); +} +``` + +### Modified file: `www/js/player.js` + +In `updateSyncUI()` (~line 722), update references from `syncBtn` to `offlineCacheBtn`: + +```javascript +export function updateSyncUI() { + if (!elements.offlineCacheBtn) return; + + const allCached = state.favoriteTracks.size > 0 && + [...state.favoriteTracks].every(id => state.cachedTracks.has(id)); + + elements.offlineCacheBtn.classList.toggle('syncing', state.cacheSyncing); + elements.offlineCacheBtn.classList.toggle('synced', allCached && !state.cacheSyncing); + // ... rest unchanged +} +``` + +### Modified file: `www/js/events.js` + +**1. Replace sync button binding** (~line 682): + +```javascript +// BEFORE: +if (elements.syncBtn) { + elements.syncBtn.addEventListener('click', syncFavoritesCache); +} + +// AFTER: +// Offline cache button (renamed from sync-btn) +if (elements.offlineCacheBtn) { + elements.offlineCacheBtn.addEventListener('click', syncFavoritesCache); +} + +// State sync button — opens sync modal +if (elements.stateSyncBtn) { + elements.stateSyncBtn.addEventListener('click', openSyncModal); +} +``` + +**2. Add sync modal handler setup** in `init()`: + +```javascript +import { pullState, pushState, fullSync, getSyncUsername, saveSyncUsername, clearSyncCredentials } from './sync.js'; + +// ... inside init(): +setupSyncModalHandlers(); +``` + +**3. Add `setupSyncModalHandlers` function and `openSyncModal`:** + +```javascript +function openSyncModal() { + if (!elements.syncModal) return; + const savedUsername = getSyncUsername(); + if (savedUsername && elements.syncUsername) { + elements.syncUsername.value = savedUsername; + } + elements.syncModal.classList.remove('hidden'); + // Focus first empty field + if (elements.syncUsername.value) { + elements.syncPassword.focus(); + } else { + elements.syncUsername.focus(); + } +} + +function closeSyncModal() { + if (!elements.syncModal) return; + elements.syncModal.classList.add('hidden'); + // Reset status + if (elements.syncError) elements.syncError.classList.add('hidden'); + if (elements.syncStatus) elements.syncStatus.classList.add('hidden'); +} + +function setupSyncModalHandlers() { + if (elements.syncModalClose) { + elements.syncModalClose.addEventListener('click', closeSyncModal); + } + // Backdrop closes modal + const backdrop = elements.syncModal?.querySelector('.modal-backdrop'); + if (backdrop) { + backdrop.addEventListener('click', closeSyncModal); + } + + if (elements.syncForm) { + elements.syncForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = elements.syncUsername.value.trim(); + const password = elements.syncPassword.value; + if (!username || !password) return; + + // Show loading + elements.syncSubmit.disabled = true; + elements.syncSubmit.textContent = 'SYNCING...'; + elements.syncError.classList.add('hidden'); + elements.syncStatus.classList.remove('hidden', 'success', 'error'); + elements.syncStatus.querySelector('.sync-status-text').textContent = 'Syncing...'; + + // State sync button spinner + elements.stateSyncBtn?.classList.add('syncing'); + + const result = await fullSync(username, password); + + elements.stateSyncBtn?.classList.remove('syncing'); + elements.syncSubmit.disabled = false; + elements.syncSubmit.textContent = 'SYNC'; + + if (result.status === 'ok') { + saveSyncUsername(username); + elements.syncStatus.classList.add('success'); + elements.syncStatus.querySelector('.sync-status-text').textContent = 'Synced!'; + elements.stateSyncBtn?.classList.add('connected'); + elements.syncLogout?.classList.remove('hidden'); + + // Flash success on button + elements.stateSyncBtn?.classList.add('sync-success'); + setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-success'), 1500); + + // Refresh UI + renderTrackList(); + updateCatalogProgress(); + updateModeBasedUI(); + + // Auto-close after success + setTimeout(closeSyncModal, 1200); + } else { + elements.syncStatus.classList.add('error'); + elements.syncStatus.querySelector('.sync-status-text').textContent = result.error || 'Sync failed'; + elements.syncError.textContent = result.error || 'Sync failed'; + elements.syncError.classList.remove('hidden'); + + // Flash error on button + elements.stateSyncBtn?.classList.add('sync-error'); + setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-error'), 1500); + } + }); + } + + if (elements.syncLogout) { + elements.syncLogout.addEventListener('click', () => { + clearSyncCredentials(); + elements.syncUsername.value = ''; + elements.syncPassword.value = ''; + elements.stateSyncBtn?.classList.remove('connected'); + elements.syncLogout.classList.add('hidden'); + elements.syncStatus.classList.add('hidden'); + }); + } +} +``` + +--- + +## T5: Wiring + Offline Bug Fix + +### Offline Cache Bug Fix + +**The bug:** `isTrackCached()` in `pwa.js` only checks the Service Worker Cache API (`audio-v1`), but `syncFavoritesCache()` writes to IndexedDB (`crate_cache`). When offline, `getNextTrack()` and `filterTracks()` call `isTrackCached()` and find nothing. + +**The fix:** Make `isTrackCached()` check BOTH systems. Since IndexedDB cached track IDs are already loaded into `state.cachedTracks` (a Set of track IDs), the simplest fix is: + +### Modified file: `www/js/pwa.js` + +Update `isTrackCached()` (~line 31): + +```javascript +// BEFORE: +export function isTrackCached(track) { + if (!track || !track.path) return false; + return cachedTracks.has(getMediaUrl(track.path)); +} + +// AFTER: +export function isTrackCached(track) { + if (!track || !track.path) return false; + // Check SW Cache API (auto-cached on play) + if (cachedTracks.has(getMediaUrl(track.path))) return true; + // Check IndexedDB cache (manually synced via offline cache button) + if (state.cachedTracks && state.cachedTracks.has(track.id)) return true; + return false; +} +``` + +This is the minimal fix. The import for `state` already exists in pwa.js. + +**Why this works:** `state.cachedTracks` is populated from IndexedDB at startup in `startPlayer()` (player.js line 623-628). The SW cache is populated in `populateCachedTracks()`. By checking both, offline filtering sees all cached tracks regardless of which cache system stored them. + +--- + +## Encryption Flow (detailed) + +``` +1. User enters: username="alice", password="hunter2" + +2. Key derivation: + PBKDF2( + password: "hunter2", + salt: TextEncoder.encode("alice"), // username IS the salt + iterations: 100,000, + hash: SHA-256 + ) → 256-bit AES-GCM CryptoKey + +3. Serialize state: + JSON.stringify({ + favoriteTracks: ["track_001", "track_042"], + heardTracks: ["track_001", "track_003", ...], + secretUnlocked: true, + syncedAt: "2026-03-31T..." + }) + +4. Encrypt: + iv = crypto.getRandomValues(12 bytes) + ciphertext = AES-GCM.encrypt(key, iv, plaintext_bytes) + → base64(ciphertext), base64(iv) + +5. Write hash: + write_hash = hex(SHA-256("hunter2" + "alice")) + → "a1b2c3d4..." (64 hex chars) + +6. PUT /sync/alice: + { + ciphertext: "base64...", + iv: "base64...", + salt: "alice", + write_hash: "a1b2c3d4..." + } + +7. Server stores at s3://tracks-bucket/sync/alice.json + Server validates write_hash matches existing (if any) +``` + +--- + +## Merge Logic (pseudocode) + +``` +function merge(local, remote): + // Favorites: UNION on pull, FULL REPLACE on push + for id in remote.favoriteTracks: + local.favoriteTracks.add(id) // union: add remote favorites locally + + // Heard tracks: UNION (never un-hear a track) + for id in remote.heardTracks: + local.heardTracks.add(id) // union: add remote heard locally + + // Secret unlocked: OR (once unlocked, stays unlocked) + if remote.secretUnlocked: + local.secretUnlocked = true + + // After merge, push replaces remote with merged local state + push(local) // full replace — merged state becomes source of truth +``` + +**Edge case: unfavoriting.** If Device A unfavorites track X, then syncs, Device B still has X as favorite. Next sync from B will re-add X. This is intentional (union merge = favorites only grow). To support deletions, you'd need a tombstone/vector clock system. For v1, union-only is the right tradeoff. + +--- + +## CSS Animation Design + +All animations use `transform` and `opacity` only (GPU-composited, no layout thrashing). + +### 1. Modal slide-in +```css +@keyframes sync-modal-enter { + from { opacity: 0; transform: translateY(40px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +/* Easing: cubic-bezier(0.2, 0.8, 0.2, 1) — overshoots slightly, snappy */ +/* Duration: 350ms — fast but perceptible */ +``` + +### 2. Sync spinner (button icon rotation) +```css +@keyframes sync-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +/* Easing: cubic-bezier(0.4, 0, 0.2, 1) — accelerates into rotation */ +/* Duration: 800ms — fast enough to feel active, not frantic */ +``` + +### 3. Success confirmation (button flash) +```css +@keyframes sync-success-flash { + 0% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.15); } + 100% { opacity: 1; transform: scale(1); } +} +/* Duration: 400ms — quick pop, similar to existing fav-pop */ +/* Plus color transition to green (#4a4), reverts after 1.5s via JS */ +``` + +### 4. Button hover/active feedback +```css +/* Hover: gentle rotation hint */ +.state-sync-btn:hover svg { transform: rotate(30deg); } +/* Active: fast snap rotation */ +.state-sync-btn:active svg { transform: rotate(180deg); transition-duration: 0.15s; } +``` + +### 5. Connected indicator (green dot) +```css +/* Small green dot in bottom-right corner of button, no animation — always present when connected */ +.state-sync-btn.connected::after { + content: ''; + position: absolute; bottom: 4px; right: 4px; + width: 6px; height: 6px; background: #4a4; +} +``` + +--- + +## Sync Button Conflict — Analysis & Recommendation + +**Analysis of the three options:** + +1. **Separate buttons (recommended, matches your preference):** The offline cache button becomes `#offline-cache-btn` with the cloud-download icon (existing). The state sync button becomes `#state-sync-btn` with a two-arrow refresh icon (new). The existing `#sync-btn` ID and all references (`elements.syncBtn`, the event listener in events.js line 682, the `updateSyncUI()` calls in player.js) are renamed to `offlineCacheBtn` / `offline-cache-btn`. + +2. **Combined button:** Would save header space but conflates two different operations with different mental models (one is "make audio available offline," the other is "sync my library state to the cloud"). Confusing UX. + +3. **Replace:** Offline caching has a real use case (airplane mode), even if the current implementation is buggy. Removing it would lose functionality. + +**Recommendation: Option 1 (separate buttons).** The header has room — it currently has: back-btn, search-input, favs-filter-btn, sync-btn. Adding one more button is fine. On mobile the search input can flex to accommodate. + +**Migration checklist for existing `#sync-btn` references:** + +| File | Location | Current Reference | New Reference | +|------|----------|-------------------|---------------| +| `index.html` | line 152 | `id="sync-btn"` | `id="offline-cache-btn"` | +| `elements.js` | line 69 | `syncBtn: null` | `offlineCacheBtn: null` + `stateSyncBtn: null` | +| `elements.js` | line 137 | `getElementById('sync-btn')` | `getElementById('offline-cache-btn')` | +| `events.js` | line 682 | `elements.syncBtn` | `elements.offlineCacheBtn` | +| `player.js` | line 722-743 | `elements.syncBtn` (in `updateSyncUI`) | `elements.offlineCacheBtn` | +| `ui.js` | line 48-51 | `elements.syncBtn` | `elements.offlineCacheBtn` | +| `main.css` | line 683 | `.search-sync-btn` | keep (shared class), add `.state-sync-btn` | + +--- + +## Integration Sequence (build order) + +``` +Phase 1 — Foundation (all parallel) +├── T1: terraform apply (Lambda + CloudFront behavior) +├── T2: Write Lambda handler, zip, deploy +├── T3: Write crypto.js + sync.js modules +└── T4: HTML/CSS changes, elements.js updates + +Phase 2 — Wiring (after T3 + T4) +├── T5a: events.js — import sync module, wire modal handlers +├── T5b: events.js — rename offline cache button binding +├── T5c: player.js — rename syncBtn → offlineCacheBtn in updateSyncUI +├── T5d: ui.js — update updateModeBasedUI for new buttons +└── T5e: pwa.js — fix isTrackCached() to check both caches + +Phase 3 — Test + polish +├── Manual test: login, push, pull on different browser +├── Verify offline playback works after cache bug fix +├── Verify modal animations are smooth +└── Verify sync button shows connected state on reload +``` + +--- + +## New Files Summary + +| File | Purpose | +|------|---------| +| `www/js/crypto.js` | PBKDF2 key derivation, AES-GCM encrypt/decrypt, SHA-256 write hash | +| `www/js/sync.js` | Push/pull/merge orchestration, credential storage | +| `terraform/lambda-sync.tf` | Lambda function, IAM role, Function URL, CloudFront behavior | +| `terraform/lambda/index.mjs` | Lambda handler — GET/PUT encrypted blobs to S3 | + +## Modified Files Summary + +| File | Changes | +|------|---------| +| `www/index.html` | Rename sync-btn to offline-cache-btn, add state-sync-btn, add sync-modal | +| `www/main.css` | Add ~120 lines: sync button states, modal styles, keyframes | +| `www/js/elements.js` | Replace `syncBtn` with `offlineCacheBtn` + `stateSyncBtn`, add modal elements | +| `www/js/events.js` | Import sync module, add sync modal handlers, rename cache button binding | +| `www/js/player.js` | Rename `syncBtn` to `offlineCacheBtn` in `updateSyncUI()` | +| `www/js/ui.js` | Update `updateModeBasedUI()` for new button names | +| `www/js/pwa.js` | Fix `isTrackCached()` to check IndexedDB cache too | +| `terraform/cloudfront.tf` | Add Lambda origin + `/sync/*` cache behavior | +| `terraform/outputs.tf` | Add `sync_lambda_url` output | diff --git a/.claude/plans/magical-petting-snowglobe.md b/.claude/plans/magical-petting-snowglobe.md new file mode 100644 index 0000000..d8438a8 --- /dev/null +++ b/.claude/plans/magical-petting-snowglobe.md @@ -0,0 +1,199 @@ +# State Sharing + Offline Bug Fix + +## Context + +Crate stores favorites, heard tracks, and secret-unlocked state in browser localStorage — device-local and lost on clear. The goal is to sync this state across devices using a username + password that derives a client-side encryption key (zero-knowledge — the server never sees plaintext). Additionally, offline listening is broken due to two disconnected cache systems that need unification. + +## Architecture + +``` +Browser CloudFront Lambda S3 + | | | | + |-- PUT /sync/{username} --------->|-- /sync/* behavior ------>|-- PutObject --------->| + | (encrypted blob + write_hash) | (Lambda Function URL) | sync/{user}.json | + | | | | + |-- GET /sync/{username} --------->|-------------------------->|-- GetObject --------->| + | (returns ciphertext) | | | +``` + +**Encryption**: `password → PBKDF2(100k iters, username as salt) → AES-256-GCM` via Web Crypto API. +**Write protection**: `write_hash = SHA-256(password + username)` — stored alongside blob, validated on PUT. +**Storage**: Existing tracks S3 bucket under `sync/` prefix. No new bucket. +**Compute**: Single Lambda with Function URL, fronted by CloudFront `/sync/*` behavior. No API Gateway. + +## What Syncs + +| State | Strategy | Priority | +|-------|----------|----------| +| `favoriteTracks` (Set) | Union merge on pull, full replace on push | Must | +| `heardTracks` (Set) | Union merge (never un-hear) | Should | +| `secretUnlocked` (bool) | OR (once unlocked, stays unlocked) | Must | + +## Bug Fix: Offline Listening + +**Root cause**: Two disconnected cache systems: +- **Service Worker Cache API** (`audio-v1`) — auto-caches on play, queried by `isTrackCached()` in pwa.js +- **IndexedDB** (`crate_cache`) — populated by manual sync button, queried by `playTrack()` in player.js + +`getNextTrack()` and `filterTracks()` call `isTrackCached()` which only checks SW cache. After syncing favorites offline via the button (IndexedDB), going offline shows no playable tracks. + +**Fix**: One-line addition to `isTrackCached()` in pwa.js — also check `state.cachedTracks` (IndexedDB track IDs, already loaded at startup). + +--- + +## Tasks (5 units, T1-T4 parallelizable) + +### T1: Terraform Infrastructure +**New file**: `terraform/lambda-sync.tf` +- `aws_lambda_function.sync` — nodejs20.x, 128MB, 10s timeout +- `aws_lambda_function_url.sync` — NONE auth, CORS for crate domain +- `aws_iam_role.sync_lambda` — assume role for Lambda service +- `aws_iam_role_policy_attachment` — CloudWatch basic execution +- `aws_iam_role_policy.sync_lambda_s3` — GetObject + PutObject on `tracks-bucket/sync/*` + +**Modified**: `terraform/cloudfront.tf` +- Add Lambda Function URL as custom origin (`sync-lambda`) +- Add ordered_cache_behavior for `/sync/*` — allowed methods include PUT, `default_ttl = 0` + +**Modified**: `terraform/outputs.tf` — add `sync_lambda_url` + +### T2: Lambda Handler +**New file**: `terraform/lambda/index.mjs` (~50 lines) +- GET `/sync/{username}` → read `sync/{username}.json` from S3, return JSON +- PUT `/sync/{username}` → validate `write_hash` against existing (if any), write to S3 +- Input validation (no path traversal, required fields check) +- No node_modules needed — AWS SDK v3 built into nodejs20.x + +**Build**: `cd terraform/lambda && zip sync.zip index.mjs` + +### T3: Client Crypto + Sync Module +**New file**: `www/js/crypto.js` +- `deriveKey(password, username)` — PBKDF2 → AES-GCM CryptoKey +- `encrypt(key, plaintext)` → `{ciphertext, iv}` (base64) +- `decrypt(key, ciphertextB64, ivB64)` → plaintext string +- `generateWriteHash(password, username)` → hex SHA-256 + +**New file**: `www/js/sync.js` +- `serializeState()` — JSON of favorites + heard + secretUnlocked +- `mergeState(remote)` — union merge favorites/heard, OR secretUnlocked +- `pullState(username, password)` — GET, decrypt, merge +- `pushState(username, password)` — encrypt, PUT with write_hash +- `fullSync(username, password)` — pull then push +- `saveSyncUsername()` / `getSyncUsername()` / `clearSyncCredentials()` — localStorage + +### T4: UI — HTML, CSS, Animations +**Modified**: `www/index.html` +- Rename `#sync-btn` → `#offline-cache-btn` (existing cloud-download icon) +- Add `#state-sync-btn` (two-arrow refresh icon, SVG) in search-header, rightmost position +- Add `#sync-modal` after info-modal: form with username/password, status indicator, disconnect button + +**Modified**: `www/main.css` (~120 new lines) + +Sync button states: +- `.state-sync-btn` — hover: `rotate(30deg)`, active: `rotate(180deg) 0.15s` +- `.state-sync-btn.syncing` — `sync-spin` 0.8s `cubic-bezier(0.4, 0, 0.2, 1)` infinite +- `.state-sync-btn.connected::after` — 6px green dot, bottom-right +- `.state-sync-btn.sync-success` — green border/color flash +- `.state-sync-btn.sync-error` — red border/color flash + +Modal: +- `.sync-modal-content` — `sync-modal-enter` 0.35s `cubic-bezier(0.2, 0.8, 0.2, 1)` (slide up + scale) +- `sync-backdrop-in` — 0.25s `ease-out` fade +- Inputs: transparent bg, `1px solid --muted`, focus → `--fg` border, `Bebas Neue` font +- Submit button: `2px solid --fg`, hover inverts, active `scale(0.97)` +- **No border-radius anywhere** — brutalist aesthetic maintained + +Keyframes: +- `sync-spin` — `rotate(0→360deg)`, `cubic-bezier(0.4, 0, 0.2, 1)` 0.8s +- `sync-modal-enter` — `translateY(40px) scale(0.95) → translateY(0) scale(1)`, 0.35s +- `sync-success-flash` — `scale(1→1.15→1)`, 0.4s +- `sync-backdrop-in` — `opacity 0→1`, 0.25s + +All animations: `transform` + `opacity` only (GPU composited, no layout thrash). + +**Modified**: `www/js/elements.js` +- Replace `syncBtn` → `offlineCacheBtn`, add `stateSyncBtn` +- Add modal element refs: `syncModal`, `syncForm`, `syncUsername`, `syncPassword`, `syncError`, `syncSubmit`, `syncStatus`, `syncLogout`, `syncModalClose` + +### T5: Wiring + Offline Bug Fix (depends on T3, T4) + +**Modified**: `www/js/events.js` +- Import sync module +- Rename `syncBtn` → `offlineCacheBtn` event binding +- Add `stateSyncBtn` click → `openSyncModal()` +- Add `setupSyncModalHandlers()` — form submit, backdrop close, disconnect +- Form submit: call `fullSync()`, update button states (syncing/success/error), auto-close on success after 1.2s, refresh track list + mode UI on secret unlock + +**Modified**: `www/js/player.js` +- Rename all `elements.syncBtn` → `elements.offlineCacheBtn` in `updateSyncUI()` + +**Modified**: `www/js/ui.js` +- `updateModeBasedUI()`: `offlineCacheBtn` hidden unless secret, `stateSyncBtn` always visible + +**Modified**: `www/js/pwa.js` — offline bug fix +```javascript +// BEFORE: +export function isTrackCached(track) { + if (\!track || \!track.path) return false; + return cachedTracks.has(getMediaUrl(track.path)); +} + +// AFTER: +export function isTrackCached(track) { + if (\!track || \!track.path) return false; + if (cachedTracks.has(getMediaUrl(track.path))) return true; + if (state.cachedTracks && state.cachedTracks.has(track.id)) return true; + return false; +} +``` + +--- + +## File Summary + +### New Files (4) +| File | Purpose | +|------|---------| +| `www/js/crypto.js` | PBKDF2 key derivation, AES-GCM encrypt/decrypt, SHA-256 write hash | +| `www/js/sync.js` | Push/pull/merge orchestration, credential storage | +| `terraform/lambda-sync.tf` | Lambda, IAM, Function URL, CloudFront behavior | +| `terraform/lambda/index.mjs` | Lambda handler — GET/PUT encrypted blobs to S3 | + +### Modified Files (8) +| File | Changes | +|------|---------| +| `www/index.html` | Rename sync-btn → offline-cache-btn, add state-sync-btn + sync-modal | +| `www/main.css` | ~120 lines: sync button states, modal styles, 4 keyframe animations | +| `www/js/elements.js` | Replace syncBtn, add stateSyncBtn + modal element refs | +| `www/js/events.js` | Import sync, wire modal handlers, rename cache button binding | +| `www/js/player.js` | Rename syncBtn → offlineCacheBtn in updateSyncUI() | +| `www/js/ui.js` | Update updateModeBasedUI() for new button names | +| `www/js/pwa.js` | Fix isTrackCached() to check both cache systems | +| `terraform/cloudfront.tf` | Add Lambda origin + /sync/* behavior | + +--- + +## Verification + +1. **Terraform**: `terraform plan` shows expected resources (Lambda, IAM role, CloudFront behavior) +2. **Lambda**: Deploy zip, test with curl — `PUT /sync/testuser` then `GET /sync/testuser` +3. **Encryption roundtrip**: In browser console, verify encrypt→decrypt with same key returns original +4. **Sync flow**: Open app on two browsers, login with same creds, favorite tracks on A, sync on B → favorites appear +5. **Write protection**: Try PUT with wrong write_hash → 403 +6. **Wrong password**: Try pull with wrong password → decryption fails with clear error message +7. **Offline bug fix**: Sync favorites cache, go offline (DevTools), verify tracks still appear and auto-advance works +8. **Animations**: Modal slides up smoothly, spinner rotates during sync, green dot appears after connect +9. **Secret unlock sync**: Unlock secret on device A, sync, login on device B → secret mode activates +10. **No regression**: Existing offline cache button still works, favorites toggle still works, Konami still works + +--- + +## Credential Storage Decision + +**Store both username + password in localStorage.** No checkbox, no re-entry friction. On app load, if credentials exist → auto-pull + merge silently. KISS. + +In `sync.js`: +- `saveSyncCredentials(username, password)` — stores both +- `getSyncCredentials()` → `{username, password}` or null +- On app init: if credentials exist, `pullState()` silently in background +- On favorite toggle / heard track: debounced `pushState()` (~2s) diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl index f0d41ca..e055969 100644 --- a/terraform/.terraform.lock.hcl +++ b/terraform/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "5.100.0" constraints = "~> 5.0" hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", "h1:edXOJWE4ORX8Fm+dpVpICzMZJat4AX0VRCAy/xkcOc0=", "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", @@ -29,6 +30,7 @@ provider "registry.terraform.io/hashicorp/tls" { constraints = "~> 4.0" hashes = [ "h1:F5d6bQY8UlBo0D71Sv7CsV+3aZOFz0yeNF+vufog7h4=", + "h1:akFNuHwvrtnYMBofieoeXhPJDhYZzJVu/Q/BgZK2fgg=", "zh:0d1e7d07ac973b97fa228f46596c800de830820506ee145626f079dd6bbf8d8a", "zh:5c7e3d4348cb4861ab812973ef493814a4b224bdd3e9d534a7c8a7c992382b86", "zh:7c6d4a86cd7a4e9c1025c6b3a3a6a45dea202af85d870cddbab455fb1bd568ad", From b270ed8a66cfdfa382fb81fc9d8069017cf276b4 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Thu, 2 Apr 2026 13:22:25 -0400 Subject: [PATCH 08/18] chore: bump version to 1.1.0 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 14 ++++++++++++++ package.json | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbe5bc4..cd4cc37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.1.0] - 2026-03-31 + +### Added +- Cross-device state sync with zero-knowledge encryption (PBKDF2 + AES-256-GCM) +- Lambda sync endpoint with S3 storage (`/sync/*` via CloudFront) +- Client-side crypto module (Web Crypto API) for key derivation, encrypt/decrypt +- Sync button in player secondary controls (always visible, prompt-based auth) +- Auto-pull on app load when credentials exist; debounced push on state changes +- Play history persistence and sync across devices +- Terraform infrastructure for sync Lambda, IAM, and CloudFront behavior + +### Fixed +- Offline listening broken: unified SW Cache API and IndexedDB cache checks in `isTrackCached()` + ## [0.1.0] - 2026-02-14T01:12:36+00:00 ### Added diff --git a/package.json b/package.json index 876dadf..fc53a88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crate", - "version": "1.0.0", + "version": "1.1.0", "description": "Self-hosted music streaming PWA", "scripts": { "build": "node tools/obfuscate.js && node tools/build-config.js", From c44466fe1c953ec647f593b1c291f635a0ddcff4 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Thu, 2 Apr 2026 19:37:39 -0400 Subject: [PATCH 09/18] fix: add sync/crypto to SW shell cache, bump to shell-v2 New modules (sync.js, crypto.js) were missing from the service worker's SHELL_ASSETS list, so the old cached events.js (without sync handler) was being served via stale-while-revalidate. Bumping to shell-v2 forces a full reinstall. Also passthrough /sync/* API requests. Co-Authored-By: Claude Opus 4.6 --- www/sw.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/www/sw.js b/www/sw.js index 6020c7a..6f9eef5 100644 --- a/www/sw.js +++ b/www/sw.js @@ -4,7 +4,7 @@ * artwork (cache-first), audio (cache-first + cache-on-play) */ -const SHELL_CACHE = 'shell-v1'; +const SHELL_CACHE = 'shell-v2'; const MANIFEST_CACHE = 'manifest-v1'; const ARTWORK_CACHE = 'artwork-v1'; const AUDIO_CACHE = 'audio-v1'; @@ -31,6 +31,8 @@ const SHELL_ASSETS = [ '/js/pwa.js', '/js/state.js', '/js/storage.js', + '/js/sync.js', + '/js/crypto.js', '/js/tracks.js', '/js/ui.js', '/js/utils.js', @@ -46,6 +48,7 @@ const SHELL_ASSETS = [ function isPassthrough(url) { const path = new URL(url).pathname; if (path === '/version.txt') return true; + if (path.startsWith('/sync/')) return true; // Cross-origin (GA, fonts) handled by origin check below return false; } From a22870fef195ac9d1baed1e1d5dcc698b7c09b64 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Thu, 2 Apr 2026 20:53:13 -0400 Subject: [PATCH 10/18] feat: profile screen with stats dashboard, enter screen personalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add profile screen: username, listen time, tracks heard, favorites, last played, sync status with remediation, debug panel - Replace sync button with profile icon (person silhouette) in player - Add listen stats tracking: totalListenSeconds, totalUniqueHeard, lastPlayedAt — accumulated on play/pause/end, persisted + synced - Enter screen: returning users see "WELCOME BACK" + username, new users see credential inputs with "CONNECT" button - Stats merge strategy: max() for counters, most-recent for timestamps - Debug-in-UI skill for visual debug during development - Bump SW shell cache to v3 Co-Authored-By: Claude Opus 4.6 --- .claude/plans/magical-petting-snowglobe.md | 366 +++++++++++---------- .claude/skills/debug-in-ui.md | 65 ++++ CLAUDE.md | 1 + www/index.html | 63 +++- www/js/config.js | 1 + www/js/elements.js | 44 ++- www/js/events.js | 256 +++++++++++--- www/js/player.js | 20 +- www/js/state.js | 12 +- www/js/storage.js | 32 ++ www/js/sync.js | 23 +- www/js/tracks.js | 4 + www/js/ui.js | 3 +- www/main.css | 259 +++++++++++++++ www/sw.js | 2 +- 15 files changed, 914 insertions(+), 237 deletions(-) create mode 100644 .claude/skills/debug-in-ui.md diff --git a/.claude/plans/magical-petting-snowglobe.md b/.claude/plans/magical-petting-snowglobe.md index d8438a8..b5d1483 100644 --- a/.claude/plans/magical-petting-snowglobe.md +++ b/.claude/plans/magical-petting-snowglobe.md @@ -1,199 +1,227 @@ -# State Sharing + Offline Bug Fix +# Profile Screen + Stats Dashboard + Debug-in-UI Principle ## Context -Crate stores favorites, heard tracks, and secret-unlocked state in browser localStorage — device-local and lost on clear. The goal is to sync this state across devices using a username + password that derives a client-side encryption key (zero-knowledge — the server never sees plaintext). Additionally, offline listening is broken due to two disconnected cache systems that need unification. +The sync button (↻) is an implementation detail masquerading as a feature. Users don't think in terms of "sync" — they think in terms of "me" and "my stuff." The sync button should become a **profile screen** — a personal dashboard showing listening stats, sync health, and (during development) debug information. -## Architecture +Additionally, this work introduces a **debug-in-UI** design principle: during UI development, render debug state visually in the page itself (not just console.log), organized for easy removal when the feature is stable. -``` -Browser CloudFront Lambda S3 - | | | | - |-- PUT /sync/{username} --------->|-- /sync/* behavior ------>|-- PutObject --------->| - | (encrypted blob + write_hash) | (Lambda Function URL) | sync/{user}.json | - | | | | - |-- GET /sync/{username} --------->|-------------------------->|-- GetObject --------->| - | (returns ciphertext) | | | -``` +--- -**Encryption**: `password → PBKDF2(100k iters, username as salt) → AES-256-GCM` via Web Crypto API. -**Write protection**: `write_hash = SHA-256(password + username)` — stored alongside blob, validated on PUT. -**Storage**: Existing tracks S3 bucket under `sync/` prefix. No new bucket. -**Compute**: Single Lambda with Function URL, fronted by CloudFront `/sync/*` behavior. No API Gateway. +## Profile Screen — What It Shows + +### Identity +- **Username** (large, top, `Bebas Neue`) — from `getSyncCredentials().username` +- No profile pic, no avatar — just the name + +### Stats (computed from state + new tracking fields) +| Stat | Source | New? | +|------|--------|------| +| Tracks heard | `state.heardTracks.size` | No | +| Catalog % | `heardTracks.size / tracks.length` | No | +| Favorites count | `state.favoriteTracks.size` | No | +| Total listen time | **New**: `state.totalListenSeconds` | Yes | +| Last played | **New**: `state.lastPlayedAt` | Yes | +| Total unique tracks (non-resetting) | **New**: `state.totalUniqueHeard` | Yes | + +### Sync Status +- **Status indicator**: Connected / Disconnected / Error +- **Last synced**: timestamp from `syncedAt` (already in serialized state) +- **Sync problems**: clear text explaining issues ("Wrong password", "Server unreachable", etc.) +- **Remediation**: actionable text ("Tap to retry", "Re-enter credentials", "Check network") +- **Manual sync button**: force push/pull from this screen +- **Disconnect button**: clear credentials, return to "new user" state + +### Debug Panel (development only, organized for removal) +- All debug items wrapped in a single `
` +- Easy to hide with one CSS rule or remove the entire div +- Shows: raw sync credentials status, localStorage keys/sizes, SW cache state, last push/pull result, network state, service worker registration status -## What Syncs +--- -| State | Strategy | Priority | -|-------|----------|----------| -| `favoriteTracks` (Set) | Union merge on pull, full replace on push | Must | -| `heardTracks` (Set) | Union merge (never un-hear) | Should | -| `secretUnlocked` (bool) | OR (once unlocked, stays unlocked) | Must | +## New Data Tracking -## Bug Fix: Offline Listening +### State additions (`www/js/state.js`) +```js +totalListenSeconds: 0, // cumulative, never resets +totalUniqueHeard: 0, // cumulative, never resets (heardTracks resets on full cycle) +lastPlayedAt: null, // ISO timestamp +lastSyncResult: null, // { status, error?, timestamp } +``` -**Root cause**: Two disconnected cache systems: -- **Service Worker Cache API** (`audio-v1`) — auto-caches on play, queried by `isTrackCached()` in pwa.js -- **IndexedDB** (`crate_cache`) — populated by manual sync button, queried by `playTrack()` in player.js +### Storage additions (`www/js/storage.js`) +- `saveListenStats()` / `loadListenStats()` — persists `totalListenSeconds`, `totalUniqueHeard`, `lastPlayedAt` -`getNextTrack()` and `filterTracks()` call `isTrackCached()` which only checks SW cache. After syncing favorites offline via the button (IndexedDB), going offline shows no playable tracks. +### Sync additions (`www/js/sync.js`) +- `serializeState()` — include `totalListenSeconds`, `totalUniqueHeard`, `lastPlayedAt` +- `mergeState()` — take `max()` for counters, most-recent for timestamps -**Fix**: One-line addition to `isTrackCached()` in pwa.js — also check `state.cachedTracks` (IndexedDB track IDs, already loaded at startup). +### Player instrumentation (`www/js/player.js`) +- `playTrack()`: set `state.lastPlayedAt = new Date().toISOString()`, record `state._playStartTime = Date.now()` +- `handleTrackEnded()`: add elapsed seconds to `state.totalListenSeconds`, save +- `handlePlayPause()`: on pause, add elapsed since `_playStartTime`; on play, reset `_playStartTime` +- `markTrackHeard()` (in `tracks.js`): increment `state.totalUniqueHeard` only when ID is genuinely new --- -## Tasks (5 units, T1-T4 parallelizable) - -### T1: Terraform Infrastructure -**New file**: `terraform/lambda-sync.tf` -- `aws_lambda_function.sync` — nodejs20.x, 128MB, 10s timeout -- `aws_lambda_function_url.sync` — NONE auth, CORS for crate domain -- `aws_iam_role.sync_lambda` — assume role for Lambda service -- `aws_iam_role_policy_attachment` — CloudWatch basic execution -- `aws_iam_role_policy.sync_lambda_s3` — GetObject + PutObject on `tracks-bucket/sync/*` - -**Modified**: `terraform/cloudfront.tf` -- Add Lambda Function URL as custom origin (`sync-lambda`) -- Add ordered_cache_behavior for `/sync/*` — allowed methods include PUT, `default_ttl = 0` - -**Modified**: `terraform/outputs.tf` — add `sync_lambda_url` - -### T2: Lambda Handler -**New file**: `terraform/lambda/index.mjs` (~50 lines) -- GET `/sync/{username}` → read `sync/{username}.json` from S3, return JSON -- PUT `/sync/{username}` → validate `write_hash` against existing (if any), write to S3 -- Input validation (no path traversal, required fields check) -- No node_modules needed — AWS SDK v3 built into nodejs20.x - -**Build**: `cd terraform/lambda && zip sync.zip index.mjs` - -### T3: Client Crypto + Sync Module -**New file**: `www/js/crypto.js` -- `deriveKey(password, username)` — PBKDF2 → AES-GCM CryptoKey -- `encrypt(key, plaintext)` → `{ciphertext, iv}` (base64) -- `decrypt(key, ciphertextB64, ivB64)` → plaintext string -- `generateWriteHash(password, username)` → hex SHA-256 - -**New file**: `www/js/sync.js` -- `serializeState()` — JSON of favorites + heard + secretUnlocked -- `mergeState(remote)` — union merge favorites/heard, OR secretUnlocked -- `pullState(username, password)` — GET, decrypt, merge -- `pushState(username, password)` — encrypt, PUT with write_hash -- `fullSync(username, password)` — pull then push -- `saveSyncUsername()` / `getSyncUsername()` / `clearSyncCredentials()` — localStorage - -### T4: UI — HTML, CSS, Animations -**Modified**: `www/index.html` -- Rename `#sync-btn` → `#offline-cache-btn` (existing cloud-download icon) -- Add `#state-sync-btn` (two-arrow refresh icon, SVG) in search-header, rightmost position -- Add `#sync-modal` after info-modal: form with username/password, status indicator, disconnect button - -**Modified**: `www/main.css` (~120 new lines) - -Sync button states: -- `.state-sync-btn` — hover: `rotate(30deg)`, active: `rotate(180deg) 0.15s` -- `.state-sync-btn.syncing` — `sync-spin` 0.8s `cubic-bezier(0.4, 0, 0.2, 1)` infinite -- `.state-sync-btn.connected::after` — 6px green dot, bottom-right -- `.state-sync-btn.sync-success` — green border/color flash -- `.state-sync-btn.sync-error` — red border/color flash - -Modal: -- `.sync-modal-content` — `sync-modal-enter` 0.35s `cubic-bezier(0.2, 0.8, 0.2, 1)` (slide up + scale) -- `sync-backdrop-in` — 0.25s `ease-out` fade -- Inputs: transparent bg, `1px solid --muted`, focus → `--fg` border, `Bebas Neue` font -- Submit button: `2px solid --fg`, hover inverts, active `scale(0.97)` -- **No border-radius anywhere** — brutalist aesthetic maintained - -Keyframes: -- `sync-spin` — `rotate(0→360deg)`, `cubic-bezier(0.4, 0, 0.2, 1)` 0.8s -- `sync-modal-enter` — `translateY(40px) scale(0.95) → translateY(0) scale(1)`, 0.35s -- `sync-success-flash` — `scale(1→1.15→1)`, 0.4s -- `sync-backdrop-in` — `opacity 0→1`, 0.25s - -All animations: `transform` + `opacity` only (GPU composited, no layout thrash). - -**Modified**: `www/js/elements.js` -- Replace `syncBtn` → `offlineCacheBtn`, add `stateSyncBtn` -- Add modal element refs: `syncModal`, `syncForm`, `syncUsername`, `syncPassword`, `syncError`, `syncSubmit`, `syncStatus`, `syncLogout`, `syncModalClose` - -### T5: Wiring + Offline Bug Fix (depends on T3, T4) - -**Modified**: `www/js/events.js` -- Import sync module -- Rename `syncBtn` → `offlineCacheBtn` event binding -- Add `stateSyncBtn` click → `openSyncModal()` -- Add `setupSyncModalHandlers()` — form submit, backdrop close, disconnect -- Form submit: call `fullSync()`, update button states (syncing/success/error), auto-close on success after 1.2s, refresh track list + mode UI on secret unlock - -**Modified**: `www/js/player.js` -- Rename all `elements.syncBtn` → `elements.offlineCacheBtn` in `updateSyncUI()` - -**Modified**: `www/js/ui.js` -- `updateModeBasedUI()`: `offlineCacheBtn` hidden unless secret, `stateSyncBtn` always visible - -**Modified**: `www/js/pwa.js` — offline bug fix -```javascript -// BEFORE: -export function isTrackCached(track) { - if (\!track || \!track.path) return false; - return cachedTracks.has(getMediaUrl(track.path)); -} - -// AFTER: -export function isTrackCached(track) { - if (\!track || \!track.path) return false; - if (cachedTracks.has(getMediaUrl(track.path))) return true; - if (state.cachedTracks && state.cachedTracks.has(track.id)) return true; - return false; -} +## Tasks (5 units, T1-T3 parallelizable) + +### T1: Stats Tracking Infrastructure +**Modified files**: `state.js`, `storage.js`, `sync.js`, `config.js` + +- Add 4 new state fields +- Add `saveListenStats()` / `loadListenStats()` to storage +- Add new config keys for localStorage +- Extend `serializeState()` and `mergeState()` in sync.js +- Merge strategy: `max()` for counters, most-recent for timestamps + +### T2: Player Instrumentation +**Modified files**: `player.js`, `tracks.js` + +- Track play start time in `playTrack()` +- Accumulate listen seconds in `handleTrackEnded()` and pause handler +- Set `lastPlayedAt` on each play +- Increment `totalUniqueHeard` in `markTrackHeard()` (only for new IDs) +- Call `saveListenStats()` after mutations + +### T3: Profile Screen HTML + CSS +**Modified files**: `index.html`, `main.css` + +HTML structure: +```html +
+
+ + +

---

+ +
+
+ LISTENED + 0h 0m +
+
+ TRACKS HEARD + 0 / 0 +
+
+ FAVORITES + 0 +
+
+ LAST PLAYED + --- +
+
+ +
+
+ + Not connected +
+ +
+ + +
+
+ + <\!-- DEBUG: Remove before release --> +
+

DEBUG

+

+    
+
+
``` +CSS: +- Brutalist: no border-radius, monospace for values, `Bebas Neue` for labels +- `.profile-container` — `max-width: 400px`, centered, `padding-top: 80px` +- `.stat-row` — flex between label and value, `border-bottom: 1px solid var(--muted)` +- `.sync-status-dot` — 8px circle, green/red/yellow based on class +- `.profile-debug` — `border: 1px dashed var(--muted)`, `font-size: 11px`, monospace, slightly dimmed +- All `.profile-debug` items use a `[data-debug]` attribute for easy querySelectorAll removal + +### T4: Profile Screen Wiring +**Modified files**: `elements.js`, `events.js`, `state.js`, `ui.js`, `sw.js` + +- Add `SCREENS.PROFILE` and `SCREEN_ID_MAP['profile-screen']` +- Add element refs: `profileScreen`, `profileBackBtn`, `profileUsername`, stat elements, sync elements, debug output +- Replace sync button click handler: now opens profile screen instead of prompting +- `showScreen('profile-screen')` → populate stats, sync status, debug info +- Profile back button → `showScreen('player-screen')` +- Profile sync button → force `fullSync()` with visual feedback +- Profile disconnect → `clearSyncCredentials()`, update UI, show "enter creds" state +- Title logo: add `'profile-screen'` to the `at-top` condition in `ui.js:98` +- Bump SW shell cache to `shell-v3` + +### T5: Enter Screen Personalization +**Modified files**: `index.html`, `elements.js`, `events.js`, `main.css` + +- Add `#enter-greeting` and `#enter-username-display` elements (hidden by default) +- Add `#enter-creds` form (username + password inputs, hidden by default) +- In `init()`: if creds exist → show "WELCOME BACK" + username, button = "ENTER" +- In `init()`: if no creds → show credential inputs, button = "CONNECT" +- On CONNECT click: save creds, kick off non-blocking `fullSync()`, enter player +- On ENTER click (returning user): proceed as normal (auto-pull already fired) + +### T6: GitHub Issues +1. **Account recovery** — zero-knowledge means no server-side recovery. Options to explore: recovery codes, local export/import, "just create new account" +2. **PDS ethos: Debug-in-UI** — design principle for rendering debug state visually during development, organized for removal + +### T7: Debug-in-UI Skill +**New file**: `.claude/skills/debug-in-ui.md` + +Principle: When building UI features, render relevant debug state in the page itself — not just `console.log`. Rules: +- Wrap all debug elements in a single container with a predictable ID pattern (`#*-debug`) +- Use `data-debug` attributes on individual items +- Use `class="debug-*"` for styling (dashed borders, monospace, dimmed) +- CSS: `.debug-*` styles grouped in one block with `/* DEBUG STYLES — REMOVE */` comment +- JS: debug population logic in clearly marked functions (`populateDebugInfo()`) +- Removal checklist: delete HTML container, delete CSS block, delete JS function, grep for `debug` to verify clean + --- -## File Summary +## Files Summary -### New Files (4) +### New Files | File | Purpose | |------|---------| -| `www/js/crypto.js` | PBKDF2 key derivation, AES-GCM encrypt/decrypt, SHA-256 write hash | -| `www/js/sync.js` | Push/pull/merge orchestration, credential storage | -| `terraform/lambda-sync.tf` | Lambda, IAM, Function URL, CloudFront behavior | -| `terraform/lambda/index.mjs` | Lambda handler — GET/PUT encrypted blobs to S3 | +| `.claude/skills/debug-in-ui.md` | Design principle skill for visual debug during UI dev | -### Modified Files (8) +### Modified Files | File | Changes | |------|---------| -| `www/index.html` | Rename sync-btn → offline-cache-btn, add state-sync-btn + sync-modal | -| `www/main.css` | ~120 lines: sync button states, modal styles, 4 keyframe animations | -| `www/js/elements.js` | Replace syncBtn, add stateSyncBtn + modal element refs | -| `www/js/events.js` | Import sync, wire modal handlers, rename cache button binding | -| `www/js/player.js` | Rename syncBtn → offlineCacheBtn in updateSyncUI() | -| `www/js/ui.js` | Update updateModeBasedUI() for new button names | -| `www/js/pwa.js` | Fix isTrackCached() to check both cache systems | -| `terraform/cloudfront.tf` | Add Lambda origin + /sync/* behavior | +| `www/index.html` | Profile screen HTML, enter screen greeting + creds form | +| `www/main.css` | ~100 lines: profile layout, stat rows, sync status, debug panel, enter creds | +| `www/js/state.js` | Add `SCREENS.PROFILE`, 4 new tracking fields | +| `www/js/storage.js` | `saveListenStats()` / `loadListenStats()` | +| `www/js/sync.js` | Extend serialize/merge with new stats fields | +| `www/js/config.js` | New localStorage key for listen stats | +| `www/js/player.js` | Listen time tracking, lastPlayedAt, play start time | +| `www/js/tracks.js` | `totalUniqueHeard` increment in markTrackHeard | +| `www/js/elements.js` | ~15 new element refs (profile + enter screen) | +| `www/js/events.js` | Profile screen navigation, enter flow personalization, sync → profile redirect | +| `www/js/ui.js` | Add profile to SCREEN_ID_MAP, title logo handling | +| `www/sw.js` | Bump to shell-v3 (no new JS module needed — profile logic lives in events.js) | --- ## Verification -1. **Terraform**: `terraform plan` shows expected resources (Lambda, IAM role, CloudFront behavior) -2. **Lambda**: Deploy zip, test with curl — `PUT /sync/testuser` then `GET /sync/testuser` -3. **Encryption roundtrip**: In browser console, verify encrypt→decrypt with same key returns original -4. **Sync flow**: Open app on two browsers, login with same creds, favorite tracks on A, sync on B → favorites appear -5. **Write protection**: Try PUT with wrong write_hash → 403 -6. **Wrong password**: Try pull with wrong password → decryption fails with clear error message -7. **Offline bug fix**: Sync favorites cache, go offline (DevTools), verify tracks still appear and auto-advance works -8. **Animations**: Modal slides up smoothly, spinner rotates during sync, green dot appears after connect -9. **Secret unlock sync**: Unlock secret on device A, sync, login on device B → secret mode activates -10. **No regression**: Existing offline cache button still works, favorites toggle still works, Konami still works - ---- - -## Credential Storage Decision - -**Store both username + password in localStorage.** No checkbox, no re-entry friction. On app load, if credentials exist → auto-pull + merge silently. KISS. - -In `sync.js`: -- `saveSyncCredentials(username, password)` — stores both -- `getSyncCredentials()` → `{username, password}` or null -- On app init: if credentials exist, `pullState()` silently in background -- On favorite toggle / heard track: debounced `pushState()` (~2s) +1. **New user**: Clear localStorage → see username/password inputs + "CONNECT" +2. **Connect**: Fill creds, click CONNECT → creds saved, player starts, background sync +3. **Returning user**: Reload → "WELCOME BACK" + username, "ENTER" button +4. **Profile screen**: Tap sync/profile icon → see name, stats, sync status +5. **Stats update**: Play tracks → listen time increments, heard count grows, last played updates +6. **Stats persist**: Reload → stats survive (localStorage) +7. **Stats sync**: Sync from device A → pull on device B → stats merge (max counters) +8. **Sync status**: Profile shows green dot when connected, error details on failure +9. **Force sync**: Tap "SYNC NOW" on profile → spinner → success/error feedback +10. **Disconnect**: Tap disconnect → creds cleared, next reload shows new user state +11. **Debug panel**: Visible during dev, shows localStorage state, SW status, sync result +12. **Debug removal**: Delete `#profile-debug` div + CSS block + JS function → clean removal +13. **Konami**: Still works on enter screen (inputs don't capture arrow keys) diff --git a/.claude/skills/debug-in-ui.md b/.claude/skills/debug-in-ui.md new file mode 100644 index 0000000..e268ee5 --- /dev/null +++ b/.claude/skills/debug-in-ui.md @@ -0,0 +1,65 @@ +# Debug-in-UI + +When building UI features, render relevant debug state **visually in the page** — not just in console.log. This gives immediate feedback during development and makes state bugs visible at a glance. + +## Principle + +Debug information should be: +- **Visible** — rendered in the actual UI, not hidden in devtools +- **Organized** — grouped in a single container per feature/screen +- **Removable** — structured for clean deletion when the feature stabilizes + +## Implementation Pattern + +### HTML +Wrap all debug elements in a single container with a predictable ID: + + +
+

DEBUG

+

+    
+ +Use data-debug attributes on individual items when scattered across the page: + + connected + +### CSS +Group all debug styles in one clearly marked block: + + /* DEBUG STYLES — REMOVE BEFORE RELEASE */ + .feature-debug { border: 1px dashed var(--muted); padding: 16px; opacity: 0.7; } + .debug-heading { /* ... */ } + .debug-output { /* ... */ } + /* END DEBUG STYLES */ + +### JavaScript +Isolate debug population in clearly named functions: + + /** DEBUG: Remove before release */ + function populateDebugInfo() { + const output = document.getElementById('debug-output'); + if (!output) return; + output.textContent = JSON.stringify({ key: 'value' }, null, 2); + } + +## Removal Checklist + +When the feature is stable: +1. Delete the HTML debug container +2. Delete the CSS block between DEBUG STYLES markers +3. Delete the JS populateDebugInfo() function and call sites +4. Remove any data-debug attributes +5. Grep for "debug" to verify clean removal + +## When to Use + +- Always when building new UI screens or features +- Always when implementing state sync, caching, or offline features +- Always when the feature has complex state that could silently break + +## When NOT to Use + +- Simple styling changes with no state +- Backend-only changes +- Changes where automated tests provide sufficient coverage diff --git a/CLAUDE.md b/CLAUDE.md index 6f7d054..0643144 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ AI-assisted development methodology. Skills for consistency. Agents for scale. | `/commit` | Before any git commit | | `/review` | Before submitting or reviewing PRs | | `/debug` | When troubleshooting issues | +| `/debug-in-ui` | Visual debug rendering during UI development | | `/test` | Writing or running tests | | `/design` | Architecture decisions, new features | | `/worktree` | Branch isolation, parallel work | diff --git a/www/index.html b/www/index.html index 8ee892a..b9c1a2e 100644 --- a/www/index.html +++ b/www/index.html @@ -46,6 +46,12 @@

Crate

+ + + @@ -126,13 +132,8 @@

Crate

- @@ -171,6 +172,54 @@

Crate

+ +
+
+ + +

---

+ +
+
+ LISTENED + 0h 0m +
+
+ TRACKS HEARD + 0 / 0 +
+
+ FAVORITES + 0 +
+
+ LAST PLAYED + --- +
+
+ +
+
+ + Not connected +
+

+
+ + +
+
+ + +
+

DEBUG

+

+        
+
+
+ @@ -64,6 +58,7 @@

Crate

+
@@ -200,15 +195,32 @@

---

+ + + +
-
- - Not connected + + -

-
- - + + +
diff --git a/www/js/circles.js b/www/js/circles.js new file mode 100644 index 0000000..9ccae1e --- /dev/null +++ b/www/js/circles.js @@ -0,0 +1,113 @@ +/** + * Dante's Circles — smoke theme progression system + * @module circles + */ + +import { SITE } from './site.config.js'; + +/** + * Circle definitions with thresholds and default colors. + * Colors are arrays of 3 rgba strings for --smoke-1, --smoke-2, --smoke-3. + * Site config can override via SITE.theme.circles.{id} + */ +const DEFAULT_COLORS = { + limbo: ['rgba(255,255,255,0.25)', 'rgba(255,255,255,0.22)', 'rgba(255,255,255,0.18)'], + lust: ['rgba(200,50,80,0.25)', 'rgba(180,40,70,0.22)', 'rgba(160,30,60,0.18)'], + gluttony: ['rgba(212,175,55,0.25)', 'rgba(190,155,45,0.22)', 'rgba(170,140,35,0.18)'], + greed: ['rgba(50,180,80,0.25)', 'rgba(40,160,70,0.22)', 'rgba(30,140,60,0.18)'], + wrath: ['rgba(220,30,30,0.25)', 'rgba(200,20,20,0.22)', 'rgba(180,15,15,0.18)'], + heresy: ['rgba(230,120,20,0.25)', 'rgba(210,100,15,0.22)', 'rgba(190,85,10,0.18)'], + violence: ['rgba(140,20,30,0.25)', 'rgba(120,15,25,0.22)', 'rgba(100,10,20,0.18)'], + fraud: ['rgba(120,40,180,0.25)', 'rgba(100,30,160,0.22)', 'rgba(85,20,140,0.18)'], + treachery: ['rgba(100,180,255,0.25)', 'rgba(80,160,235,0.22)', 'rgba(60,140,215,0.18)'], +}; + +export const CIRCLES = [ + { id: 'limbo', threshold: 0 }, + { id: 'lust', threshold: 5 }, + { id: 'gluttony', threshold: 12 }, + { id: 'greed', threshold: 20 }, + { id: 'wrath', threshold: 35 }, + { id: 'heresy', threshold: 50 }, + { id: 'violence', threshold: 65 }, + { id: 'fraud', threshold: 80 }, + { id: 'treachery', threshold: 100 }, +]; + +/** + * Get colors for a circle, checking site config overrides first + * @param {string} circleId + * @returns {string[]} Array of 3 rgba color strings + */ +function getCircleColors(circleId) { + // Check site config override + const siteColors = SITE.theme?.circles?.[circleId]; + if (siteColors) { + // Site config can provide a single color string or array of 3 + if (Array.isArray(siteColors)) return siteColors; + // Single color — derive variants with slightly lower opacity + return [siteColors, siteColors.replace(/[\d.]+\)$/, m => (parseFloat(m) * 0.88).toFixed(2) + ')'), siteColors.replace(/[\d.]+\)$/, m => (parseFloat(m) * 0.72).toFixed(2) + ')')]; + } + return DEFAULT_COLORS[circleId] || DEFAULT_COLORS.limbo; +} + +/** + * Get the current circle based on unique tracks heard + * @param {number} uniqueHeard + * @returns {Object} Circle object {id, threshold} + */ +export function getCurrentCircle(uniqueHeard) { + let current = CIRCLES[0]; + for (const circle of CIRCLES) { + if (uniqueHeard >= circle.threshold) { + current = circle; + } + } + return current; +} + +/** + * Get all unlocked circles + * @param {number} uniqueHeard + * @returns {Object[]} Array of unlocked circle objects + */ +export function getUnlockedCircles(uniqueHeard) { + return CIRCLES.filter(c => uniqueHeard >= c.threshold); +} + +/** + * Apply a circle's smoke theme by setting CSS custom properties + * @param {string} circleId - Circle ID to apply + */ +export function applyCircleTheme(circleId) { + const colors = getCircleColors(circleId || 'limbo'); + const root = document.documentElement; + root.style.setProperty('--smoke-1', colors[0]); + root.style.setProperty('--smoke-2', colors[1]); + root.style.setProperty('--smoke-3', colors[2]); +} + +/** + * Check if a new circle was unlocked between prev and new heard counts + * @param {number} prevHeard - Previous totalUniqueHeard + * @param {number} newHeard - New totalUniqueHeard + * @returns {Object|null} Newly unlocked circle, or null + */ +export function checkCircleAdvancement(prevHeard, newHeard) { + const prevCircle = getCurrentCircle(prevHeard); + const newCircle = getCurrentCircle(newHeard); + if (newCircle.id !== prevCircle.id) { + return newCircle; + } + return null; +} + +/** + * Get circle index (0-8) for a given circle ID + * @param {string} circleId + * @returns {number} + */ +export function getCircleIndex(circleId) { + const idx = CIRCLES.findIndex(c => c.id === circleId); + return idx >= 0 ? idx : 0; +} diff --git a/www/js/elements.js b/www/js/elements.js index 2a2e890..3026200 100644 --- a/www/js/elements.js +++ b/www/js/elements.js @@ -37,6 +37,7 @@ export const elements = { konamiProgress: null, artworkContainer: null, artworkImage: null, + artworkCanvas: null, passwordContainer: null, passwordInput: null, passwordError: null, @@ -84,12 +85,15 @@ export const elements = { profileSyncBtn: null, profileDisconnectBtn: null, debugOutput: null, - // Enter screen personalization - enterGreeting: null, - enterUsernameDisplay: null, - enterCreds: null, - enterUserInput: null, - enterPassInput: null + // Circle progress + circleProgress: null, + circleMarks: null, + // Profile credentials form + profileCreds: null, + profileCredsUsername: null, + profileCredsPassword: null, + profileConnectBtn: null, + profileSyncInfo: null, }; /** @@ -126,6 +130,7 @@ export function initElements() { elements.konamiProgress = document.getElementById('konami-progress'); elements.artworkContainer = document.getElementById('artwork-container'); elements.artworkImage = document.getElementById('artwork-image'); + elements.artworkCanvas = document.getElementById('artwork-canvas'); elements.passwordContainer = document.getElementById('password-container'); elements.passwordInput = document.getElementById('password-input'); elements.passwordError = document.getElementById('password-error'); @@ -172,9 +177,11 @@ export function initElements() { elements.profileSyncBtn = document.getElementById('profile-sync-btn'); elements.profileDisconnectBtn = document.getElementById('profile-disconnect-btn'); elements.debugOutput = document.getElementById('debug-output'); - elements.enterGreeting = document.getElementById('enter-greeting'); - elements.enterUsernameDisplay = document.getElementById('enter-username-display'); - elements.enterCreds = document.getElementById('enter-creds'); - elements.enterUserInput = document.getElementById('enter-user-input'); - elements.enterPassInput = document.getElementById('enter-pass-input'); + elements.circleProgress = document.getElementById('circle-progress'); + elements.circleMarks = document.getElementById('circle-marks'); + elements.profileCreds = document.getElementById('profile-creds'); + elements.profileCredsUsername = document.getElementById('profile-creds-username'); + elements.profileCredsPassword = document.getElementById('profile-creds-password'); + elements.profileConnectBtn = document.getElementById('profile-connect-btn'); + elements.profileSyncInfo = document.getElementById('profile-sync-info'); } diff --git a/www/js/events.js b/www/js/events.js index 4625934..dd6ee46 100644 --- a/www/js/events.js +++ b/www/js/events.js @@ -7,7 +7,7 @@ import { MODES } from './config.js'; import { SITE } from './site.config.js'; import { state, isSecretMode } from './state.js'; import { elements, initElements } from './elements.js'; -import { getSecretUnlocked, setSecretUnlocked } from './storage.js'; +import { getSecretUnlocked, setSecretUnlocked, saveListenStats } from './storage.js'; import { clearAllCookies } from './cookies.js'; import { trackEvent } from './analytics.js'; import { getTrackPathFromHash } from './hash.js'; @@ -52,6 +52,7 @@ import { } from './player.js'; import { initPWA, setOfflineChangeCallback } from './pwa.js'; import { fullSync, getSyncCredentials, saveSyncCredentials, clearSyncCredentials, pullState } from './sync.js'; +import { getCurrentCircle, getUnlockedCircles, applyCircleTheme, getCircleIndex, CIRCLES } from './circles.js'; // Set up cross-module function references setStartPlayerFn(startPlayer); @@ -226,18 +227,18 @@ function setupPlayerSwipeHandlers() { * Setup long-press to download on artwork (desktop only) */ function setupArtworkLongPress() { - if (!elements.artworkImage) return; + if (!elements.artworkContainer) return; let pressTimer = null; - elements.artworkImage.addEventListener('mousedown', (e) => { + elements.artworkContainer.addEventListener('mousedown', (e) => { if (!isSecretMode() || !state.currentTrack) return; e.preventDefault(); - elements.artworkImage.classList.add('holding'); + elements.artworkContainer.classList.add('holding'); pressTimer = setTimeout(() => { state.artworkLongPressTriggered = true; - elements.artworkImage.classList.remove('holding'); + elements.artworkContainer.classList.remove('holding'); downloadTrack(state.currentTrack, 'long_press'); }, 1500); }); @@ -245,11 +246,11 @@ function setupArtworkLongPress() { function cancelPress() { clearTimeout(pressTimer); pressTimer = null; - elements.artworkImage.classList.remove('holding'); + elements.artworkContainer.classList.remove('holding'); } - elements.artworkImage.addEventListener('mouseup', cancelPress); - elements.artworkImage.addEventListener('mouseleave', cancelPress); + elements.artworkContainer.addEventListener('mouseup', cancelPress); + elements.artworkContainer.addEventListener('mouseleave', cancelPress); } /** @@ -603,38 +604,74 @@ function populateProfile() { } } - // Sync status - if (elements.profileSyncDot) { - elements.profileSyncDot.className = 'sync-status-dot'; - if (creds) { + // Circle progress + if (elements.circleProgress && elements.circleMarks) { + const currentCircle = state.currentCircle || getCurrentCircle(state.totalUniqueHeard).id; + const unlocked = getUnlockedCircles(state.totalUniqueHeard); + const unlockedIds = new Set(unlocked.map(c => c.id)); + + elements.circleMarks.innerHTML = CIRCLES.map(circle => { + const isUnlocked = unlockedIds.has(circle.id); + const isCurrent = circle.id === currentCircle; + const classes = ['circle-mark']; + if (isUnlocked) classes.push('unlocked'); + if (isCurrent) classes.push('current'); + return `
`; + }).join(''); + + elements.circleProgress.classList.remove('hidden'); + + // Bind click handlers for unlocked marks + elements.circleMarks.querySelectorAll('.circle-mark.unlocked').forEach(mark => { + mark.addEventListener('click', () => { + const circleId = mark.dataset.circle; + state.currentCircle = circleId; + applyCircleTheme(circleId); + saveListenStats(); + populateProfile(); // Re-render to update current highlight + }); + }); + } + + // Sync section: show creds form or sync info + if (creds) { + // Connected — show sync info, hide creds form + if (elements.profileCreds) elements.profileCreds.classList.add('hidden'); + if (elements.profileSyncInfo) elements.profileSyncInfo.classList.remove('hidden'); + + // Sync status + if (elements.profileSyncDot) { + elements.profileSyncDot.className = 'sync-status-dot'; elements.profileSyncDot.classList.add(state.lastSyncResult?.status === 'error' ? 'error' : 'connected'); } - } - if (elements.profileSyncStatus) { - if (!creds) { - elements.profileSyncStatus.textContent = 'Not connected'; - } else if (state.lastSyncResult?.status === 'error') { - elements.profileSyncStatus.textContent = 'Sync error'; - } else { - elements.profileSyncStatus.textContent = 'Connected'; + if (elements.profileSyncStatus) { + if (state.lastSyncResult?.status === 'error') { + elements.profileSyncStatus.textContent = 'Sync error'; + } else { + elements.profileSyncStatus.textContent = 'Connected'; + } } - } - if (elements.profileSyncDetail) { - if (state.lastSyncResult?.error) { - elements.profileSyncDetail.textContent = state.lastSyncResult.error; - } else if (state.lastSyncResult?.status === 'ok') { - elements.profileSyncDetail.textContent = 'Last sync: ' + new Date().toLocaleTimeString(); - } else { - elements.profileSyncDetail.textContent = ''; + if (elements.profileSyncDetail) { + if (state.lastSyncResult?.error) { + elements.profileSyncDetail.textContent = state.lastSyncResult.error; + } else if (state.lastSyncResult?.status === 'ok') { + elements.profileSyncDetail.textContent = 'Last sync: ' + new Date().toLocaleTimeString(); + } else { + elements.profileSyncDetail.textContent = ''; + } } + } else { + // Not connected — show creds form, hide sync info + if (elements.profileCreds) elements.profileCreds.classList.remove('hidden'); + if (elements.profileSyncInfo) elements.profileSyncInfo.classList.add('hidden'); } // Show/hide sync actions based on connection state if (elements.profileSyncBtn) { - elements.profileSyncBtn.textContent = creds ? 'SYNC NOW' : 'CONNECT'; + elements.profileSyncBtn.textContent = 'SYNC NOW'; } if (elements.profileDisconnectBtn) { - elements.profileDisconnectBtn.style.display = creds ? '' : 'none'; + elements.profileDisconnectBtn.style.display = ''; } // DEBUG: populate debug info @@ -651,7 +688,8 @@ function populateDebugInfo() { stats: { totalListenSeconds: state.totalListenSeconds, totalUniqueHeard: state.totalUniqueHeard, - lastPlayedAt: state.lastPlayedAt + lastPlayedAt: state.lastPlayedAt, + currentCircle: state.currentCircle }, state: { heardTracks: state.heardTracks.size, @@ -695,25 +733,10 @@ export function init() { state.pendingTrackPath = getTrackPathFromHash(); // Bind event listeners - async function handleEnterWithCreds() { - // If new user with credential inputs visible - if (elements.enterCreds && !elements.enterCreds.classList.contains('hidden')) { - const username = elements.enterUserInput?.value?.trim(); - const password = elements.enterPassInput?.value; - if (username && password) { - saveSyncCredentials(username, password); - // Non-blocking sync after enter - fullSync(username, password).then(result => { - state.lastSyncResult = result; - }).catch(() => {}); - } - } - handleEnter(); - } - elements.enterBtn.addEventListener('click', handleEnterWithCreds); + elements.enterBtn.addEventListener('click', handleEnter); elements.enterBtn.addEventListener('touchend', (e) => { e.preventDefault(); - handleEnterWithCreds(); + handleEnter(); }); if (elements.backBtn) { elements.backBtn.addEventListener('click', playPreviousTrack); @@ -827,53 +850,66 @@ export function init() { }); } - // Profile sync button — connect or force sync + // Profile sync button — force sync (only visible when connected) if (elements.profileSyncBtn) { elements.profileSyncBtn.addEventListener('click', async () => { const creds = getSyncCredentials(); + if (!creds) return; // Shouldn't happen — button only visible when connected - if (creds) { - // Force sync - elements.profileSyncBtn.classList.add('syncing'); - elements.profileSyncBtn.textContent = 'SYNCING...'; - if (elements.profileSyncDot) elements.profileSyncDot.className = 'sync-status-dot syncing'; + elements.profileSyncBtn.classList.add('syncing'); + elements.profileSyncBtn.textContent = 'SYNCING...'; + if (elements.profileSyncDot) elements.profileSyncDot.className = 'sync-status-dot syncing'; - const result = await fullSync(creds.username, creds.password); - state.lastSyncResult = result; + const result = await fullSync(creds.username, creds.password); + state.lastSyncResult = result; - elements.profileSyncBtn.classList.remove('syncing'); - populateProfile(); + elements.profileSyncBtn.classList.remove('syncing'); + populateProfile(); - if (result.status === 'ok' && result.pullResult?.details?.secretChanged) { + if (result.status === 'ok' && result.pullResult?.details?.secretChanged) { + updateModeBasedUI(); + } + }); + } + + // Profile connect button — inline credential form + if (elements.profileConnectBtn) { + elements.profileConnectBtn.addEventListener('click', async () => { + const username = elements.profileCredsUsername?.value?.trim(); + const password = elements.profileCredsPassword?.value; + + if (!username || !password) return; + + elements.profileConnectBtn.classList.add('syncing'); + elements.profileConnectBtn.textContent = 'CONNECTING...'; + + const result = await fullSync(username, password); + state.lastSyncResult = result; + + elements.profileConnectBtn.classList.remove('syncing'); + elements.profileConnectBtn.textContent = 'CONNECT'; + + if (result.status === 'ok') { + saveSyncCredentials(username, password); + if (result.pullResult?.details?.secretChanged) { updateModeBasedUI(); } } else { - // No credentials — prompt - const username = prompt('SYNC USERNAME'); - if (!username) return; - const password = prompt('SYNC PASSWORD'); - if (!password) return; - - elements.profileSyncBtn.classList.add('syncing'); - elements.profileSyncBtn.textContent = 'CONNECTING...'; - if (elements.profileSyncDot) elements.profileSyncDot.className = 'sync-status-dot syncing'; - - const result = await fullSync(username.trim(), password); - state.lastSyncResult = result; - - elements.profileSyncBtn.classList.remove('syncing'); - - if (result.status === 'ok') { - saveSyncCredentials(username.trim(), password); - if (result.pullResult?.details?.secretChanged) { - updateModeBasedUI(); - } - } else { - alert(result.error || 'Connection failed'); - } - - populateProfile(); + // Show error inline — briefly flash the input border red + elements.profileCredsUsername.style.borderBottomColor = '#f87171'; + elements.profileCredsPassword.style.borderBottomColor = '#f87171'; + setTimeout(() => { + elements.profileCredsUsername.style.borderBottomColor = ''; + elements.profileCredsPassword.style.borderBottomColor = ''; + }, 2000); + return; } + + // Clear inputs + if (elements.profileCredsUsername) elements.profileCredsUsername.value = ''; + if (elements.profileCredsPassword) elements.profileCredsPassword.value = ''; + + populateProfile(); }); } @@ -943,18 +979,9 @@ export function init() { filterTracks(state.searchQuery); }); - // Enter screen personalization + // Auto-sync on load if credentials exist const syncCreds = getSyncCredentials(); if (syncCreds) { - // Returning user — show welcome - if (elements.enterGreeting) { - elements.enterGreeting.classList.remove('hidden'); - } - if (elements.enterUsernameDisplay) { - elements.enterUsernameDisplay.textContent = syncCreds.username; - elements.enterUsernameDisplay.classList.remove('hidden'); - } - // Auto-pull in background pullState(syncCreds.username, syncCreds.password).then(result => { state.lastSyncResult = result; if (result.status === 'merged' && result.details) { @@ -966,13 +993,5 @@ export function init() { } } }).catch(() => {}); - } else { - // New user — show credential inputs - if (elements.enterCreds) { - elements.enterCreds.classList.remove('hidden'); - } - if (elements.enterBtn) { - elements.enterBtn.textContent = 'CONNECT'; - } } } diff --git a/www/js/genart.js b/www/js/genart.js new file mode 100644 index 0000000..406f9a4 --- /dev/null +++ b/www/js/genart.js @@ -0,0 +1,97 @@ +/** + * Generative artwork for tracks without cover art + * @module genart + */ + +let animationId = null; +let currentCanvas = null; + +/** + * Simple string hash → number + */ +function hash(str) { + let h = 0; + for (let i = 0; i < str.length; i++) { + h = ((h << 5) - h + str.charCodeAt(i)) | 0; + } + return Math.abs(h); +} + +/** + * Derive HSL color from hash with constrained saturation/lightness + */ +function hashColor(seed, offset) { + const hue = (seed + offset * 137) % 360; + const sat = 30 + (seed + offset * 53) % 40; // 30-70% + const lit = 15 + (seed + offset * 29) % 25; // 15-40% — keep dark + return `hsl(${hue}, ${sat}%, ${lit}%)`; +} + +/** + * Start generative art animation on a canvas + * @param {HTMLCanvasElement} canvas + * @param {Object} track - Track object with title, artist, album + */ +export function startGenArt(canvas, track) { + stopGenArt(); + currentCanvas = canvas; + + const ctx = canvas.getContext('2d'); + const size = canvas.width; + const seed = hash(`${track.title || ''}${track.artist || ''}${track.album || ''}`); + + // Derive 3 colors and blob parameters from seed + const colors = [hashColor(seed, 0), hashColor(seed, 1), hashColor(seed, 2)]; + const blobCount = 3 + (seed % 3); // 3-5 blobs + const blobs = []; + + for (let i = 0; i < blobCount; i++) { + const s = seed + i * 1000; + blobs.push({ + cx: (s % 100) / 100, // center x (0-1) + cy: ((s >> 3) % 100) / 100, // center y (0-1) + rx: 0.15 + ((s >> 6) % 30) / 100, // radius x + ry: 0.15 + ((s >> 9) % 30) / 100, // radius y + speed: 0.0003 + ((s >> 12) % 10) / 30000, // animation speed + phase: ((s >> 15) % 100) / 100 * Math.PI * 2, + color: colors[i % colors.length] + }); + } + + function draw(time) { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, size, size); + + for (const blob of blobs) { + const t = time * blob.speed + blob.phase; + const x = (blob.cx + Math.sin(t) * 0.15) * size; + const y = (blob.cy + Math.cos(t * 0.7) * 0.15) * size; + const rx = blob.rx * size * (0.8 + Math.sin(t * 1.3) * 0.2); + const ry = blob.ry * size * (0.8 + Math.cos(t * 0.9) * 0.2); + + const gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.max(rx, ry)); + gradient.addColorStop(0, blob.color); + gradient.addColorStop(1, 'transparent'); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.ellipse(x, y, rx, ry, t * 0.5, 0, Math.PI * 2); + ctx.fill(); + } + + animationId = requestAnimationFrame(draw); + } + + animationId = requestAnimationFrame(draw); +} + +/** + * Stop generative art animation + */ +export function stopGenArt() { + if (animationId) { + cancelAnimationFrame(animationId); + animationId = null; + } + currentCanvas = null; +} diff --git a/www/js/konami.js b/www/js/konami.js index fbf4561..2a7b3e2 100644 --- a/www/js/konami.js +++ b/www/js/konami.js @@ -122,40 +122,58 @@ function fireKonamiReward() { } /** - * Show cash rain animation + * Show cash rain animation — deep 3D pour */ export function showCashRain() { const overlay = document.createElement('div'); overlay.className = 'cash-rain'; - // $100 bill textures const billImages = ['/img/benj_front.jpeg', '/img/benj_back.jpeg']; - const sizes = [ - { w: 50, h: 21 }, - { w: 70, h: 30 }, - { w: 90, h: 38 } + + // Size tiers for parallax depth (small = far away, large = close) + const sizeTiers = [ + { w: 30, h: 13, opacity: 0.4, zIndex: 1 }, // far background + { w: 45, h: 19, opacity: 0.6, zIndex: 2 }, // mid-back + { w: 60, h: 25, opacity: 0.75, zIndex: 3 }, // middle + { w: 80, h: 34, opacity: 0.9, zIndex: 4 }, // mid-front + { w: 100, h: 42, opacity: 1, zIndex: 5 }, // foreground ]; - const numBills = 80; - // Create 3 waves of falling bills - for (let wave = 0; wave < 3; wave++) { - const waveDelay = wave * 0.4; - const billsInWave = Math.floor(numBills / 3); + const totalBills = 150; + const numWaves = 5; + const billsPerWave = Math.floor(totalBills / numWaves); + + for (let wave = 0; wave < numWaves; wave++) { + const waveDelay = wave * 0.3; - for (let i = 0; i < billsInWave; i++) { + for (let i = 0; i < billsPerWave; i++) { const bill = document.createElement('div'); bill.className = 'bill'; - const size = sizes[Math.floor(Math.random() * sizes.length)]; + // Weight distribution: more small bills (far) than large (close) + const tierRoll = Math.random(); + let tier; + if (tierRoll < 0.3) tier = sizeTiers[0]; // 30% tiny + else if (tierRoll < 0.55) tier = sizeTiers[1]; // 25% small + else if (tierRoll < 0.75) tier = sizeTiers[2]; // 20% medium + else if (tierRoll < 0.9) tier = sizeTiers[3]; // 15% large + else tier = sizeTiers[4]; // 10% huge + const img = billImages[Math.floor(Math.random() * billImages.length)]; - bill.style.setProperty('--bill-width', size.w + 'px'); - bill.style.setProperty('--bill-height', size.h + 'px'); + bill.style.setProperty('--bill-width', tier.w + 'px'); + bill.style.setProperty('--bill-height', tier.h + 'px'); bill.style.setProperty('--bill-img', `url(${img})`); + bill.style.setProperty('--bill-opacity', tier.opacity); + bill.style.zIndex = tier.zIndex; bill.style.left = (-10 + Math.random() * 120) + 'vw'; - bill.style.setProperty('--fall-duration', (2 + Math.random() * 1.5) + 's'); + + // Slower fall for small (far) bills, faster for large (close) + const baseDuration = 2 + (5 - tier.zIndex) * 0.4; + bill.style.setProperty('--fall-duration', (baseDuration + Math.random() * 1.5) + 's'); bill.style.setProperty('--fall-delay', (waveDelay + Math.random() * 0.5) + 's'); - // Random spin amounts + + // Random 3D tumble bill.style.setProperty('--spin-x', (360 + Math.random() * 720) * (Math.random() > 0.5 ? 1 : -1) + 'deg'); bill.style.setProperty('--spin-y', (360 + Math.random() * 720) * (Math.random() > 0.5 ? 1 : -1) + 'deg'); bill.style.setProperty('--spin-z', (180 + Math.random() * 360) * (Math.random() > 0.5 ? 1 : -1) + 'deg'); @@ -166,7 +184,7 @@ export function showCashRain() { } document.body.appendChild(overlay); - setTimeout(() => overlay.remove(), 3000); + setTimeout(() => overlay.remove(), 4500); } /** diff --git a/www/js/player.js b/www/js/player.js index 0728821..f4f9e43 100644 --- a/www/js/player.js +++ b/www/js/player.js @@ -32,6 +32,8 @@ import { setUpdateCatalogProgressFn } from './tracks.js'; import { debouncedPush } from './sync.js'; +import { applyCircleTheme, getCurrentCircle } from './circles.js'; +import { startGenArt, stopGenArt } from './genart.js'; // Track current blob URL for cleanup let currentBlobUrl = null; @@ -116,6 +118,7 @@ export function updateArtwork(track) { if (!elements.artworkContainer || !elements.artworkImage) return; if (track.artwork) { + stopGenArt(); elements.artworkImage.src = getMediaUrl(track.artwork); elements.artworkImage.alt = `${track.artist || 'Unknown'} - ${track.album || 'Unknown'}`; elements.artworkContainer.classList.remove('no-art'); @@ -123,6 +126,10 @@ export function updateArtwork(track) { elements.artworkImage.src = ''; elements.artworkImage.alt = ''; elements.artworkContainer.classList.add('no-art'); + // Start generative art placeholder + if (elements.artworkCanvas) { + startGenArt(elements.artworkCanvas, track); + } } } @@ -165,7 +172,7 @@ function searchFor(query) { * Setup clickable metadata for search */ function setupClickableMetadata() { - const clickables = [elements.artist, elements.artworkImage]; + const clickables = [elements.artist, elements.artworkContainer]; if (isSecretMode()) { // Add clickable class and handlers in secret mode @@ -173,8 +180,8 @@ function setupClickableMetadata() { if (el) el.classList.add('clickable'); }); elements.artist.onclick = () => searchFor(state.currentTrack?.artist); - if (elements.artworkImage) { - elements.artworkImage.onclick = () => { + if (elements.artworkContainer) { + elements.artworkContainer.onclick = () => { if (state.artworkLongPressTriggered) { state.artworkLongPressTriggered = false; return; @@ -640,6 +647,10 @@ export async function startPlayer() { loadFavoriteTracks(); loadPlayHistory(); loadListenStats(); + // Restore saved circle theme + const circleToApply = state.currentCircle || getCurrentCircle(state.totalUniqueHeard).id; + state.currentCircle = circleToApply; + applyCircleTheme(circleToApply); updateCatalogProgress(); // Load cached track IDs for offline indicators diff --git a/www/js/state.js b/www/js/state.js index f8e6e72..a5aee02 100644 --- a/www/js/state.js +++ b/www/js/state.js @@ -81,6 +81,7 @@ export const state = { totalListenSeconds: 0, totalUniqueHeard: 0, lastPlayedAt: null, + currentCircle: null, // Sync tracking lastSyncResult: null, _playStartTime: null, diff --git a/www/js/storage.js b/www/js/storage.js index d39a23f..78365b4 100644 --- a/www/js/storage.js +++ b/www/js/storage.js @@ -133,7 +133,8 @@ export function saveListenStats() { localStorage.setItem(CONFIG.STATS_KEY, JSON.stringify({ totalListenSeconds: state.totalListenSeconds, totalUniqueHeard: state.totalUniqueHeard, - lastPlayedAt: state.lastPlayedAt + lastPlayedAt: state.lastPlayedAt, + currentCircle: state.currentCircle })); } catch (e) { console.warn('Failed to save listen stats:', e); @@ -151,6 +152,7 @@ export function loadListenStats() { state.totalListenSeconds = data.totalListenSeconds || 0; state.totalUniqueHeard = data.totalUniqueHeard || 0; state.lastPlayedAt = data.lastPlayedAt || null; + state.currentCircle = data.currentCircle || null; } } catch (e) { console.warn('Failed to load listen stats:', e); diff --git a/www/js/sync.js b/www/js/sync.js index 984c1fe..a0e5849 100644 --- a/www/js/sync.js +++ b/www/js/sync.js @@ -7,6 +7,7 @@ import { state } from './state.js'; import { CONFIG } from './config.js'; import { saveFavoriteTracks, saveHeardTracks, setSecretUnlocked, savePlayHistory, saveListenStats } from './storage.js'; import { deriveKey, encrypt, decrypt, generateWriteHash } from './crypto.js'; +import { getCircleIndex } from './circles.js'; const SYNC_ENDPOINT = '/sync'; const SYNC_CREDS_KEY = `${CONFIG.STORAGE_KEY.replace('_heard_tracks', '')}_sync_credentials`; @@ -57,6 +58,7 @@ function serializeState() { totalListenSeconds: state.totalListenSeconds, totalUniqueHeard: state.totalUniqueHeard, lastPlayedAt: state.lastPlayedAt, + currentCircle: state.currentCircle, syncedAt: new Date().toISOString() }); } @@ -120,6 +122,16 @@ function mergeState(remote) { } if (statsChanged) saveListenStats(); + // Circle: take the more advanced circle (higher threshold) + if (remote.currentCircle) { + const remoteIdx = getCircleIndex(remote.currentCircle); + const localIdx = getCircleIndex(state.currentCircle || 'limbo'); + if (remoteIdx > localIdx) { + state.currentCircle = remote.currentCircle; + saveListenStats(); + } + } + return { favoritesAdded, heardAdded, secretChanged, statsChanged }; } diff --git a/www/js/tracks.js b/www/js/tracks.js index aa74541..affcb2a 100644 --- a/www/js/tracks.js +++ b/www/js/tracks.js @@ -6,11 +6,12 @@ import { state, isSecretMode, REPEAT_MODES } from './state.js'; import { elements } from './elements.js'; import { seededRandom, escapeHtml, getMediaUrl } from './utils.js'; -import { saveHeardTracks } from './storage.js'; +import { saveHeardTracks, saveListenStats } from './storage.js'; import { debouncedPush } from './sync.js'; import { trackEvent } from './analytics.js'; import { showAuthError } from './ui.js'; import { networkState, isTrackCached } from './pwa.js'; +import { checkCircleAdvancement, applyCircleTheme } from './circles.js'; // Forward declaration for renderTrackList callback let updateCatalogProgressFn = null; @@ -109,8 +110,25 @@ export function getNextTrack() { */ export function markTrackHeard(trackId) { // Track cumulative unique heard (doesn't reset like heardTracks) - if (!state.heardTracks.has(trackId)) { + const wasNew = !state.heardTracks.has(trackId); + if (wasNew) { + const prevHeard = state.totalUniqueHeard; state.totalUniqueHeard++; + + // Check for circle advancement + const newCircle = checkCircleAdvancement(prevHeard, state.totalUniqueHeard); + if (newCircle) { + state.currentCircle = newCircle.id; + applyCircleTheme(newCircle.id); + saveListenStats(); + // Pulse the profile nav button + if (elements.profileNavBtn) { + elements.profileNavBtn.classList.add('circle-advance'); + setTimeout(() => { + elements.profileNavBtn.classList.remove('circle-advance'); + }, 2000); + } + } } state.heardTracks.add(trackId); saveHeardTracks(); diff --git a/www/main.css b/www/main.css index 920a6e8..6171042 100644 --- a/www/main.css +++ b/www/main.css @@ -48,7 +48,17 @@ html { body { background-color: var(--bg); - background-image: none; + background-image: + radial-gradient(ellipse at 30% 20%, rgba(80, 0, 0, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 70% 80%, rgba(40, 0, 60, 0.12) 0%, transparent 50%), + radial-gradient(ellipse at center, #1a1a1a 0%, #000 70%), + repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(255,255,255,0.015) 2px, + rgba(255,255,255,0.015) 4px + ); color: var(--fg); font-family: var(--font); font-size: 14px; @@ -56,7 +66,53 @@ body { position: relative; } -/* Background effects (customize for your theme) */ +/* Smoke background — colors driven by CSS custom properties for circle themes */ +body::before, +body::after { + content: ''; + position: fixed; + inset: -50%; + pointer-events: none; + z-index: -1; +} + +body::before { + background: + radial-gradient(ellipse 80% 50% at 20% 80%, var(--smoke-1, rgba(255,255,255,0.25)) 0%, transparent 50%), + radial-gradient(ellipse 60% 40% at 80% 20%, var(--smoke-2, rgba(255,255,255,0.22)) 0%, transparent 50%), + radial-gradient(ellipse 50% 60% at 50% 50%, var(--smoke-3, rgba(255,255,255,0.18)) 0%, transparent 40%); + animation: smoke1 12s ease-in-out infinite; + filter: blur(50px); +} + +body::after { + background: + radial-gradient(ellipse 70% 55% at 70% 70%, var(--smoke-2, rgba(255,255,255,0.22)) 0%, transparent 45%), + radial-gradient(ellipse 55% 45% at 30% 30%, var(--smoke-3, rgba(255,255,255,0.18)) 0%, transparent 50%), + radial-gradient(ellipse 45% 70% at 60% 40%, var(--smoke-1, rgba(255,255,255,0.15)) 0%, transparent 40%); + animation: smoke2 15s ease-in-out infinite; + filter: blur(60px); +} + +@keyframes smoke1 { + 0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.9; } + 25% { transform: translate(8%, -5%) scale(1.1); opacity: 1; } + 50% { transform: translate(-5%, 8%) scale(0.9); opacity: 0.7; } + 75% { transform: translate(6%, 3%) scale(1.05); opacity: 1; } +} + +@keyframes smoke2 { + 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 0.8; } + 33% { transform: translate(-6%, 6%) scale(1.08) rotate(3deg); opacity: 1; } + 66% { transform: translate(5%, -4%) scale(0.92) rotate(-2deg); opacity: 0.85; } +} + +@media (prefers-reduced-motion: reduce) { + body::before, + body::after { + animation: none; + } +} #app { height: 100%; @@ -176,62 +232,6 @@ body { display: none; } -/* Enter screen - credentials and greeting */ -.enter-greeting { - font-family: var(--font); - font-size: clamp(14px, 2vh, 20px); - color: var(--muted); - letter-spacing: 0.15em; - margin: 0 0 4px 0; - opacity: 0; - animation: fadeIn 1.5s ease-out 0.3s forwards; -} - -.enter-username-display { - font-family: var(--font); - font-size: clamp(12px, 1.5vh, 16px); - color: var(--fg); - letter-spacing: 0.1em; - margin: 0 0 16px 0; - display: block; - text-align: center; - opacity: 0; - animation: fadeIn 1.5s ease-out 0.5s forwards; -} - -.enter-creds { - display: flex; - flex-direction: column; - gap: 12px; - margin: 0 0 20px 0; - width: 200px; - opacity: 0; - animation: fadeIn 1.5s ease-out 0.3s forwards; -} - -.enter-creds input { - background: transparent; - border: none; - border-bottom: 1px solid var(--muted); - color: var(--fg); - font-family: var(--font); - font-size: clamp(14px, 2vh, 18px); - letter-spacing: 0.1em; - padding: 8px 4px; - text-align: center; - outline: none; - transition: border-color 0.2s ease; -} - -.enter-creds input:focus { - border-bottom-color: var(--fg); -} - -.enter-creds input::placeholder { - color: var(--muted); - opacity: 0.6; - letter-spacing: 0.15em; -} /* Voice login container */ .password-container { @@ -421,6 +421,48 @@ body { display: block; } +/* Cash Rain overlay — Konami reward */ +.cash-rain { + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; + overflow: hidden; + animation: cash-fade 4s ease-out forwards; + perspective: 1000px; +} + +.cash-rain .bill { + position: absolute; + top: -120px; + width: var(--bill-width, 60px); + height: var(--bill-height, 25px); + background-image: var(--bill-img); + background-size: cover; + background-position: center; + transform-style: preserve-3d; + animation: fall-tumble var(--fall-duration, 2s) linear forwards; + animation-delay: var(--fall-delay, 0s); + opacity: var(--bill-opacity, 1); + box-shadow: 0 2px 4px rgba(0,0,0,0.3); +} + +@keyframes fall-tumble { + 0% { + transform: translateY(0) rotateZ(var(--start-rot, 0deg)) rotateX(0deg) rotateY(0deg); + opacity: var(--bill-opacity, 1); + } + 100% { + transform: translateY(calc(100vh + 150px)) rotateZ(var(--spin-z, 360deg)) rotateX(var(--spin-x, 720deg)) rotateY(var(--spin-y, 540deg)); + opacity: calc(var(--bill-opacity, 1) * 0.7); + } +} + +@keyframes cash-fade { + 0%, 60% { opacity: 1; } + 100% { opacity: 0; } +} + /* Player Screen */ .player-title { position: absolute; @@ -462,6 +504,9 @@ body { height: var(--artwork-size); border: 2px solid var(--muted); flex-shrink: 0; + position: relative; + overflow: hidden; + cursor: default; } .artwork-image { @@ -472,10 +517,23 @@ body { cursor: default; } -.artwork-container.no-art { +.artwork-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: none; + pointer-events: none; +} + +.artwork-container.no-art .artwork-image { display: none; } +.artwork-container.no-art .artwork-canvas { + display: block; +} + /* Right side content wrapper - flows naturally on mobile */ .player-right { display: flex; @@ -547,15 +605,14 @@ body { } /* Artwork long-press hold feedback */ -.artwork-image.holding { +.artwork-container.holding { opacity: 0.7; - transition: opacity 0.3s; + transition: opacity 0.15s ease; } /* Clickable metadata (super/secret modes) */ -.artwork-image.clickable { +.artwork-container.clickable { cursor: pointer; - pointer-events: auto; } #artist, #album, #title, #year { @@ -2248,6 +2305,94 @@ body { pointer-events: none; } +/* Circle progress marks */ +.circle-progress { + margin: 0 0 32px 0; +} + +.circle-marks { + display: flex; + gap: 8px; + justify-content: center; +} + +.circle-mark { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid var(--muted); + background: transparent; + cursor: default; + transition: background 0.3s ease, border-color 0.3s ease, transform 0.15s ease; +} + +.circle-mark.unlocked { + border-color: var(--fg); + cursor: pointer; +} + +.circle-mark.unlocked:hover { + transform: scale(1.2); +} + +.circle-mark.current { + background: var(--fg); + border-color: var(--fg); +} + +/* Circle advancement pulse on profile nav button */ +.circle-advance { + animation: circle-pulse 2s ease-out forwards; +} + +@keyframes circle-pulse { + 0% { transform: scale(1); } + 15% { transform: scale(1.3); filter: brightness(1.5); } + 30% { transform: scale(1); } + 45% { transform: scale(1.15); filter: brightness(1.3); } + 60% { transform: scale(1); filter: brightness(1); } + 100% { transform: scale(1); } +} + +/* Credential form for unconnected users */ +.sync-heading { + font-family: var(--font); + font-size: 14px; + letter-spacing: 0.15em; + color: var(--muted); + margin: 0 0 16px 0; +} + +.profile-input { + display: block; + width: 100%; + background: transparent; + border: none; + border-bottom: 2px solid var(--muted); + color: var(--fg); + font-family: var(--font); + font-size: 16px; + letter-spacing: 0.05em; + padding: 12px 0; + margin: 0 0 16px 0; + outline: none; + transition: border-color 0.2s ease; +} + +.profile-input::placeholder { + color: var(--muted); + opacity: 0.6; +} + +.profile-input:focus { + border-bottom-color: var(--fg); +} + +.profile-creds .profile-action-btn { + width: 100%; + margin-top: 8px; +} + /* DEBUG STYLES — REMOVE BEFORE RELEASE */ .profile-debug { margin-top: 40px; diff --git a/www/sw.js b/www/sw.js index f88608e..d752f89 100644 --- a/www/sw.js +++ b/www/sw.js @@ -4,7 +4,7 @@ * artwork (cache-first), audio (cache-first + cache-on-play) */ -const SHELL_CACHE = 'shell-v3'; +const SHELL_CACHE = 'shell-v4'; const MANIFEST_CACHE = 'manifest-v1'; const ARTWORK_CACHE = 'artwork-v1'; const AUDIO_CACHE = 'audio-v1'; @@ -25,6 +25,7 @@ const SHELL_ASSETS = [ '/js/cookies.js', '/js/elements.js', '/js/events.js', + '/js/genart.js', '/js/hash.js', '/js/konami.js', '/js/player.js', @@ -33,11 +34,14 @@ const SHELL_ASSETS = [ '/js/storage.js', '/js/sync.js', '/js/crypto.js', + '/js/circles.js', '/js/tracks.js', '/js/ui.js', '/js/utils.js', '/js/version.js', '/js/voice.js', + '/img/benj_front.jpeg', + '/img/benj_back.jpeg', '/favicon.svg', '/icons/icon-192.png', '/icons/icon-512.png', @@ -61,7 +65,8 @@ function isShellRequest(url) { p.endsWith('.css') || (p.endsWith('.js') && !p.startsWith('/audio/')) || p.startsWith('/icons/') || - p.startsWith('/favicon'); + p.startsWith('/favicon') || + p.startsWith('/img/'); } function isManifestRequest(url) { From 2a086a9b9e1d709bb48f16e76a727e9d32d6fe83 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Fri, 3 Apr 2026 00:32:05 -0400 Subject: [PATCH 14/18] chore: bump version to 1.3.0 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 18 ++++++++++++++++++ VERSION | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d552b84..0101a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.3.0] - 2026-04-03 + +### Added +- Smoke background: layered animated radial gradients with CSS custom properties, `prefers-reduced-motion` support +- Dante's Circles: 9 unlockable smoke color themes tied to unique tracks heard (0–100 milestones) +- Circle progress on profile screen: 9 dot marks, tappable to switch between unlocked themes +- Generative artwork: canvas-based animated placeholder for tracks without cover art, unique per track +- Cash rain depth: 150 bills across 5 parallax size tiers with weighted distribution +- Inline credential form on profile screen for sync opt-in (replaces prompt-based flow) +- Theme config: `site.md` frontmatter supports `theme.circles` for per-instance color overrides +- Circle state syncs across devices (highest circle wins on merge) + +### Changed +- Enter screen stripped to just ENTER button — no credential inputs, no greeting +- Profile screen: unconnected users see stats + credential form; connected users see stats + sync status + circle progress +- Artwork long-press download and click-to-search gestures bind to container (works with both real artwork and generated art) +- Service worker bumped to shell-v4 with circles.js, genart.js, and bill images + ## [1.2.0] - 2026-04-02 ### Added diff --git a/VERSION b/VERSION index 6e8bf73..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +1.3.0 From a177b416bea0ecf7985aec3157f91ffcda86d9d1 Mon Sep 17 00:00:00 2001 From: Ramzi Abdoch Date: Fri, 3 Apr 2026 00:55:19 -0400 Subject: [PATCH 15/18] feat: admin/debug mode for username "rmzi" Activates when synced as "rmzi": auto-unlocks secret mode and all circles (totalUniqueHeard=999), shows a persistent green-on-black debug strip at the bottom of every screen, and a slide-up action menu for testing heard counts, cash rain, force sync, and secret mode toggle. Deactivates cleanly on disconnect. Co-Authored-By: Claude Sonnet 4.6 --- www/index.html | 27 +++++++++ www/js/elements.js | 13 +++++ www/js/events.js | 136 ++++++++++++++++++++++++++++++++++++++++++++- www/js/state.js | 2 + www/main.css | 78 ++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 1 deletion(-) diff --git a/www/index.html b/www/index.html index ba6b1f0..581ecaf 100644 --- a/www/index.html +++ b/www/index.html @@ -232,6 +232,33 @@

DEBUG

+ + + +