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.

rewrite copy to use atmosphere account framing, improve onboarding

replace PDS/repository jargon with accessible language throughout:
"your account", "your data", "your apps" instead of technical terms.
PDS explained once as "personal data server" where needed.

- landing page: new "what is this?" copy with atmosphere framing
- info modal: reframed as "this is your account"
- identity sidebar: "your account" instead of "your personal data server"
- onboarding: rewritten with clip-path spotlight, smooth transitions
- merge chat.bsky into app.bsky (not a standalone app)
- ghost app circles for users with few apps (tangled, whitewind, etc.)
- wire up "replay tour" link in info modal

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

+414 -210
+8 -8
index.html
··· 10 10 <meta property="og:type" content="website"> 11 11 <meta property="og:url" content="https://at-me.wisp.place/"> 12 12 <meta property="og:title" content="@me - explore your atproto identity"> 13 - <meta property="og:description" content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 13 + <meta property="og:description" content="see your atmosphere account — one account, all your apps and data"> 14 14 <meta property="og:image" content="https://at-me.wisp.place/og-image.png"> 15 15 16 16 <!-- Twitter --> 17 17 <meta property="twitter:card" content="summary_large_image"> 18 18 <meta property="twitter:url" content="https://at-me.wisp.place/"> 19 19 <meta property="twitter:title" content="@me - explore your atproto identity"> 20 - <meta property="twitter:description" content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 20 + <meta property="twitter:description" content="see your atmosphere account — one account, all your apps and data"> 21 21 <meta property="twitter:image" content="https://at-me.wisp.place/og-image.png"> 22 22 </head> 23 23 <body> ··· 48 48 49 49 <div class="info-content" id="infoContent"> 50 50 <div class="info-section"> 51 - <h3>you should own your data</h3> 52 - <p>today's social platforms own your data. built 10k followers? wrote years of posts? if you leave, you lose it all. you don't own your network - they do.</p> 51 + <h3>one account for everything</h3> 52 + <p>on most platforms, your posts, followers, and data are locked inside each app. if you leave, you start over.</p> 53 53 54 - <h3>what if social media worked like email?</h3> 55 - <p>with email, you can switch from gmail to protonmail and keep your contacts. same idea here: your posts and followers live on your own server (called a <a href="https://atproto.com/guides/data-repos" target="_blank" rel="noopener noreferrer">Personal Data Server</a>). apps like <a href="https://bsky.app" target="_blank" rel="noopener noreferrer">bluesky</a> just connect to it.</p> 54 + <h3>not here</h3> 55 + <p>in <a href="https://augment.ink/the-everything-account" target="_blank" rel="noopener noreferrer">the atmosphere</a>, you have one account that works across every app. your data belongs to you — apps just connect to it. switch apps anytime and take everything with you.</p> 56 56 57 - <h3>see it in action</h3> 58 - <p>enter any handle above to see how <a href="https://atproto.com" target="_blank" rel="noopener noreferrer">atproto</a> stores their social data.</p> 57 + <h3>see it for yourself</h3> 58 + <p>enter any handle above to see what apps and data are connected to their account.</p> 59 59 </div> 60 60 </div> 61 61 </div>
+22
src/view/layout.css
··· 201 201 #field.many-apps .app-name.invalid-link { 202 202 display: none; 203 203 } 204 + 205 + /* Ghost/suggested apps for users with few apps */ 206 + .app-view.ghost-app { 207 + opacity: 0.25; 208 + cursor: pointer; 209 + } 210 + 211 + .app-view.ghost-app:hover { 212 + opacity: 0.5; 213 + transform: scale(1.1); 214 + } 215 + 216 + .app-view.ghost-app .app-circle { 217 + border-style: dashed; 218 + border-color: var(--text-light); 219 + background: transparent; 220 + } 221 + 222 + .app-view.ghost-app .app-name { 223 + color: var(--text-light); 224 + font-style: italic; 225 + }
+11 -2
src/view/main.js
··· 18 18 import { initGuestbookUI, updateAuthUI } from './guestbook-ui.js'; 19 19 import { initFirehoseUI } from './firehose.js'; 20 20 import { initOAuth, handleOAuthCallback, tryResumeSession, consumeReturnIntent } from './oauth.js'; 21 - import { initOnboarding } from './onboarding.js'; 21 + import { initOnboarding, restartOnboarding } from './onboarding.js'; 22 22 23 23 // ============================================================================ 24 24 // INITIALIZATION ··· 123 123 } 124 124 125 125 // Group collections by namespace (first two parts) 126 + // Merge chat.bsky into app.bsky since Bluesky Chat isn't a standalone app 127 + const NAMESPACE_MERGES = { 'chat.bsky': 'app.bsky' }; 126 128 const apps = {}; 127 129 repoInfo.collections.forEach(collection => { 128 130 const parts = collection.split('.'); 129 - const namespace = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : collection; 131 + let namespace = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : collection; 132 + namespace = NAMESPACE_MERGES[namespace] || namespace; 130 133 if (!apps[namespace]) apps[namespace] = []; 131 134 apps[namespace].push(collection); 132 135 }); ··· 143 146 initGuestbookUI(); 144 147 initFirehoseUI(); 145 148 initOnboarding(); 149 + 150 + // Wire up replay tour link in info modal 151 + document.getElementById('replayTour')?.addEventListener('click', (e) => { 152 + e.preventDefault(); 153 + restartOnboarding(); 154 + }); 146 155 147 156 // Check guestbook state 148 157 await checkGuestbookState();
+102 -50
src/view/onboarding.css
··· 1 1 .onboarding-overlay { 2 2 position: fixed; 3 3 inset: 0; 4 - background: transparent; 5 4 z-index: 3000; 6 5 display: none; 7 6 opacity: 0; 8 - transition: opacity 0.3s ease; 9 - pointer-events: none; 7 + transition: opacity 0.4s ease; 10 8 } 11 9 12 - .onboarding-overlay.active { 10 + .onboarding-overlay[data-active] { 13 11 display: block; 14 12 opacity: 1; 15 13 } 16 14 17 - .onboarding-spotlight { 15 + /* Full-screen dim layer with a circular hole punched via clip-path */ 16 + .onboarding-backdrop { 18 17 position: absolute; 19 - border: 2px solid rgba(255, 255, 255, 0.9); 18 + inset: 0; 19 + background: rgba(0, 0, 0, 0.8); 20 + transition: clip-path 0.5s cubic-bezier(0.4, 0, 0.2, 1); 21 + } 22 + 23 + /* When no spotlight needed (e.g. the "apps" step), just dim everything */ 24 + .onboarding-backdrop.no-cutout { 25 + clip-path: none; 26 + } 27 + 28 + /* Subtle ring around the cutout */ 29 + .onboarding-ring { 30 + position: absolute; 20 31 border-radius: 50%; 21 - box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.75), 0 0 40px rgba(255, 255, 255, 0.5); 32 + border: 2px solid rgba(255, 255, 255, 0.4); 22 33 pointer-events: none; 23 - transition: all 0.5s ease; 34 + transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); 35 + opacity: 0; 36 + } 37 + 38 + .onboarding-ring[data-visible] { 39 + opacity: 1; 24 40 } 25 41 26 - .onboarding-content { 42 + /* Tooltip card */ 43 + .onboarding-card { 27 44 position: fixed; 28 45 background: var(--surface); 29 - border: 2px solid var(--border); 30 - padding: clamp(1rem, 3vmin, 2rem); 31 - max-width: min(400px, 90vw); 32 - z-index: 3001; 33 - border-radius: 4px; 34 - transition: all 0.3s ease; 46 + border: 1px solid var(--border); 47 + padding: 1.5rem; 48 + width: min(360px, calc(100vw - 2rem)); 49 + border-radius: 8px; 35 50 pointer-events: auto; 51 + opacity: 0; 52 + transform: translateY(8px); 53 + transition: opacity 0.35s ease, transform 0.35s ease; 54 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); 36 55 } 37 56 38 - .onboarding-content h3 { 39 - font-size: clamp(0.9rem, 2vmin, 1.1rem); 40 - margin-bottom: clamp(0.5rem, 1.5vmin, 0.75rem); 57 + .onboarding-card[data-visible] { 58 + opacity: 1; 59 + transform: translateY(0); 60 + } 61 + 62 + .onboarding-card h3 { 63 + font-size: 0.95rem; 64 + margin-bottom: 0.5rem; 41 65 color: var(--text); 42 - font-weight: 500; 66 + font-weight: 600; 67 + letter-spacing: -0.01em; 43 68 } 44 69 45 - .onboarding-content p { 46 - font-size: clamp(0.7rem, 1.5vmin, 0.85rem); 70 + .onboarding-card p { 71 + font-size: 0.8rem; 47 72 color: var(--text-light); 48 - line-height: 1.5; 49 - margin-bottom: clamp(1rem, 2vmin, 1.25rem); 73 + line-height: 1.6; 74 + margin: 0; 75 + } 76 + 77 + .onboarding-card p a { 78 + color: inherit; 79 + text-decoration: underline; 80 + } 81 + 82 + /* Footer: progress dots + actions */ 83 + .onboarding-footer { 84 + display: flex; 85 + align-items: center; 86 + justify-content: space-between; 87 + margin-top: 1.25rem; 88 + } 89 + 90 + .onboarding-dots { 91 + display: flex; 92 + gap: 6px; 93 + } 94 + 95 + .onboarding-dots span { 96 + width: 6px; 97 + height: 6px; 98 + border-radius: 50%; 99 + background: var(--border); 100 + transition: all 0.3s ease; 101 + } 102 + 103 + .onboarding-dots span.active { 104 + background: var(--text); 105 + transform: scale(1.3); 106 + } 107 + 108 + .onboarding-dots span.done { 109 + background: var(--text-light); 50 110 } 51 111 52 112 .onboarding-actions { 53 113 display: flex; 54 - gap: clamp(0.5rem, 1.5vmin, 0.75rem); 55 - justify-content: flex-end; 114 + gap: 0.5rem; 56 115 } 57 116 58 117 .onboarding-actions button { 59 118 font-family: inherit; 60 - font-size: clamp(0.7rem, 1.5vmin, 0.8rem); 61 - padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 119 + font-size: 0.75rem; 120 + padding: 0.4rem 0.9rem; 62 121 background: transparent; 63 122 border: 1px solid var(--border); 64 - color: var(--text); 123 + color: var(--text-light); 65 124 cursor: pointer; 66 - transition: all 0.2s ease; 67 - border-radius: 2px; 125 + transition: all 0.15s ease; 126 + border-radius: 4px; 68 127 } 69 128 70 129 .onboarding-actions button:hover { 71 - background: var(--surface-hover); 130 + color: var(--text); 72 131 border-color: var(--text-light); 73 132 } 74 133 75 134 .onboarding-actions button.primary { 76 - background: var(--surface-hover); 77 - border-color: var(--text-light); 78 - } 79 - 80 - .onboarding-progress { 81 - display: flex; 82 - gap: clamp(0.4rem, 1vmin, 0.5rem); 83 - justify-content: center; 84 - margin-top: clamp(0.75rem, 2vmin, 1rem); 135 + background: var(--text); 136 + color: var(--bg); 137 + border-color: var(--text); 85 138 } 86 139 87 - .onboarding-progress span { 88 - width: clamp(6px, 1.5vmin, 8px); 89 - height: clamp(6px, 1.5vmin, 8px); 90 - border-radius: 50%; 91 - background: var(--border); 92 - transition: background 0.3s ease; 140 + .onboarding-actions button.primary:hover { 141 + opacity: 0.85; 93 142 } 94 143 95 - .onboarding-progress span.active { 96 - background: var(--text); 144 + /* Pulse animation on app circles during the "apps" step */ 145 + @keyframes onboardingPulse { 146 + 0%, 100% { opacity: 0.7; } 147 + 50% { opacity: 1; transform: scale(1.05); } 97 148 } 98 149 99 - .onboarding-progress span.done { 100 - background: var(--text-light); 150 + .onboarding-highlight-apps .app-view { 151 + animation: onboardingPulse 2s ease-in-out infinite; 152 + animation-delay: calc(var(--i, 0) * 0.1s); 101 153 }
+188 -119
src/view/onboarding.js
··· 1 - // Onboarding overlay for first-time users 1 + // Onboarding tour for first-time users 2 2 const ONBOARDING_KEY = 'atme_onboarding_seen'; 3 3 4 4 const steps = [ 5 5 { 6 6 target: '.identity', 7 7 title: 'this is you', 8 - description: 'your global identity and handle. your data is hosted at your <a href="https://atproto.com/guides/overview" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">Personal Data Server (PDS)</a>.', 9 - position: 'bottom' 8 + text: 'your <a href="https://augment.ink/the-everything-account" target="_blank" rel="noopener noreferrer">atmosphere account</a> — one account that works across every app.', 9 + cardPosition: 'below', // place card below the target 10 + padding: 20, 10 11 }, 11 12 { 12 - target: '.canvas', 13 - title: 'atproto applications', 14 - description: 'these apps use your global identity to write public records to your PDS. they can also read records you\'ve created.', 15 - position: 'center' 13 + target: null, // no spotlight — we highlight all apps instead 14 + title: 'your apps', 15 + text: 'each circle is an app connected to your account. they read and write data that belongs to you, not them.', 16 + cardPosition: 'center', 17 + highlightApps: true, 16 18 }, 17 19 { 18 20 target: '.app-view:not(.filtered)', 19 - title: 'explore your records', 20 - description: 'click any app to see what records it has written to your PDS.', 21 - position: 'bottom' 22 - } 21 + title: 'explore your data', 22 + text: 'click any app to see the data it\'s created in your account.', 23 + cardPosition: 'below', 24 + padding: 15, 25 + }, 23 26 ]; 24 27 25 28 let currentStep = 0; 26 29 27 - function showOnboarding() { 28 - const overlay = document.getElementById('onboardingOverlay'); 29 - if (!overlay) return; 30 + // Build a clip-path polygon that covers the whole screen except a circle 31 + function circularCutout(cx, cy, r) { 32 + // Use a polygon that traces the viewport edges, then cuts inward to a circle 33 + // We approximate the circle with 48 points 34 + const points = []; 35 + const segments = 48; 36 + for (let i = 0; i <= segments; i++) { 37 + const angle = (i / segments) * 2 * Math.PI; 38 + points.push(`${cx + r * Math.cos(angle)}px ${cy + r * Math.sin(angle)}px`); 39 + } 30 40 31 - overlay.style.display = 'block'; 32 - setTimeout(() => { 33 - overlay.style.opacity = '1'; 34 - showStep(0); 35 - }, 50); 41 + // Viewport corners + cut to circle + back 42 + return `polygon( 43 + evenodd, 44 + 0 0, 100% 0, 100% 100%, 0 100%, 0 0, 45 + ${points.join(', ')} 46 + )`; 36 47 } 37 48 38 - function hideOnboarding() { 39 - const overlay = document.getElementById('onboardingOverlay'); 40 - const spotlight = document.getElementById('onboardingSpotlight'); 41 - const content = document.getElementById('onboardingContent'); 49 + function getTargetCenter(el) { 50 + const rect = el.getBoundingClientRect(); 51 + return { 52 + x: rect.left + rect.width / 2, 53 + y: rect.top + rect.height / 2, 54 + radius: Math.max(rect.width, rect.height) / 2, 55 + }; 56 + } 42 57 43 - if (overlay) { 44 - overlay.style.opacity = '0'; 45 - setTimeout(() => { 46 - overlay.style.display = 'none'; 47 - }, 300); 48 - } 58 + function positionCard(card, step, targetEl) { 59 + const cardWidth = 360; 60 + const margin = 16; 49 61 50 - if (spotlight) spotlight.classList.remove('active'); 51 - if (content) content.classList.remove('active'); 62 + if (step.cardPosition === 'center') { 63 + card.style.left = '50%'; 64 + card.style.top = '50%'; 65 + card.style.transform = 'translate(-50%, -50%)'; 66 + return; 67 + } 52 68 53 - localStorage.setItem(ONBOARDING_KEY, 'true'); 54 - } 69 + // Reset transform 70 + card.style.transform = ''; 55 71 56 - function showStep(stepIndex) { 57 - if (stepIndex >= steps.length) { 58 - hideOnboarding(); 72 + if (!targetEl) { 73 + card.style.left = '50%'; 74 + card.style.top = '50%'; 75 + card.style.transform = 'translate(-50%, -50%)'; 59 76 return; 60 77 } 61 78 62 - currentStep = stepIndex; 63 - const step = steps[stepIndex]; 64 - const target = document.querySelector(step.target); 79 + const { x, y, radius } = getTargetCenter(targetEl); 80 + const padding = step.padding || 20; 81 + const gap = 20; 82 + 83 + // Try below first 84 + let top = y + radius + padding + gap; 85 + let left = x - cardWidth / 2; 86 + 87 + // Clamp horizontal 88 + left = Math.max(margin, Math.min(left, window.innerWidth - cardWidth - margin)); 65 89 66 - if (!target) { 67 - console.warn('Onboarding target not found:', step.target); 68 - showStep(stepIndex + 1); 69 - return; 90 + // If below would go offscreen, place above 91 + if (top + 200 > window.innerHeight - margin) { 92 + top = y - radius - padding - gap - 200; 70 93 } 71 94 72 - const spotlight = document.getElementById('onboardingSpotlight'); 73 - const content = document.getElementById('onboardingContent'); 95 + // If above would go offscreen, just center vertically 96 + if (top < margin) { 97 + top = window.innerHeight / 2 - 100; 98 + } 74 99 75 - // Position spotlight on target 76 - const rect = target.getBoundingClientRect(); 77 - const padding = step.target === '.canvas' ? 100 : 20; 100 + card.style.left = `${left}px`; 101 + card.style.top = `${top}px`; 102 + } 78 103 79 - spotlight.style.left = `${rect.left - padding}px`; 80 - spotlight.style.top = `${rect.top - padding}px`; 81 - spotlight.style.width = `${rect.width + padding * 2}px`; 82 - spotlight.style.height = `${rect.height + padding * 2}px`; 83 - spotlight.classList.add('active'); 104 + function renderCard(stepIndex) { 105 + const step = steps[stepIndex]; 106 + const card = document.getElementById('onboardingCard'); 107 + const isLast = stepIndex === steps.length - 1; 84 108 85 - // Position content 86 - const isLastStep = stepIndex === steps.length - 1; 87 - content.innerHTML = ` 109 + card.innerHTML = ` 88 110 <h3>${step.title}</h3> 89 - <p>${step.description}</p> 90 - <div class="onboarding-actions"> 91 - ${!isLastStep ? '<button id="skipOnboarding" class="onboarding-skip">skip</button>' : ''} 92 - <button id="nextOnboarding" class="onboarding-next"> 93 - ${isLastStep ? 'got it' : 'next'} 94 - </button> 95 - </div> 96 - <div class="onboarding-progress"> 97 - ${steps.map((_, i) => `<span class="${i === stepIndex ? 'active' : i < stepIndex ? 'done' : ''}"></span>`).join('')} 111 + <p>${step.text}</p> 112 + <div class="onboarding-footer"> 113 + <div class="onboarding-dots"> 114 + ${steps.map((_, i) => 115 + `<span class="${i === stepIndex ? 'active' : i < stepIndex ? 'done' : ''}"></span>` 116 + ).join('')} 117 + </div> 118 + <div class="onboarding-actions"> 119 + ${!isLast ? '<button id="onboardingSkip">skip</button>' : ''} 120 + <button id="onboardingNext" class="primary">${isLast ? 'got it' : 'next'}</button> 121 + </div> 98 122 </div> 99 123 `; 100 124 101 - // Position content relative to spotlight 102 - let contentTop, contentLeft; 103 - const contentMaxWidth = Math.min(400, window.innerWidth * 0.9); 104 - const contentHeight = 250; 105 - const margin = Math.max(20, window.innerWidth * 0.05); 125 + document.getElementById('onboardingNext').addEventListener('click', () => advance()); 126 + const skipBtn = document.getElementById('onboardingSkip'); 127 + if (skipBtn) skipBtn.addEventListener('click', () => dismiss()); 128 + } 129 + 130 + function showStep(stepIndex) { 131 + if (stepIndex >= steps.length) { 132 + dismiss(); 133 + return; 134 + } 135 + 136 + currentStep = stepIndex; 137 + const step = steps[stepIndex]; 138 + const backdrop = document.getElementById('onboardingBackdrop'); 139 + const ring = document.getElementById('onboardingRing'); 140 + const card = document.getElementById('onboardingCard'); 141 + const field = document.getElementById('field'); 142 + 143 + // Remove app highlight class 144 + field?.classList.remove('onboarding-highlight-apps'); 106 145 107 - if (step.position === 'bottom') { 108 - contentTop = rect.bottom + padding + margin; 109 - contentLeft = rect.left + rect.width / 2; 146 + // Hide card briefly for transition 147 + card.removeAttribute('data-visible'); 148 + 149 + const targetEl = step.target ? document.querySelector(step.target) : null; 110 150 111 - if (contentTop + contentHeight > window.innerHeight) { 112 - contentTop = rect.top - padding - contentHeight - margin; 113 - } 114 - } else if (step.position === 'center') { 115 - contentTop = window.innerHeight / 2 - contentHeight / 2; 116 - contentLeft = window.innerWidth / 2; 117 - } else { 118 - contentTop = rect.top - padding - contentHeight - margin; 119 - contentLeft = rect.left + rect.width / 2; 151 + if (targetEl) { 152 + const { x, y, radius } = getTargetCenter(targetEl); 153 + const r = radius + (step.padding || 20); 120 154 121 - if (contentTop < margin) { 122 - contentTop = rect.bottom + padding + margin; 123 - } 124 - } 155 + backdrop.classList.remove('no-cutout'); 156 + backdrop.style.clipPath = circularCutout(x, y, r); 125 157 126 - // Ensure content stays on screen horizontally 127 - const halfWidth = contentMaxWidth / 2; 128 - if (contentLeft - halfWidth < margin) { 129 - contentLeft = halfWidth + margin; 130 - } else if (contentLeft + halfWidth > window.innerWidth - margin) { 131 - contentLeft = window.innerWidth - halfWidth - margin; 158 + // Position ring 159 + ring.style.left = `${x - r}px`; 160 + ring.style.top = `${y - r}px`; 161 + ring.style.width = `${r * 2}px`; 162 + ring.style.height = `${r * 2}px`; 163 + ring.setAttribute('data-visible', ''); 164 + } else { 165 + backdrop.classList.add('no-cutout'); 166 + backdrop.style.clipPath = ''; 167 + ring.removeAttribute('data-visible'); 132 168 } 133 169 134 - // Ensure content stays on screen vertically 135 - if (contentTop < margin) { 136 - contentTop = margin; 137 - } else if (contentTop + contentHeight > window.innerHeight - margin) { 138 - contentTop = window.innerHeight - contentHeight - margin; 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 + }); 139 177 } 140 178 141 - content.style.top = `${contentTop}px`; 142 - content.style.left = `${contentLeft}px`; 143 - content.style.transform = 'translate(-50%, 0)'; 144 - content.classList.add('active'); 179 + // Render and position card with a slight delay for the backdrop transition 180 + renderCard(stepIndex); 181 + positionCard(card, step, targetEl); 145 182 146 - // Add event listeners 147 - const skipBtn = document.getElementById('skipOnboarding'); 148 - if (skipBtn) { 149 - skipBtn.addEventListener('click', hideOnboarding); 150 - } 151 - document.getElementById('nextOnboarding').addEventListener('click', () => { 152 - showStep(stepIndex + 1); 183 + requestAnimationFrame(() => { 184 + requestAnimationFrame(() => { 185 + card.setAttribute('data-visible', ''); 186 + }); 153 187 }); 154 188 } 155 189 190 + function advance() { 191 + showStep(currentStep + 1); 192 + } 193 + 194 + function dismiss() { 195 + const overlay = document.getElementById('onboardingOverlay'); 196 + const card = document.getElementById('onboardingCard'); 197 + const ring = document.getElementById('onboardingRing'); 198 + const field = document.getElementById('field'); 199 + 200 + card.removeAttribute('data-visible'); 201 + ring.removeAttribute('data-visible'); 202 + field?.classList.remove('onboarding-highlight-apps'); 203 + 204 + overlay.style.opacity = '0'; 205 + setTimeout(() => { 206 + overlay.removeAttribute('data-active'); 207 + overlay.style.opacity = ''; 208 + }, 400); 209 + 210 + localStorage.setItem(ONBOARDING_KEY, 'true'); 211 + } 212 + 156 213 export function initOnboarding() { 157 - const seen = localStorage.getItem(ONBOARDING_KEY); 214 + if (localStorage.getItem(ONBOARDING_KEY)) return; 215 + 216 + // Wait for app circles to render and settle 217 + setTimeout(() => { 218 + const overlay = document.getElementById('onboardingOverlay'); 219 + if (!overlay) return; 158 220 159 - if (!seen) { 160 - // Wait for app circles to render 161 - setTimeout(() => { 162 - showOnboarding(); 163 - }, 1000); 164 - } 221 + overlay.setAttribute('data-active', ''); 222 + // Trigger reflow, then fade in 223 + requestAnimationFrame(() => { 224 + overlay.style.opacity = '1'; 225 + showStep(0); 226 + }); 227 + }, 1000); 165 228 } 166 229 167 230 export function restartOnboarding() { ··· 171 234 if (infoModal) infoModal.classList.remove('visible'); 172 235 if (overlay) overlay.classList.remove('visible'); 173 236 setTimeout(() => { 174 - showOnboarding(); 237 + const onboardingOverlay = document.getElementById('onboardingOverlay'); 238 + if (!onboardingOverlay) return; 239 + onboardingOverlay.setAttribute('data-active', ''); 240 + requestAnimationFrame(() => { 241 + onboardingOverlay.style.opacity = '1'; 242 + showStep(0); 243 + }); 175 244 }, 300); 176 245 } 177 246 178 - // ESC key handler 247 + // ESC to dismiss 179 248 document.addEventListener('keydown', (e) => { 180 249 if (e.key === 'Escape') { 181 250 const overlay = document.getElementById('onboardingOverlay'); 182 - if (overlay && overlay.style.display === 'block') { 183 - hideOnboarding(); 251 + if (overlay?.hasAttribute('data-active')) { 252 + dismiss(); 184 253 } 185 254 } 186 255 });
+63 -13
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 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' }, 23 + ]; 24 + 16 25 export async function renderVisualization(apps, profile) { 17 26 const field = document.getElementById('field'); 18 27 field.innerHTML = ''; ··· 109 118 return { div, namespace }; 110 119 }); 111 120 121 + // Add ghost/suggested apps when user has very few real apps 122 + const ghostApps = []; 123 + if (appCount <= 3) { 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); 127 + const totalCount = appCount + ghostCount; 128 + 129 + // Recalculate positions to include ghost apps in the circle 130 + suggestions.slice(0, ghostCount).forEach((suggestion, idx) => { 131 + const totalIdx = appCount + idx; 132 + const angle = (totalIdx / totalCount) * 2 * Math.PI - Math.PI / 2; 133 + const circleOffset = circleSize / 2; 134 + const x = centerX + radius * Math.cos(angle) - circleOffset; 135 + const y = centerY + radius * Math.sin(angle) - circleOffset; 136 + 137 + const div = document.createElement('div'); 138 + div.className = 'app-view ghost-app'; 139 + div.style.left = `${x}px`; 140 + div.style.top = `${y}px`; 141 + 142 + div.innerHTML = ` 143 + <div class="app-circle" style="width: ${circleSize}px; height: ${circleSize}px; font-size: ${circleSize * 0.4}px;">${suggestion.letter}</div> 144 + <a href="${suggestion.url}" target="_blank" rel="noopener noreferrer" class="app-name">${suggestion.name} &#8599;</a> 145 + `; 146 + 147 + ghostApps.push(div); 148 + }); 149 + 150 + // Reposition real apps to account for the ghost apps in the layout 151 + appDivs.forEach(({ div }, i) => { 152 + const angle = (i / totalCount) * 2 * Math.PI - Math.PI / 2; 153 + const circleOffset = circleSize / 2; 154 + const x = centerX + radius * Math.cos(angle) - circleOffset; 155 + const y = centerY + radius * Math.sin(angle) - circleOffset; 156 + div.style.left = `${x}px`; 157 + div.style.top = `${y}px`; 158 + }); 159 + } 160 + 112 161 // Add all divs to field 113 162 appDivs.forEach(({ div }) => field.appendChild(div)); 163 + ghostApps.forEach(div => field.appendChild(div)); 114 164 115 165 // Set up identity click handler immediately (non-blocking) 116 166 setupIdentityClickHandler(allCollections, appCount, profile); ··· 159 209 document.querySelector('.identity').addEventListener('click', () => { 160 210 const detail = document.getElementById('detail'); 161 211 162 - // Build the hosting status card based on Bluesky vs independent PDS 212 + // Build the hosting status card based on Bluesky vs independent hosting 163 213 let hostingStatusCard; 164 214 if (isBlueskyPds) { 165 215 hostingStatusCard = ` 166 216 <div class="ownership-box bluesky-hosted"> 167 - <div class="ownership-header">☁️ bluesky-hosted pds</div> 168 - <div class="ownership-text">your data is hosted on bluesky's infrastructure. this is the default and works great! but if you want more control, you can <a href="https://atproto.com/guides/self-hosting" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline; font-weight: 500;">self-host your own PDS</a>.</div> 169 - <div style="margin-top: 0.5rem; font-size: 0.6rem; color: var(--text-lighter);">want to take your data somewhere else? <a href="https://pdsmoover.com" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">pdsmoover</a> makes it easy to move to a different PDS.</div> 217 + <div class="ownership-header">☁️ hosted by bluesky</div> 218 + <div class="ownership-text">your account data lives on bluesky's servers. this is the default and works great! but if you want more control, you can <a href="https://atproto.com/guides/self-hosting" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline; font-weight: 500;">host it yourself</a>.</div> 219 + <div style="margin-top: 0.5rem; font-size: 0.6rem; color: var(--text-lighter);">want to move your account somewhere else? <a href="https://pdsmoover.com" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">pdsmoover</a> makes it easy.</div> 170 220 </div> 171 221 `; 172 222 } else { 173 223 hostingStatusCard = ` 174 224 <div class="ownership-box independent-pds"> 175 - <div class="ownership-header">🌐 independent pds</div> 176 - <div class="ownership-text">your data is hosted on <strong>${pdsHost}</strong>, independent of bluesky's infrastructure.</div> 225 + <div class="ownership-header">🌐 independently hosted</div> 226 + <div class="ownership-text">your account data lives on <strong>${pdsHost}</strong>, independent of bluesky's infrastructure.</div> 177 227 <div style="margin-top: 0.5rem; font-size: 0.6rem; color: var(--text-lighter);">use <a href="https://pdsmoover.com" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">pdsmoover</a> to move your account to a new home.</div> 178 228 </div> 179 229 `; ··· 181 231 182 232 detail.innerHTML = ` 183 233 <button class="detail-close" id="detailClose">x</button> 184 - <h3>your personal data server</h3> 185 - <div class="subtitle">where your social data lives</div> 234 + <h3>your account</h3> 235 + <div class="subtitle">one account, all your apps</div> 186 236 187 237 <div class="stats-box"> 188 238 <div class="stat"> ··· 196 246 </div> 197 247 198 248 <div class="ownership-box yours"> 199 - <div class="ownership-header">your pds location</div> 200 - <div class="ownership-text">your <a href="https://atproto.com/guides/data-repos" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Personal Data Server</a> is hosted at <a href="${state.globalPds}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;"><strong>${pdsHost}</strong></a>. all your posts, likes, and follows are stored here. apps like bluesky just connect to it.</div> 249 + <div class="ownership-header">where your data lives</div> 250 + <div class="ownership-text">your account is hosted at <a href="${state.globalPds}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;"><strong>${pdsHost}</strong></a> — a <a href="https://atproto.com/guides/overview" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">personal data server</a>. all your posts, likes, and follows are stored here. apps just connect to it.</div> 201 251 </div> 202 252 203 253 ${hostingStatusCard} 204 254 205 255 <div class="ownership-box"> 206 - <div class="ownership-header">explore your data</div> 207 - <div class="ownership-text">want to see everything stored on your PDS? check out <a href="https://pdsls.dev/${pdsHost}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">pdsls.dev/${pdsHost}</a> - a tool for browsing all the records in your repository.</div> 256 + <div class="ownership-header">dive deeper</div> 257 + <div class="ownership-text">want to see everything in your account? <a href="https://pdsls.dev/${pdsHost}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">pdsls.dev/${pdsHost}</a> lets you browse every record.</div> 208 258 </div> 209 259 210 260 <a href="https://bsky.app/profile/${state.globalHandle}" target="_blank" rel="noopener noreferrer" class="tree-item" style="text-decoration: none; display: block; margin-top: 1rem;"> ··· 293 343 let html = ` 294 344 <button class="detail-close" id="detailClose">x</button> 295 345 <h3><a href="${appUrl}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border);">${displayName} &#8599;</a></h3> 296 - <div class="subtitle">stores records in <a href="${state.globalPds}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">your PDS</a> <a href="https://overreacted.io/a-social-filesystem/#up-in-the-atmosphere" target="_blank" rel="noopener noreferrer" style="color: var(--text-lighter); font-size: 0.6rem; margin-left: 1rem; opacity: 0.7;">what is a PDS?</a></div> 346 + <div class="subtitle">writes data to <a href="${state.globalPds}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">your account</a></div> 297 347 `; 298 348 299 349 if (collections && collections.length > 0) {
+20 -18
view.html
··· 12 12 <meta property="og:url" content="https://at-me.wisp.place/"> 13 13 <meta property="og:title" content="@me - explore your atproto identity"> 14 14 <meta property="og:description" 15 - content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 15 + content="see your atmosphere account — one account, all your apps and data"> 16 16 <meta property="og:image" content="https://at-me.wisp.place/og-image.png"> 17 17 18 18 <!-- Twitter --> ··· 20 20 <meta property="twitter:url" content="https://at-me.wisp.place/"> 21 21 <meta property="twitter:title" content="@me - explore your atproto identity"> 22 22 <meta property="twitter:description" 23 - content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 23 + content="see your atmosphere account — one account, all your apps and data"> 24 24 <meta property="twitter:image" content="https://at-me.wisp.place/og-image.png"> 25 25 </head> 26 26 ··· 101 101 102 102 <div class="overlay" id="overlay"></div> 103 103 <div class="info-modal" id="infoModal"> 104 - <h2>this is your data</h2> 105 - <p>this visualization shows your <a href="https://atproto.com/guides/data-repos" target="_blank" 106 - rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Personal Data 107 - Server</a> - where your social data actually lives. unlike traditional platforms that lock everything in 108 - their database, your posts, likes, and follows are stored here, on infrastructure you control.</p> 109 - <p>each circle represents an app that writes to your space. <a href="https://bsky.app" target="_blank" 104 + <h2>this is your account</h2> 105 + <p>you're looking at an <a href="https://augment.ink/the-everything-account" target="_blank" 106 + rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">atmosphere account</a> 107 + — one account that works across every app. your posts, likes, follows, and everything else you create 108 + belongs to you, not to any single app.</p> 109 + <p>each circle is an app connected to this account. <a href="https://bsky.app" target="_blank" 110 110 rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">bluesky</a> for 111 111 microblogging. <a href="https://whtwnd.com" target="_blank" rel="noopener noreferrer" 112 112 style="color: var(--text); text-decoration: underline;">whitewind</a> for long-form posts. <a 113 113 href="https://tangled.org" target="_blank" rel="noopener noreferrer" 114 - style="color: var(--text); text-decoration: underline;">tangled.org</a> for code hosting. they're all 115 - just different views of the same underlying data - <strong>your</strong> data.</p> 116 - <p>this is what "<a href="https://overreacted.io/open-social/" target="_blank" rel="noopener noreferrer" 117 - style="color: var(--text); text-decoration: underline;">open social</a>" means: your followers, your 118 - content, your connections - they all belong to you, not the app. switch apps anytime and take everything 119 - with you. no platform can hold your social graph hostage.</p> 120 - <p style="margin-bottom: 1rem;"><strong>how to explore:</strong> click your avatar in the center to see the 121 - details of your identity. click any app to browse the records it's created in your repository.</p> 114 + style="color: var(--text); text-decoration: underline;">tangled.org</a> for code hosting. different 115 + apps, same account, same data.</p> 116 + <p>switch apps anytime. take everything with you. no platform can hold your 117 + <a href="https://overreacted.io/open-social/" target="_blank" rel="noopener noreferrer" 118 + style="color: var(--text); text-decoration: underline;">social graph</a> hostage.</p> 119 + <p style="margin-bottom: 1rem;"><strong>how to explore:</strong> click the avatar in the center to see 120 + account details. click any app to browse the data it's created.</p> 122 121 <button id="closeInfo">got it</button> 123 122 <p 124 123 style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); font-size: 0.7rem; color: var(--text-light); display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;"> 124 + <a href="#" id="replayTour" style="color: var(--text); text-decoration: underline;">replay tour</a> 125 + <span style="opacity: 0.4;">·</span> 125 126 <span>view <a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer" 126 127 style="color: var(--text); text-decoration: underline;">the source code</a> on</span> 127 128 <a href="https://tangled.org" target="_blank" rel="noopener noreferrer" ··· 135 136 </div> 136 137 137 138 <div class="onboarding-overlay" id="onboardingOverlay"> 138 - <div class="onboarding-spotlight" id="onboardingSpotlight"></div> 139 - <div class="onboarding-content" id="onboardingContent"></div> 139 + <div class="onboarding-backdrop" id="onboardingBackdrop"></div> 140 + <div class="onboarding-ring" id="onboardingRing"></div> 141 + <div class="onboarding-card" id="onboardingCard"></div> 140 142 </div> 141 143 142 144 <div class="canvas">