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

fix: improve many-apps UX and record content interaction

- fix icon movement when selected by using absolute positioning for labels
- add pulsing animation on selected state to invite second-click interaction
- remove parentheses from "what is a PDS?" link for cleaner appearance
- add text cursor on record content to indicate text is selectable
- prevent collection content clicks from closing the expanded section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+156 -23
+19
src/view/detail.css
··· 123 123 .tree-item-count { 124 124 font-size: 0.65rem; 125 125 color: var(--text-light); 126 + transition: color 0.15s ease; 127 + } 128 + 129 + .tree-item-count.has-records { 130 + color: var(--text); 131 + font-weight: 500; 132 + } 133 + 134 + .tree-item:hover .tree-item-count.has-records { 135 + color: var(--text); 136 + } 137 + 138 + .tree-item-count.empty { 139 + color: var(--text-lighter); 140 + opacity: 0.6; 126 141 } 127 142 128 143 .collection-content { ··· 277 292 278 293 .record-content { 279 294 padding: 0.6rem; 295 + cursor: text; 296 + user-select: text; 280 297 } 281 298 282 299 .record-content pre { ··· 285 302 word-break: break-word; 286 303 line-height: 1.5; 287 304 font-size: 0.625rem; 305 + cursor: text; 306 + user-select: text; 288 307 } 289 308 290 309 .load-more {
+11
src/view/filters.js
··· 248 248 const maxRadius = Math.max(Math.min(maxTop, maxBottom, maxSide), 150); 249 249 radius = Math.min(radius, maxRadius); 250 250 251 + // Calculate if labels would overlap based on geometry 252 + // Arc length between adjacent apps vs estimated label width 253 + const field = document.getElementById('field'); 254 + const arcLength = (2 * Math.PI * radius) / visibleCount; 255 + const estimatedLabelWidth = 80; // typical label width in px 256 + if (arcLength < estimatedLabelWidth) { 257 + field.classList.add('many-apps'); 258 + } else { 259 + field.classList.remove('many-apps'); 260 + } 261 + 251 262 // Position only visible apps evenly around the circle 252 263 visibleAppViews.forEach((div, i) => { 253 264 const angle = (i / visibleCount) * 2 * Math.PI - Math.PI / 2;
+40 -10
src/view/layout.css
··· 134 134 } 135 135 136 136 .app-name { 137 - font-size: clamp(0.55rem, 1.2vmin, 0.7rem); 137 + position: absolute; 138 + top: 100%; 139 + left: 50%; 140 + transform: translateX(-50%); 141 + margin-top: clamp(0.3rem, 1vmin, 0.5rem); 142 + font-size: clamp(0.6rem, 1.2vmin, 0.75rem); 138 143 color: var(--text); 139 144 text-align: center; 140 - max-width: clamp(70px, 15vmin, 120px); 145 + max-width: 200px; 141 146 text-decoration: none; 142 147 display: block; 143 - overflow: hidden; 144 - text-overflow: ellipsis; 145 148 white-space: nowrap; 149 + transition: opacity 0.15s ease, transform 0.15s ease; 150 + } 151 + 152 + /* When many apps: hide labels by default */ 153 + #field.many-apps .app-name { 154 + display: none; 155 + } 156 + 157 + /* But show label when this specific app is selected */ 158 + #field.many-apps .app-view.selected .app-name { 159 + display: block; 160 + } 161 + 162 + /* Visual feedback when app is selected */ 163 + .app-view.selected { 164 + opacity: 1; 165 + z-index: 100; 166 + } 167 + 168 + .app-view.selected .app-circle { 169 + box-shadow: 0 0 0 3px var(--text-light); 170 + border-color: var(--text); 171 + animation: selectedPulse 2s ease-in-out infinite; 172 + } 173 + 174 + @keyframes selectedPulse { 175 + 0%, 100% { box-shadow: 0 0 0 3px var(--text-light); } 176 + 50% { box-shadow: 0 0 0 5px var(--text-light), 0 0 12px rgba(255, 255, 255, 0.15); } 146 177 } 147 178 148 179 @media (max-width: 768px) { 149 180 .app-name { 150 - font-size: clamp(0.5rem, 1vmin, 0.6rem); 151 - max-width: clamp(60px, 12vmin, 100px); 152 - } 153 - 154 - #field.many-apps .app-name { 155 - display: none; 181 + font-size: clamp(0.55rem, 1vmin, 0.65rem); 156 182 } 157 183 } 158 184 ··· 171 197 text-decoration: none; 172 198 color: var(--text-light); 173 199 } 200 + 201 + #field.many-apps .app-name.invalid-link { 202 + display: none; 203 + }
+83 -11
src/view/visualization.js
··· 18 18 field.innerHTML = ''; 19 19 field.classList.remove('loading'); 20 20 21 + // Global escape key handler to close detail panel 22 + document.addEventListener('keydown', (e) => { 23 + if (e.key === 'Escape') { 24 + const detail = document.getElementById('detail'); 25 + if (detail.classList.contains('visible')) { 26 + detail.classList.remove('visible'); 27 + } 28 + // Also close any open popups 29 + document.querySelectorAll('.app-popup').forEach(p => p.remove()); 30 + } 31 + }); 32 + 21 33 const appNames = Object.keys(apps).sort(); 22 34 const appCount = appNames.length; 23 35 const allCollections = Object.values(apps).flat(); 24 36 25 - // Hide labels on mobile when there are too many apps 26 - const isMobileView = window.innerWidth < 768; 27 - if (isMobileView && appCount > 20) { 28 - field.classList.add('many-apps'); 29 - } 30 - 31 37 // Calculate dimensions 32 38 const vmin = Math.min(window.innerWidth, window.innerHeight); 33 39 const isMobile = window.innerWidth < 768; ··· 63 69 radius = Math.max(vmin * 0.35, 150); 64 70 } 65 71 72 + // Calculate if labels would overlap based on geometry 73 + // Arc length between adjacent apps vs estimated label width 74 + const arcLength = (2 * Math.PI * radius) / appCount; 75 + const estimatedLabelWidth = 80; // typical label width in px 76 + if (arcLength < estimatedLabelWidth) { 77 + field.classList.add('many-apps'); 78 + } 79 + 66 80 const centerX = window.innerWidth / 2; 67 81 const centerY = window.innerHeight / 2; 68 82 ··· 90 104 <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName} &#8599;</a> 91 105 `; 92 106 93 - div.addEventListener('click', () => showAppDetail(namespace, apps[namespace], displayName, url)); 107 + div.addEventListener('click', (e) => handleAppClick(e, div, namespace, apps[namespace], displayName, url)); 94 108 95 109 return { div, namespace }; 96 110 }); ··· 230 244 }); 231 245 } 232 246 247 + // Handle app click - show popup with name when many apps, then show detail on second click 248 + function handleAppClick(e, appDiv, namespace, collections, displayName, appUrl) { 249 + const field = document.getElementById('field'); 250 + const isManyApps = field.classList.contains('many-apps'); 251 + 252 + // If clicking the link directly, let it navigate 253 + if (e.target.classList.contains('app-name') || e.target.classList.contains('app-popup-link')) { 254 + return; 255 + } 256 + 257 + // If not many apps, show detail directly (labels already visible) 258 + if (!isManyApps) { 259 + showAppDetail(namespace, collections, displayName, appUrl); 260 + return; 261 + } 262 + 263 + // Check if this app is already selected (label visible) 264 + const isSelected = appDiv.classList.contains('selected'); 265 + if (isSelected) { 266 + // Second click - show PDS data 267 + appDiv.classList.remove('selected'); 268 + showAppDetail(namespace, collections, displayName, appUrl); 269 + return; 270 + } 271 + 272 + // Deselect any other selected apps 273 + document.querySelectorAll('.app-view.selected').forEach(el => el.classList.remove('selected')); 274 + 275 + // Select this app - reveals its label 276 + appDiv.classList.add('selected'); 277 + 278 + // Deselect when clicking outside 279 + const deselectOnClickOutside = (e) => { 280 + if (!appDiv.contains(e.target)) { 281 + appDiv.classList.remove('selected'); 282 + document.removeEventListener('click', deselectOnClickOutside); 283 + } 284 + }; 285 + setTimeout(() => document.addEventListener('click', deselectOnClickOutside), 0); 286 + } 287 + 233 288 async function showAppDetail(namespace, collections, displayName, appUrl) { 234 289 const detail = document.getElementById('detail'); 290 + 291 + const pdsHost = state.globalPds.replace('https://', '').replace('http://', ''); 235 292 236 293 let html = ` 237 294 <button class="detail-close" id="detailClose">x</button> 238 295 <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> 239 - <div class="subtitle">records stored in your <a href="https://atproto.com/guides/self-hosting" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">PDS</a>:</div> 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://atproto.com/guides/self-hosting" 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> 240 297 `; 241 298 242 299 if (collections && collections.length > 0) { ··· 292 349 detail.classList.remove('visible'); 293 350 }); 294 351 295 - // Fetch record counts 352 + // Fetch record counts with a larger sample to show meaningful counts 296 353 if (collections) { 297 354 for (const lexicon of collections) { 298 - const data = await listRecords(state.globalPds, state.did, lexicon, 1); 355 + const data = await listRecords(state.globalPds, state.did, lexicon, 25); 299 356 const item = detail.querySelector(`[data-lexicon="${lexicon}"]`); 300 357 if (item) { 301 358 const countSpan = item.querySelector('.tree-item-count'); 302 - countSpan.textContent = data?.records?.length > 0 ? 'has records' : 'empty'; 359 + const count = data?.records?.length || 0; 360 + if (count === 0) { 361 + countSpan.textContent = 'empty'; 362 + countSpan.classList.add('empty'); 363 + } else if (count < 25) { 364 + countSpan.textContent = `explore ${count} record${count === 1 ? '' : 's'} →`; 365 + countSpan.classList.add('has-records'); 366 + } else { 367 + countSpan.textContent = `explore 25+ records →`; 368 + countSpan.classList.add('has-records'); 369 + } 303 370 } 304 371 } 305 372 } ··· 335 402 </div> 336 403 `; 337 404 item.appendChild(contentDiv); 405 + 406 + // Prevent clicks inside the collection content from bubbling up to tree-item (which would toggle/close it) 407 + contentDiv.addEventListener('click', (e) => { 408 + e.stopPropagation(); 409 + }); 338 410 339 411 const recordsView = contentDiv.querySelector('.records-view'); 340 412 const structureView = contentDiv.querySelector('.structure-view');
+3 -2
vite.config.js
··· 45 45 process.env.VITE_OAUTH_SCOPE = metadata.scope; 46 46 } 47 47 }, 48 - // Rewrite /view to /view.html for dev server 48 + // Rewrite /view/ to /view.html for dev server 49 49 { 50 50 name: 'rewrite-view', 51 51 configureServer(server) { 52 52 server.middlewares.use((req, res, next) => { 53 53 if (req.url.startsWith('/view') && !req.url.includes('.html')) { 54 - req.url = req.url.replace('/view', '/view.html'); 54 + // /view/ or /view/?query -> /view.html or /view.html?query 55 + req.url = req.url.replace(/^\/view\/?/, '/view.html'); 55 56 } 56 57 next(); 57 58 });