···
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
* { margin: 0; padding: 0; box-sizing: border-box; }
11
-
body { font-family: 'Monaco', 'Courier New', monospace; display: flex; align-items: center; justify-content: center; height: 100vh; background: #000; color: #0f0; }
12
-
.container { text-align: center; }
13
-
h1 { font-size: 2rem; margin-bottom: 2rem; }
14
-
input { font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9rem; padding: 0.5rem; margin: 0.5rem; background: #000; border: 1px solid #0f0; color: #0f0; }
15
-
button { font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9rem; padding: 0.5rem 1rem; cursor: pointer; background: #000; border: 1px solid #0f0; color: #0f0; }
16
-
button:hover { background: #0f0; color: #000; }
13
+
font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace;
15
+
background: radial-gradient(ellipse at center, #0a0a0f 0%, #000000 100%);
18
+
perspective: 1000px;
24
+
transform-style: preserve-3d;
25
+
animation: rotate 120s infinite linear;
29
+
from { transform: rotateY(0deg); }
30
+
to { transform: rotateY(360deg); }
37
+
align-items: center;
38
+
justify-content: center;
39
+
transition: all 0.3s ease;
41
+
backdrop-filter: blur(4px);
45
+
transform: scale(1.2) !important;
56
+
.app-orb .fallback {
59
+
color: rgba(255, 255, 255, 0.9);
64
+
background: rgba(10, 10, 15, 0.95);
65
+
border: 1px solid rgba(255, 255, 255, 0.1);
66
+
padding: 0.5rem 0.75rem;
69
+
white-space: nowrap;
70
+
pointer-events: none;
72
+
transition: opacity 0.2s;
76
+
.app-orb:hover .app-tooltip {
84
+
align-items: center;
85
+
justify-content: center;
90
+
background: rgba(10, 10, 15, 0.8);
91
+
border: 1px solid rgba(255, 255, 255, 0.1);
92
+
padding: 2.5rem 3rem;
94
+
backdrop-filter: blur(10px);
96
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
101
+
margin-bottom: 0.5rem;
103
+
letter-spacing: 0.05em;
107
+
font-size: 0.75rem;
108
+
color: rgba(255, 255, 255, 0.5);
109
+
margin-bottom: 2rem;
113
+
font-family: inherit;
115
+
padding: 0.75rem 1rem;
116
+
margin-bottom: 1rem;
117
+
background: rgba(255, 255, 255, 0.05);
118
+
border: 1px solid rgba(255, 255, 255, 0.1);
119
+
border-radius: 4px;
123
+
transition: all 0.2s;
128
+
border-color: rgba(255, 255, 255, 0.3);
129
+
background: rgba(255, 255, 255, 0.08);
132
+
input::placeholder {
133
+
color: rgba(255, 255, 255, 0.3);
137
+
font-family: inherit;
139
+
padding: 0.75rem 2rem;
141
+
background: rgba(255, 255, 255, 0.1);
142
+
border: 1px solid rgba(255, 255, 255, 0.2);
143
+
border-radius: 4px;
145
+
transition: all 0.2s;
150
+
background: rgba(255, 255, 255, 0.15);
151
+
border-color: rgba(255, 255, 255, 0.3);
.hidden { display: none; }
18
-
.loading { color: #0f0; opacity: 0.5; }
155
+
.loading { color: rgba(255, 255, 255, 0.5); font-size: 0.9rem; }
161
+
transform: translateX(-50%);
163
+
color: rgba(255, 255, 255, 0.3);
168
+
color: rgba(255, 255, 255, 0.5);
169
+
text-decoration: none;
170
+
transition: color 0.2s;
174
+
color: rgba(255, 255, 255, 0.8);
179
+
<div class="atmosphere" id="atmosphere"></div>
23
-
<div id="restoring" class="loading hidden">restoring session...</div>
24
-
<form id="loginForm" method="post" action="/login">
26
-
<input type="text" name="handle" placeholder="handle.bsky.social" required autofocus>
27
-
<button type="submit">login</button>
182
+
<div class="login-card">
183
+
<div id="restoring" class="loading hidden">restoring session...</div>
184
+
<form id="loginForm" method="post" action="/login">
186
+
<div class="subtitle">explore the atmosphere</div>
187
+
<input type="text" name="handle" placeholder="handle.bsky.social" required autofocus>
188
+
<button type="submit">enter</button>
193
+
<div class="footer">
194
+
by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener noreferrer">@zzstoatzz.io</a>
198
+
// Check for saved session
const savedDid = localStorage.getItem('atme_did');
document.getElementById('loginForm').classList.add('hidden');
···
document.getElementById('restoring').classList.add('hidden');
223
+
// Fetch and cache atmosphere data
224
+
async function fetchAtmosphere() {
225
+
const CACHE_KEY = 'atme_atmosphere';
226
+
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
228
+
const cached = localStorage.getItem(CACHE_KEY);
230
+
const { data, timestamp } = JSON.parse(cached);
231
+
if (Date.now() - timestamp < CACHE_DURATION) {
237
+
const response = await fetch('https://ufos-api.microcosm.blue/collections?order=dids-estimate&limit=50');
238
+
const json = await response.json();
240
+
// Group by namespace (first two segments)
241
+
const namespaces = {};
242
+
json.collections.forEach(col => {
243
+
const parts = col.nsid.split('.');
244
+
if (parts.length >= 2) {
245
+
const ns = `${parts[0]}.${parts[1]}`;
246
+
if (!namespaces[ns]) {
254
+
namespaces[ns].dids_total += col.dids_estimate;
255
+
namespaces[ns].records_total += col.creates;
256
+
namespaces[ns].collections.push(col.nsid);
260
+
const data = Object.values(namespaces).sort((a, b) => b.dids_total - a.dids_total).slice(0, 30);
262
+
localStorage.setItem(CACHE_KEY, JSON.stringify({
264
+
timestamp: Date.now()
269
+
console.error('Failed to fetch atmosphere data:', e);
274
+
// Try to fetch app avatar
275
+
async function fetchAppAvatar(namespace) {
276
+
const reversed = namespace.split('.').reverse().join('.');
277
+
const handles = [reversed, `${reversed}.bsky.social`];
279
+
for (const handle of handles) {
281
+
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`);
282
+
if (!didRes.ok) continue;
284
+
const { did } = await didRes.json();
285
+
const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
286
+
if (!profileRes.ok) continue;
288
+
const profile = await profileRes.json();
289
+
if (profile.avatar) return profile.avatar;
297
+
// Render atmosphere
298
+
async function renderAtmosphere() {
299
+
const data = await fetchAtmosphere();
300
+
if (!data.length) return;
302
+
const atmosphere = document.getElementById('atmosphere');
303
+
const maxSize = Math.max(...data.map(d => d.dids_total));
305
+
data.forEach((app, i) => {
306
+
const orb = document.createElement('div');
307
+
orb.className = 'app-orb';
309
+
// Size based on user count (20-80px)
310
+
const size = 20 + (app.dids_total / maxSize) * 60;
312
+
// Position in 3D space
313
+
const angle = (i / data.length) * Math.PI * 2;
314
+
const radius = 250 + (i % 3) * 100;
315
+
const y = (i % 5) * 80 - 160;
316
+
const x = Math.cos(angle) * radius;
317
+
const z = Math.sin(angle) * radius;
319
+
orb.style.width = `${size}px`;
320
+
orb.style.height = `${size}px`;
321
+
orb.style.left = `calc(50% + ${x}px)`;
322
+
orb.style.top = `calc(50% + ${y}px)`;
323
+
orb.style.transform = `translateZ(${z}px) translate(-50%, -50%)`;
324
+
orb.style.background = `radial-gradient(circle, rgba(255,255,255,0.1), rgba(255,255,255,0.02))`;
325
+
orb.style.border = '1px solid rgba(255,255,255,0.1)';
326
+
orb.style.boxShadow = '0 0 20px rgba(255,255,255,0.1)';
329
+
const letter = app.namespace.split('.')[1]?.[0]?.toUpperCase() || app.namespace[0].toUpperCase();
330
+
orb.innerHTML = `<div class="fallback">${letter}</div>`;
333
+
const tooltip = document.createElement('div');
334
+
tooltip.className = 'app-tooltip';
335
+
const users = app.dids_total >= 1000000
336
+
? `${(app.dids_total / 1000000).toFixed(1)}M users`
337
+
: `${(app.dids_total / 1000).toFixed(0)}K users`;
338
+
tooltip.textContent = `${app.namespace} • ${users}`;
339
+
orb.appendChild(tooltip);
341
+
atmosphere.appendChild(orb);
343
+
// Fetch and apply avatar
344
+
fetchAppAvatar(app.namespace).then(avatarUrl => {
346
+
orb.innerHTML = `<img src="${avatarUrl}" alt="${app.namespace}" />`;
347
+
orb.appendChild(tooltip);
353
+
renderAtmosphere();