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

feat: improve mobile UX and adaptive text sizing

- add adaptive handle text sizing that scales down for long handles
- improve mobile radial layout with better circle sizing and radius calculations
- add text overflow handling with ellipsis for app labels
- add "You" label below identity circle
- make port configurable via PORT environment variable
- speed up resize updates (100ms -> 50ms debounce)
- add conditional label hiding for mobile with 20+ apps

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

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

Changed files
+191 -17
src
static
+5
README.md
··· 19 19 ``` 20 20 21 21 then visit http://localhost:8080 and sign in with any atproto handle. 22 + 23 + to use a different port: 24 + ```bash 25 + PORT=3000 cargo run 26 + ```
+8 -2
src/main.rs
··· 13 13 // Create the firehose manager (connections created lazily per-DID) 14 14 let firehose_manager = firehose::create_firehose_manager(); 15 15 16 - println!("starting server at http://localhost:8080"); 16 + // Get port from environment variable, default to 8080 17 + let port = std::env::var("PORT") 18 + .unwrap_or_else(|_| "8080".to_string()) 19 + .parse::<u16>() 20 + .expect("PORT must be a valid number"); 21 + 22 + println!("starting server at http://localhost:{}", port); 17 23 18 24 HttpServer::new(move || { 19 25 App::new() ··· 30 36 .service(routes::favicon) 31 37 .service(Files::new("/static", "./static")) 32 38 }) 33 - .bind(("0.0.0.0", 8080))? 39 + .bind(("0.0.0.0", port))? 34 40 .run() 35 41 .await 36 42 }
+18 -3
src/templates.rs
··· 797 797 }} 798 798 799 799 .app-name {{ 800 - font-size: clamp(0.6rem, 1.2vmin, 0.7rem); 800 + font-size: clamp(0.55rem, 1.2vmin, 0.7rem); 801 801 color: var(--text); 802 802 text-align: center; 803 - max-width: clamp(80px, 15vmin, 120px); 803 + max-width: clamp(70px, 15vmin, 120px); 804 804 text-decoration: none; 805 805 display: block; 806 + overflow: hidden; 807 + text-overflow: ellipsis; 808 + white-space: nowrap; 809 + }} 810 + 811 + @media (max-width: 768px) {{ 812 + .app-name {{ 813 + font-size: clamp(0.5rem, 1vmin, 0.6rem); 814 + max-width: clamp(60px, 12vmin, 100px); 815 + }} 816 + 817 + /* Hide labels when there are too many apps on mobile */ 818 + .field.many-apps .app-name {{ 819 + display: none; 820 + }} 806 821 }} 807 822 808 823 .app-name:hover {{ ··· 1630 1645 <div class="identity"> 1631 1646 <div class="identity-label">@</div> 1632 1647 <div class="identity-value" id="handle">loading...</div> 1633 - <div class="identity-pds-label">Your PDS</div> 1648 + <div class="identity-pds-label">You</div> 1634 1649 </div> 1635 1650 <div id="field" class="loading">loading...</div> 1636 1651 </div>
+160 -12
static/app.js
··· 6 6 let globalHandle = null; 7 7 let globalApps = null; // Store apps for repositioning on resize 8 8 9 + // Adaptive handle text sizing 10 + function adaptHandleTextSize(handleEl) { 11 + const identity = handleEl.closest('.identity'); 12 + if (!identity) return; 13 + 14 + // Get identity circle size 15 + const maxWidth = identity.offsetWidth * 0.85; // 85% of circle width for padding 16 + 17 + // Start with the CSS-defined font size and scale down if needed 18 + const computedStyle = window.getComputedStyle(handleEl); 19 + const maxFontSize = parseFloat(computedStyle.fontSize); 20 + let fontSize = maxFontSize; 21 + 22 + // Create temporary element to measure text width 23 + const measure = document.createElement('span'); 24 + measure.style.visibility = 'hidden'; 25 + measure.style.position = 'absolute'; 26 + measure.style.whiteSpace = 'nowrap'; 27 + measure.style.fontFamily = computedStyle.fontFamily; 28 + measure.textContent = handleEl.textContent; 29 + document.body.appendChild(measure); 30 + 31 + // Reduce font size until text fits 32 + while (fontSize > 8) { // minimum 8px 33 + measure.style.fontSize = fontSize + 'px'; 34 + if (measure.offsetWidth <= maxWidth) break; 35 + fontSize -= 0.5; 36 + } 37 + 38 + document.body.removeChild(measure); 39 + handleEl.style.fontSize = fontSize + 'px'; 40 + } 41 + 9 42 // Fetch app avatar from server 10 43 async function fetchAppAvatar(namespace) { 11 44 try { ··· 42 75 globalPds = initData.pds; 43 76 globalHandle = initData.handle; 44 77 45 - // Update identity display with handle 46 - document.getElementById('handle').textContent = initData.handle; 78 + // Update identity display with handle and adapt text size 79 + const handleEl = document.getElementById('handle'); 80 + handleEl.textContent = initData.handle; 81 + adaptHandleTextSize(handleEl); 47 82 48 83 // Display user's avatar if available 49 84 if (initData.avatar) { ··· 129 164 field.classList.remove('loading'); 130 165 131 166 const appNames = Object.keys(apps).sort(); 132 - // Responsive radius: use viewport-relative sizing with min/max bounds 167 + const appCount = appNames.length; 168 + 169 + // Hide labels on mobile when there are too many apps 170 + const isMobileView = window.innerWidth < 768; 171 + if (isMobileView && appCount > 20) { 172 + field.classList.add('many-apps'); 173 + } else { 174 + field.classList.remove('many-apps'); 175 + } 176 + 177 + // Calculate circle size and radius based on viewport and app count 133 178 const vmin = Math.min(window.innerWidth, window.innerHeight); 134 - const radius = Math.max(vmin * 0.35, 150); // 35% of smallest dimension, min 150px 179 + const isMobile = window.innerWidth < 768; 180 + 181 + let circleSize; 182 + let radius; 183 + 184 + if (isMobile) { 185 + // Mobile: more aggressive scaling for many apps 186 + if (appCount <= 5) { 187 + circleSize = Math.min(60, vmin * 0.08); 188 + radius = vmin * 0.38; 189 + } else if (appCount <= 10) { 190 + circleSize = Math.min(50, vmin * 0.07); 191 + radius = vmin * 0.4; 192 + } else if (appCount <= 20) { 193 + circleSize = Math.min(40, vmin * 0.055); 194 + radius = vmin * 0.42; 195 + } else { 196 + circleSize = Math.min(32, vmin * 0.045); 197 + radius = vmin * 0.44; 198 + } 199 + circleSize = Math.max(circleSize, 28); // Smaller minimum on mobile 200 + radius = Math.max(radius, 120); 201 + } else { 202 + // Desktop: original logic with slight tweaks 203 + if (appCount <= 5) { 204 + circleSize = Math.min(70, vmin * 0.1); 205 + } else if (appCount <= 10) { 206 + circleSize = Math.min(60, vmin * 0.09); 207 + } else if (appCount <= 20) { 208 + circleSize = Math.min(50, vmin * 0.07); 209 + } else { 210 + circleSize = Math.min(40, vmin * 0.06); 211 + } 212 + circleSize = Math.max(circleSize, 35); 213 + radius = Math.max(vmin * 0.35, 150); 214 + } 215 + 135 216 const centerX = window.innerWidth / 2; 136 217 const centerY = window.innerHeight / 2; 137 218 219 + // Store circle size for resize handler 220 + globalApps._circleSize = circleSize; 221 + 138 222 appNames.forEach((namespace, i) => { 139 223 const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top 140 - const x = centerX + radius * Math.cos(angle) - 30; 141 - const y = centerY + radius * Math.sin(angle) - 30; 224 + const circleOffset = circleSize / 2; 225 + const x = centerX + radius * Math.cos(angle) - circleOffset; 226 + const y = centerY + radius * Math.sin(angle) - circleOffset; 142 227 143 228 const div = document.createElement('div'); 144 229 div.className = 'app-view'; ··· 152 237 const url = `https://${displayName}`; 153 238 154 239 div.innerHTML = ` 155 - <div class="app-circle" data-namespace="${namespace}">${firstLetter}</div> 240 + <div class="app-circle" data-namespace="${namespace}" style="width: ${circleSize}px; height: ${circleSize}px; font-size: ${circleSize * 0.4}px;">${firstLetter}</div> 156 241 <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName} ↗</a> 157 242 `; 158 243 ··· 477 562 clearTimeout(resizeTimeout); 478 563 resizeTimeout = setTimeout(() => { 479 564 repositionAppCircles(); 480 - }, 100); // Debounce resize events 565 + // Re-adapt handle text size on resize 566 + const handleEl = document.getElementById('handle'); 567 + if (handleEl) adaptHandleTextSize(handleEl); 568 + }, 50); // Faster debounce for smoother updates 481 569 }); 482 570 }) 483 571 .catch(e => { ··· 490 578 if (!globalApps) return; 491 579 492 580 const appViews = document.querySelectorAll('.app-view'); 493 - const appNames = Object.keys(globalApps).sort(); 581 + const appNames = Object.keys(globalApps).filter(k => k !== '_circleSize').sort(); 582 + const appCount = appNames.length; 583 + 584 + // Update label visibility on resize 585 + const field = document.getElementById('field'); 586 + const isMobileView = window.innerWidth < 768; 587 + if (isMobileView && appCount > 20) { 588 + field.classList.add('many-apps'); 589 + } else { 590 + field.classList.remove('many-apps'); 591 + } 494 592 593 + // Recalculate circle size and radius based on viewport and app count 495 594 const vmin = Math.min(window.innerWidth, window.innerHeight); 496 - const radius = Math.max(vmin * 0.35, 150); 595 + const isMobile = window.innerWidth < 768; 596 + 597 + let circleSize; 598 + let radius; 599 + 600 + if (isMobile) { 601 + // Mobile: more aggressive scaling for many apps 602 + if (appCount <= 5) { 603 + circleSize = Math.min(60, vmin * 0.08); 604 + radius = vmin * 0.38; 605 + } else if (appCount <= 10) { 606 + circleSize = Math.min(50, vmin * 0.07); 607 + radius = vmin * 0.4; 608 + } else if (appCount <= 20) { 609 + circleSize = Math.min(40, vmin * 0.055); 610 + radius = vmin * 0.42; 611 + } else { 612 + circleSize = Math.min(32, vmin * 0.045); 613 + radius = vmin * 0.44; 614 + } 615 + circleSize = Math.max(circleSize, 28); 616 + radius = Math.max(radius, 120); 617 + } else { 618 + // Desktop: original logic with slight tweaks 619 + if (appCount <= 5) { 620 + circleSize = Math.min(70, vmin * 0.1); 621 + } else if (appCount <= 10) { 622 + circleSize = Math.min(60, vmin * 0.09); 623 + } else if (appCount <= 20) { 624 + circleSize = Math.min(50, vmin * 0.07); 625 + } else { 626 + circleSize = Math.min(40, vmin * 0.06); 627 + } 628 + circleSize = Math.max(circleSize, 35); 629 + radius = Math.max(vmin * 0.35, 150); 630 + } 631 + 632 + // Update stored circle size 633 + globalApps._circleSize = circleSize; 634 + 635 + // Recalculate center 497 636 const centerX = window.innerWidth / 2; 498 637 const centerY = window.innerHeight / 2; 499 638 500 639 appViews.forEach((div, i) => { 501 640 const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; 502 - const x = centerX + radius * Math.cos(angle) - 30; 503 - const y = centerY + radius * Math.sin(angle) - 30; 641 + const circleOffset = circleSize / 2; 642 + const x = centerX + radius * Math.cos(angle) - circleOffset; 643 + const y = centerY + radius * Math.sin(angle) - circleOffset; 504 644 505 645 div.style.left = `${x}px`; 506 646 div.style.top = `${y}px`; 647 + 648 + // Update circle size 649 + const circle = div.querySelector('.app-circle'); 650 + if (circle) { 651 + circle.style.width = `${circleSize}px`; 652 + circle.style.height = `${circleSize}px`; 653 + circle.style.fontSize = `${circleSize * 0.4}px`; 654 + } 507 655 }); 508 656 } 509 657