interactive intro to open social

feat: add frontend firehose visualization with particle animations

implements the visual layer for real-time firehose events:
- watch live toggle button in top-right corner
- toast notifications showing event actions (create/update/delete)
- particle animation system using canvas overlay
- colored particles flow from identity to app circles
- particles: green for create, blue for update, red for delete
- app circles pulse when receiving data
- auto-reconnects on connection drop
- clean shutdown when toggled off

complete implementation:
- backend: rust jetstream connector + SSE endpoint (src/firehose.rs, src/routes.rs)
- frontend: particle system + event handling (static/app.js)
- ui: button, toast, css animations (src/templates.rs)

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

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

Changed files
+398
src
static
+105
src/templates.rs
··· 1179 1179 .ownership-text strong {{ 1180 1180 color: var(--text); 1181 1181 }} 1182 + 1183 + .watch-live-btn {{ 1184 + position: fixed; 1185 + top: clamp(1rem, 2vmin, 1.5rem); 1186 + right: clamp(6rem, 14vmin, 9rem); 1187 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1188 + color: var(--text-light); 1189 + border: 1px solid var(--border); 1190 + background: var(--bg); 1191 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1192 + transition: all 0.2s ease; 1193 + z-index: 100; 1194 + cursor: pointer; 1195 + border-radius: 2px; 1196 + display: flex; 1197 + align-items: center; 1198 + gap: 0.5rem; 1199 + }} 1200 + 1201 + .watch-live-btn:hover {{ 1202 + background: var(--surface); 1203 + color: var(--text); 1204 + border-color: var(--text-light); 1205 + }} 1206 + 1207 + .watch-live-btn.active {{ 1208 + background: var(--surface-hover); 1209 + color: var(--text); 1210 + border-color: var(--text); 1211 + }} 1212 + 1213 + .watch-indicator {{ 1214 + width: 8px; 1215 + height: 8px; 1216 + border-radius: 50%; 1217 + background: var(--text-light); 1218 + display: none; 1219 + }} 1220 + 1221 + .watch-live-btn.active .watch-indicator {{ 1222 + display: block; 1223 + animation: pulse 2s ease-in-out infinite; 1224 + }} 1225 + 1226 + @keyframes pulse {{ 1227 + 0%, 100% {{ opacity: 1; }} 1228 + 50% {{ opacity: 0.3; }} 1229 + }} 1230 + 1231 + .firehose-toast {{ 1232 + position: fixed; 1233 + top: clamp(4rem, 8vmin, 5rem); 1234 + right: clamp(1rem, 2vmin, 1.5rem); 1235 + background: var(--surface); 1236 + border: 1px solid var(--border); 1237 + padding: 0.75rem 1rem; 1238 + border-radius: 4px; 1239 + font-size: 0.7rem; 1240 + color: var(--text); 1241 + z-index: 200; 1242 + opacity: 0; 1243 + transform: translateY(-10px); 1244 + transition: all 0.3s ease; 1245 + pointer-events: none; 1246 + max-width: 300px; 1247 + }} 1248 + 1249 + .firehose-toast.visible {{ 1250 + opacity: 1; 1251 + transform: translateY(0); 1252 + }} 1253 + 1254 + .firehose-toast-action {{ 1255 + font-weight: 600; 1256 + color: var(--text); 1257 + }} 1258 + 1259 + .firehose-toast-collection {{ 1260 + color: var(--text-light); 1261 + font-size: 0.65rem; 1262 + margin-top: 0.25rem; 1263 + }} 1264 + 1265 + @media (max-width: 768px) {{ 1266 + .watch-live-btn {{ 1267 + right: clamp(1rem, 2vmin, 1.5rem); 1268 + top: clamp(4rem, 8vmin, 5rem); 1269 + }} 1270 + 1271 + .firehose-toast {{ 1272 + top: clamp(7rem, 12vmin, 8rem); 1273 + right: clamp(1rem, 2vmin, 1.5rem); 1274 + left: clamp(1rem, 2vmin, 1.5rem); 1275 + max-width: none; 1276 + }} 1277 + }} 1182 1278 </style> 1183 1279 </head> 1184 1280 <body> 1185 1281 <div class="info" id="infoBtn">?</div> 1282 + <button class="watch-live-btn" id="watchLiveBtn"> 1283 + <span class="watch-indicator"></span> 1284 + <span class="watch-label">watch live</span> 1285 + </button> 1186 1286 <a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a> 1287 + 1288 + <div class="firehose-toast" id="firehoseToast"> 1289 + <div class="firehose-toast-action"></div> 1290 + <div class="firehose-toast-collection"></div> 1291 + </div> 1187 1292 1188 1293 <div class="overlay" id="overlay"></div> 1189 1294 <div class="info-modal" id="infoModal">
+293
static/app.js
··· 731 731 traverse(tree, 0, padding, width - padding); 732 732 return nodes; 733 733 } 734 + 735 + // ============================================================================ 736 + // FIREHOSE VISUALIZATION 737 + // ============================================================================ 738 + 739 + // Particle class for animating firehose events 740 + class FirehoseParticle { 741 + constructor(startX, startY, endX, endY, color, metadata) { 742 + this.x = startX; 743 + this.y = startY; 744 + this.startX = startX; 745 + this.startY = startY; 746 + this.endX = endX; 747 + this.endY = endY; 748 + this.color = color; 749 + this.metadata = metadata; // {action, collection, namespace} 750 + this.progress = 0; 751 + this.speed = 0.012; // Slower for visibility 752 + this.size = 5; 753 + this.glowSize = 10; 754 + } 755 + 756 + update() { 757 + if (this.progress < 1) { 758 + this.progress += this.speed; 759 + // Cubic ease-in-out 760 + const eased = this.progress < 0.5 761 + ? 4 * this.progress * this.progress * this.progress 762 + : 1 - Math.pow(-2 * this.progress + 2, 3) / 2; 763 + 764 + this.x = this.startX + (this.endX - this.startX) * eased; 765 + this.y = this.startY + (this.endY - this.startY) * eased; 766 + } 767 + return this.progress < 1; 768 + } 769 + 770 + draw(ctx) { 771 + // Outer glow 772 + ctx.beginPath(); 773 + ctx.arc(this.x, this.y, this.glowSize, 0, Math.PI * 2); 774 + const gradient = ctx.createRadialGradient( 775 + this.x, this.y, 0, 776 + this.x, this.y, this.glowSize 777 + ); 778 + gradient.addColorStop(0, this.color + '80'); 779 + gradient.addColorStop(1, this.color + '00'); 780 + ctx.fillStyle = gradient; 781 + ctx.fill(); 782 + 783 + // Inner particle 784 + ctx.beginPath(); 785 + ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); 786 + ctx.fillStyle = this.color; 787 + ctx.fill(); 788 + } 789 + } 790 + 791 + // Firehose state 792 + let firehoseParticles = []; 793 + let firehoseCanvas = null; 794 + let firehoseCtx = null; 795 + let firehoseAnimationId = null; 796 + let firehoseEventSource = null; 797 + let isWatchingLive = false; 798 + 799 + function initFirehoseCanvas() { 800 + // Create canvas overlay 801 + firehoseCanvas = document.createElement('canvas'); 802 + firehoseCanvas.id = 'firehoseCanvas'; 803 + firehoseCanvas.style.position = 'fixed'; 804 + firehoseCanvas.style.top = '0'; 805 + firehoseCanvas.style.left = '0'; 806 + firehoseCanvas.style.width = '100%'; 807 + firehoseCanvas.style.height = '100%'; 808 + firehoseCanvas.style.pointerEvents = 'none'; 809 + firehoseCanvas.style.zIndex = '50'; 810 + firehoseCanvas.width = window.innerWidth; 811 + firehoseCanvas.height = window.innerHeight; 812 + 813 + document.body.appendChild(firehoseCanvas); 814 + firehoseCtx = firehoseCanvas.getContext('2d'); 815 + 816 + // Handle window resize 817 + window.addEventListener('resize', () => { 818 + firehoseCanvas.width = window.innerWidth; 819 + firehoseCanvas.height = window.innerHeight; 820 + }); 821 + } 822 + 823 + function animateFirehoseParticles() { 824 + if (!firehoseCtx) return; 825 + 826 + firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height); 827 + 828 + // Update and draw all particles 829 + firehoseParticles = firehoseParticles.filter(particle => { 830 + const alive = particle.update(); 831 + if (alive) { 832 + particle.draw(firehoseCtx); 833 + } else { 834 + // Particle reached destination - pulse the app circle 835 + pulseAppCircle(particle.metadata.namespace); 836 + } 837 + return alive; 838 + }); 839 + 840 + if (isWatchingLive) { 841 + firehoseAnimationId = requestAnimationFrame(animateFirehoseParticles); 842 + } 843 + } 844 + 845 + function pulseAppCircle(namespace) { 846 + const appCircle = document.querySelector(`[data-namespace="${namespace}"]`); 847 + if (appCircle) { 848 + appCircle.style.transition = 'all 0.3s ease'; 849 + appCircle.style.transform = 'scale(1.2)'; 850 + appCircle.style.boxShadow = '0 0 20px rgba(255, 255, 255, 0.5)'; 851 + 852 + setTimeout(() => { 853 + appCircle.style.transform = ''; 854 + appCircle.style.boxShadow = ''; 855 + }, 300); 856 + } 857 + } 858 + 859 + function showFirehoseToast(action, collection) { 860 + const toast = document.getElementById('firehoseToast'); 861 + const actionEl = toast.querySelector('.firehose-toast-action'); 862 + const collectionEl = toast.querySelector('.firehose-toast-collection'); 863 + 864 + const actionText = { 865 + 'create': 'created', 866 + 'update': 'updated', 867 + 'delete': 'deleted' 868 + }[action] || action; 869 + 870 + actionEl.textContent = `${actionText} record`; 871 + collectionEl.textContent = collection; 872 + 873 + toast.classList.add('visible'); 874 + setTimeout(() => { 875 + toast.classList.remove('visible'); 876 + }, 3000); 877 + } 878 + 879 + function getParticleColor(action) { 880 + const colors = { 881 + 'create': '#4ade80', // green 882 + 'update': '#60a5fa', // blue 883 + 'delete': '#f87171' // red 884 + }; 885 + return colors[action] || '#a0a0a0'; 886 + } 887 + 888 + function createFirehoseParticle(event) { 889 + // Get identity circle position 890 + const identity = document.querySelector('.identity'); 891 + if (!identity) return; 892 + 893 + const identityRect = identity.getBoundingClientRect(); 894 + const startX = identityRect.left + identityRect.width / 2; 895 + const startY = identityRect.top + identityRect.height / 2; 896 + 897 + // Get target app circle position 898 + const appCircle = document.querySelector(`[data-namespace="${event.namespace}"]`); 899 + if (!appCircle) return; 900 + 901 + const appRect = appCircle.getBoundingClientRect(); 902 + const endX = appRect.left + appRect.width / 2; 903 + const endY = appRect.top + appRect.height / 2; 904 + 905 + // Create particle 906 + const particle = new FirehoseParticle( 907 + startX, startY, 908 + endX, endY, 909 + getParticleColor(event.action), 910 + { 911 + action: event.action, 912 + collection: event.collection, 913 + namespace: event.namespace 914 + } 915 + ); 916 + 917 + firehoseParticles.push(particle); 918 + } 919 + 920 + function connectFirehose() { 921 + if (!did || firehoseEventSource) return; 922 + 923 + const url = `/api/firehose/watch?did=${encodeURIComponent(did)}`; 924 + console.log('Connecting to firehose:', url); 925 + 926 + firehoseEventSource = new EventSource(url); 927 + 928 + const watchBtn = document.getElementById('watchLiveBtn'); 929 + const watchLabel = watchBtn.querySelector('.watch-label'); 930 + 931 + firehoseEventSource.onopen = () => { 932 + console.log('Firehose connected'); 933 + watchLabel.textContent = 'watching...'; 934 + watchBtn.classList.add('active'); 935 + }; 936 + 937 + firehoseEventSource.onmessage = (e) => { 938 + try { 939 + const data = JSON.parse(e.data); 940 + 941 + // Skip connection message 942 + if (data.type === 'connected') { 943 + console.log('Firehose connection established'); 944 + return; 945 + } 946 + 947 + console.log('Firehose event:', data); 948 + 949 + // Create particle animation 950 + createFirehoseParticle(data); 951 + 952 + // Show toast notification 953 + showFirehoseToast(data.action, data.collection); 954 + } catch (error) { 955 + console.error('Error processing firehose message:', error); 956 + } 957 + }; 958 + 959 + firehoseEventSource.onerror = (error) => { 960 + console.error('Firehose error:', error); 961 + watchLabel.textContent = 'connection error'; 962 + 963 + // Attempt to reconnect after delay 964 + if (isWatchingLive) { 965 + setTimeout(() => { 966 + if (firehoseEventSource) { 967 + firehoseEventSource.close(); 968 + firehoseEventSource = null; 969 + } 970 + if (isWatchingLive) { 971 + watchLabel.textContent = 'reconnecting...'; 972 + connectFirehose(); 973 + } 974 + }, 3000); 975 + } 976 + }; 977 + } 978 + 979 + function disconnectFirehose() { 980 + if (firehoseEventSource) { 981 + firehoseEventSource.close(); 982 + firehoseEventSource = null; 983 + } 984 + 985 + if (firehoseAnimationId) { 986 + cancelAnimationFrame(firehoseAnimationId); 987 + firehoseAnimationId = null; 988 + } 989 + 990 + firehoseParticles = []; 991 + if (firehoseCtx) { 992 + firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height); 993 + } 994 + } 995 + 996 + // Toggle watch live 997 + document.addEventListener('DOMContentLoaded', () => { 998 + const watchBtn = document.getElementById('watchLiveBtn'); 999 + if (!watchBtn) return; 1000 + 1001 + const watchLabel = watchBtn.querySelector('.watch-label'); 1002 + 1003 + watchBtn.addEventListener('click', () => { 1004 + isWatchingLive = !isWatchingLive; 1005 + 1006 + if (isWatchingLive) { 1007 + // Start watching 1008 + watchLabel.textContent = 'connecting...'; 1009 + initFirehoseCanvas(); 1010 + connectFirehose(); 1011 + animateFirehoseParticles(); 1012 + } else { 1013 + // Stop watching 1014 + watchLabel.textContent = 'watch live'; 1015 + watchBtn.classList.remove('active'); 1016 + disconnectFirehose(); 1017 + 1018 + // Clean up canvas 1019 + if (firehoseCanvas) { 1020 + firehoseCanvas.remove(); 1021 + firehoseCanvas = null; 1022 + firehoseCtx = null; 1023 + } 1024 + } 1025 + }); 1026 + });