interactive intro to open social at-me.zzstoatzz.io
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix onboarding tour and improve suggested apps for new users

- fix step 2 spotlight: compute circular cutout around full app orbit
instead of dimming everything with no focus
- defer discover callout until onboarding completes
- wire up "replay tour" link in info modal
- replace suggested apps with grain.social, leaflet.pub, pckt.blog, plyr.fm
- fetch real logos for suggested apps via avatar API
- add descriptions (photos, blogging, music) under each suggestion
- add discover callout: "your account works with more than just bluesky"
- guard against null elements in onboarding dismiss

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+193 -42
+82 -6
src/view/layout.css
··· 204 204 205 205 /* Ghost/suggested apps for users with few apps */ 206 206 .app-view.ghost-app { 207 - opacity: 0.25; 207 + opacity: 0.35; 208 208 cursor: pointer; 209 + transition: all 0.3s ease; 209 210 } 210 211 211 212 .app-view.ghost-app:hover { 212 - opacity: 0.5; 213 + opacity: 0.8; 213 214 transform: scale(1.1); 214 215 } 215 216 216 217 .app-view.ghost-app .app-circle { 217 - border-style: dashed; 218 - border-color: var(--text-light); 219 - background: transparent; 218 + border: 1.5px dashed var(--text-light); 219 + background: rgba(255, 255, 255, 0.03); 220 + overflow: hidden; 221 + } 222 + 223 + .app-view.ghost-app:hover .app-circle { 224 + border-color: var(--text); 225 + background: rgba(255, 255, 255, 0.06); 220 226 } 221 227 222 228 .app-view.ghost-app .app-name { 223 229 color: var(--text-light); 224 - font-style: italic; 230 + } 231 + 232 + .ghost-app-description { 233 + position: absolute; 234 + top: calc(100% + 1.5rem); 235 + left: 50%; 236 + transform: translateX(-50%); 237 + font-size: 0.6rem; 238 + color: var(--text-lighter, var(--text-light)); 239 + white-space: nowrap; 240 + opacity: 0.7; 241 + margin-top: 0.3rem; 242 + } 243 + 244 + /* Discover callout banner */ 245 + .discover-callout { 246 + position: fixed; 247 + bottom: 5rem; 248 + left: 50%; 249 + transform: translateX(-50%); 250 + z-index: 500; 251 + opacity: 0; 252 + transition: opacity 0.3s ease, transform 0.3s ease; 253 + pointer-events: none; 254 + } 255 + 256 + .discover-callout.visible { 257 + opacity: 1; 258 + pointer-events: auto; 259 + } 260 + 261 + .discover-callout-content { 262 + background: var(--surface); 263 + border: 1px solid var(--border); 264 + border-radius: 8px; 265 + padding: 1rem 1.25rem; 266 + max-width: min(480px, calc(100vw - 2rem)); 267 + display: flex; 268 + align-items: center; 269 + gap: 1rem; 270 + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); 271 + } 272 + 273 + .discover-callout-text { 274 + font-size: 0.75rem; 275 + color: var(--text-light); 276 + line-height: 1.5; 277 + } 278 + 279 + .discover-callout-text strong { 280 + color: var(--text); 281 + display: block; 282 + margin-bottom: 0.25rem; 283 + } 284 + 285 + .discover-callout-dismiss { 286 + font-family: inherit; 287 + font-size: 0.7rem; 288 + padding: 0.35rem 0.75rem; 289 + background: var(--text); 290 + color: var(--bg); 291 + border: none; 292 + border-radius: 4px; 293 + cursor: pointer; 294 + white-space: nowrap; 295 + flex-shrink: 0; 296 + transition: opacity 0.15s ease; 297 + } 298 + 299 + .discover-callout-dismiss:hover { 300 + opacity: 0.85; 225 301 }
+38 -19
src/view/onboarding.js
··· 10 10 padding: 20, 11 11 }, 12 12 { 13 - target: null, // no spotlight — we highlight all apps instead 13 + target: '.canvas', // spotlight the whole orbit 14 14 title: 'your apps', 15 15 text: 'each circle is an app connected to your account. they read and write data that belongs to you, not them.', 16 16 cardPosition: 'center', 17 - highlightApps: true, 17 + useCanvasBounds: true, // compute a circular cutout from all app positions 18 18 }, 19 19 { 20 20 target: '.app-view:not(.filtered)', ··· 148 148 149 149 const targetEl = step.target ? document.querySelector(step.target) : null; 150 150 151 - if (targetEl) { 151 + if (step.useCanvasBounds) { 152 + // Compute a circle that encloses the identity + all visible app circles 153 + const identity = document.querySelector('.identity'); 154 + const identityRect = identity?.getBoundingClientRect(); 155 + const cx = identityRect ? identityRect.left + identityRect.width / 2 : window.innerWidth / 2; 156 + const cy = identityRect ? identityRect.top + identityRect.height / 2 : window.innerHeight / 2; 157 + 158 + let maxDist = 0; 159 + field?.querySelectorAll('.app-view:not(.filtered)').forEach(app => { 160 + const rect = app.getBoundingClientRect(); 161 + const appCx = rect.left + rect.width / 2; 162 + const appCy = rect.top + rect.height / 2; 163 + const dist = Math.sqrt((appCx - cx) ** 2 + (appCy - cy) ** 2) + rect.width / 2; 164 + maxDist = Math.max(maxDist, dist); 165 + }); 166 + 167 + const r = maxDist + 30; 168 + 169 + backdrop.classList.remove('no-cutout'); 170 + backdrop.style.clipPath = circularCutout(cx, cy, r); 171 + 172 + ring.style.left = `${cx - r}px`; 173 + ring.style.top = `${cy - r}px`; 174 + ring.style.width = `${r * 2}px`; 175 + ring.style.height = `${r * 2}px`; 176 + ring.setAttribute('data-visible', ''); 177 + } else if (targetEl) { 152 178 const { x, y, radius } = getTargetCenter(targetEl); 153 179 const r = radius + (step.padding || 20); 154 180 ··· 167 193 ring.removeAttribute('data-visible'); 168 194 } 169 195 170 - // Highlight apps if needed 171 - if (step.highlightApps) { 172 - field?.classList.add('onboarding-highlight-apps'); 173 - // Set stagger index for each app 174 - field?.querySelectorAll('.app-view').forEach((el, i) => { 175 - el.style.setProperty('--i', i); 176 - }); 177 - } 178 - 179 196 // Render and position card with a slight delay for the backdrop transition 180 197 renderCard(stepIndex); 181 198 positionCard(card, step, targetEl); ··· 197 214 const ring = document.getElementById('onboardingRing'); 198 215 const field = document.getElementById('field'); 199 216 200 - card.removeAttribute('data-visible'); 201 - ring.removeAttribute('data-visible'); 217 + card?.removeAttribute('data-visible'); 218 + ring?.removeAttribute('data-visible'); 202 219 field?.classList.remove('onboarding-highlight-apps'); 203 220 204 - overlay.style.opacity = '0'; 205 - setTimeout(() => { 206 - overlay.removeAttribute('data-active'); 207 - overlay.style.opacity = ''; 208 - }, 400); 221 + if (overlay) { 222 + overlay.style.opacity = '0'; 223 + setTimeout(() => { 224 + overlay.removeAttribute('data-active'); 225 + overlay.style.opacity = ''; 226 + }, 400); 227 + } 209 228 210 229 localStorage.setItem(ONBOARDING_KEY, 'true'); 211 230 }
+73 -17
src/view/visualization.js
··· 13 13 import { initFilterPanel, repositionAppCircles, applyValidFilter } from './filters.js'; 14 14 import { loadMSTStructure } from './mst.js'; 15 15 16 - // Suggested atmosphere apps to show as ghost circles when the user has few apps 16 + // Suggested atmosphere apps to show when the user has few apps 17 17 const SUGGESTED_APPS = [ 18 - { namespace: 'sh.tangled', name: 'tangled.sh', url: 'https://tangled.sh', letter: 'T' }, 19 - { namespace: 'com.whtwnd', name: 'whtwnd.com', url: 'https://whtwnd.com', letter: 'W' }, 20 - { namespace: 'pub.leaflet', name: 'leaflet.pub', url: 'https://leaflet.pub', letter: 'L' }, 21 - { namespace: 'blue.linkat', name: 'linkat.blue', url: 'https://linkat.blue', letter: 'L' }, 22 - { namespace: 'app.frontpage', name: 'frontpage.fyi', url: 'https://frontpage.fyi', letter: 'F' }, 18 + { namespace: 'social.grain', name: 'grain.social', url: 'https://grain.social', letter: 'G', description: 'photos' }, 19 + { namespace: 'pub.leaflet', name: 'leaflet.pub', url: 'https://leaflet.pub', letter: 'L', description: 'blogging' }, 20 + { namespace: 'blog.pckt', name: 'pckt.blog', url: 'https://pckt.blog', letter: 'P', description: 'blogging' }, 21 + { namespace: 'fm.plyr', name: 'plyr.fm', url: 'https://plyr.fm', letter: 'P', description: 'music' }, 23 22 ]; 24 23 25 24 export async function renderVisualization(apps, profile) { ··· 118 117 return { div, namespace }; 119 118 }); 120 119 121 - // Add ghost/suggested apps when user has very few real apps 122 - const ghostApps = []; 120 + // Add suggested apps when user has very few real apps 121 + const ghostAppDivs = []; 122 + let activeSuggestions = []; 123 123 if (appCount <= 3) { 124 124 const existingNamespaces = new Set(appNames); 125 - const suggestions = SUGGESTED_APPS.filter(s => !existingNamespaces.has(s.namespace)); 126 - const ghostCount = Math.min(suggestions.length, 4 - appCount + 1); 125 + activeSuggestions = SUGGESTED_APPS.filter(s => !existingNamespaces.has(s.namespace)); 126 + const ghostCount = Math.min(activeSuggestions.length, 4); 127 127 const totalCount = appCount + ghostCount; 128 128 129 - // Recalculate positions to include ghost apps in the circle 130 - suggestions.slice(0, ghostCount).forEach((suggestion, idx) => { 129 + activeSuggestions.slice(0, ghostCount).forEach((suggestion, idx) => { 131 130 const totalIdx = appCount + idx; 132 131 const angle = (totalIdx / totalCount) * 2 * Math.PI - Math.PI / 2; 133 132 const circleOffset = circleSize / 2; ··· 140 139 div.style.top = `${y}px`; 141 140 142 141 div.innerHTML = ` 143 - <div class="app-circle" style="width: ${circleSize}px; height: ${circleSize}px; font-size: ${circleSize * 0.4}px;">${suggestion.letter}</div> 142 + <div class="app-circle" data-namespace="${suggestion.namespace}" style="width: ${circleSize}px; height: ${circleSize}px; font-size: ${circleSize * 0.4}px;">${suggestion.letter}</div> 144 143 <a href="${suggestion.url}" target="_blank" rel="noopener noreferrer" class="app-name">${suggestion.name} &#8599;</a> 144 + <span class="ghost-app-description">${suggestion.description}</span> 145 145 `; 146 146 147 - ghostApps.push(div); 147 + ghostAppDivs.push({ div, suggestion }); 148 148 }); 149 149 150 150 // Reposition real apps to account for the ghost apps in the layout ··· 160 160 161 161 // Add all divs to field 162 162 appDivs.forEach(({ div }) => field.appendChild(div)); 163 - ghostApps.forEach(div => field.appendChild(div)); 163 + ghostAppDivs.forEach(({ div }) => field.appendChild(div)); 164 + 165 + // Show discover callout for users with few apps — wait for onboarding to finish 166 + if (ghostAppDivs.length > 0) { 167 + const waitForOnboarding = () => { 168 + const overlay = document.getElementById('onboardingOverlay'); 169 + if (overlay?.hasAttribute('data-active')) { 170 + // Onboarding still running, check again 171 + setTimeout(waitForOnboarding, 500); 172 + } else { 173 + showDiscoverCallout(field); 174 + } 175 + }; 176 + // Delay initial check to let onboarding start first 177 + setTimeout(waitForOnboarding, 1500); 178 + } 164 179 165 180 // Set up identity click handler immediately (non-blocking) 166 181 setupIdentityClickHandler(allCollections, appCount, profile); ··· 168 183 // Set up filter panel immediately (non-blocking) 169 184 initFilterPanel(); 170 185 171 - // Fetch avatars asynchronously (non-blocking) 172 - fetchAppAvatars(appNames).then(avatarMap => { 186 + // Fetch avatars asynchronously (non-blocking) — includes ghost apps 187 + const allNamespaces = [...appNames, ...activeSuggestions.map(s => s.namespace)]; 188 + fetchAppAvatars(allNamespaces).then(avatarMap => { 173 189 appDivs.forEach(({ div, namespace }) => { 174 190 const avatarUrl = avatarMap[namespace]; 175 191 if (avatarUrl) { 176 192 const circle = div.querySelector('.app-circle'); 177 193 circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`; 194 + } 195 + }); 196 + ghostAppDivs.forEach(({ div, suggestion }) => { 197 + const avatarUrl = avatarMap[suggestion.namespace]; 198 + if (avatarUrl) { 199 + const circle = div.querySelector('.app-circle'); 200 + circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${suggestion.name}" />`; 178 201 } 179 202 }); 180 203 }); ··· 291 314 e.stopPropagation(); 292 315 detail.classList.remove('visible'); 293 316 }); 317 + }); 318 + } 319 + 320 + const DISCOVER_KEY = 'atme_discover_dismissed'; 321 + 322 + function showDiscoverCallout(field) { 323 + if (localStorage.getItem(DISCOVER_KEY)) return; 324 + 325 + const callout = document.createElement('div'); 326 + callout.className = 'discover-callout'; 327 + callout.innerHTML = ` 328 + <div class="discover-callout-content"> 329 + <div class="discover-callout-text"> 330 + <strong>your account works with more than just bluesky.</strong> 331 + try any of these apps — sign in with your existing account, and come back here to see your updated activity. 332 + </div> 333 + <button class="discover-callout-dismiss" id="discoverDismiss">got it</button> 334 + </div> 335 + `; 336 + 337 + field.parentElement.appendChild(callout); 338 + 339 + // Fade in 340 + requestAnimationFrame(() => { 341 + requestAnimationFrame(() => { 342 + callout.classList.add('visible'); 343 + }); 344 + }); 345 + 346 + document.getElementById('discoverDismiss').addEventListener('click', () => { 347 + callout.classList.remove('visible'); 348 + setTimeout(() => callout.remove(), 300); 349 + localStorage.setItem(DISCOVER_KEY, 'true'); 294 350 }); 295 351 } 296 352