this repo has no description
1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Tranquil PDS</title>
7 <style>
8 :root {
9 --space-0: 0;
10 --space-1: 0.125rem;
11 --space-2: 0.25rem;
12 --space-3: 0.5rem;
13 --space-4: 0.75rem;
14 --space-5: 1rem;
15 --space-6: 1.5rem;
16 --space-7: 2rem;
17 --space-8: 3rem;
18 --space-9: 4rem;
19 --text-xs: 0.75rem;
20 --text-sm: 0.875rem;
21 --text-base: 1rem;
22 --text-lg: 1.125rem;
23 --text-xl: 1.25rem;
24 --text-2xl: 1.5rem;
25 --text-3xl: 2rem;
26 --text-4xl: 2.5rem;
27 --font-normal: 400;
28 --font-medium: 500;
29 --font-semibold: 600;
30 --font-bold: 700;
31 --leading-tight: 1.25;
32 --leading-normal: 1.5;
33 --leading-relaxed: 1.75;
34 --radius-sm: 3px;
35 --radius-md: 4px;
36 --radius-lg: 6px;
37 --radius-xl: 8px;
38 --width-xs: 360px;
39 --width-sm: 480px;
40 --width-md: 760px;
41 --width-lg: 960px;
42 --width-xl: 1100px;
43 --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
44 --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
45 --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15);
46 --shadow-focus: 0 0 0 2px var(--accent-muted);
47 --transition-fast: 0.1s ease;
48 --transition-normal: 0.15s ease;
49 --transition-slow: 0.25s ease;
50 --font-mono: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
51 --bg-primary: #f9fafa;
52 --bg-secondary: #f1f3f3;
53 --bg-tertiary: #e8ebeb;
54 --bg-hover: #e8ebeb;
55 --bg-card: #ffffff;
56 --bg-input: #ffffff;
57 --bg-input-disabled: #f1f3f3;
58 --text-primary: #1a1d1d;
59 --text-secondary: #5a605f;
60 --text-muted: #8a8f8e;
61 --text-inverse: #ffffff;
62 --border-color: #dce0df;
63 --border-light: #e8ebeb;
64 --border-dark: #c8cecc;
65 --accent: #1a1d1d;
66 --accent-hover: #2e3332;
67 --accent-muted: rgba(26, 29, 29, 0.06);
68 --accent-light: #3a403f;
69 --secondary: #1a1d1d;
70 --secondary-hover: #2e3332;
71 --secondary-muted: rgba(26, 29, 29, 0.06);
72 --success-bg: #dfd;
73 --success-border: #8c8;
74 --success-text: #060;
75 --error-bg: #fee;
76 --error-border: #fcc;
77 --error-text: #c00;
78 --warning-bg: #ffd;
79 --warning-border: #d4a03c;
80 --warning-text: #856404;
81 --border-color-light: var(--border-dark);
82 }
83 @media (prefers-color-scheme: dark) {
84 :root {
85 --bg-primary: #0a0c0c;
86 --bg-secondary: #131616;
87 --bg-tertiary: #1a1d1d;
88 --bg-hover: #1a1d1d;
89 --bg-card: #131616;
90 --bg-input: #1a1d1d;
91 --bg-input-disabled: #131616;
92 --text-primary: #e6e8e8;
93 --text-secondary: #9ca1a0;
94 --text-muted: #686d6c;
95 --text-inverse: #0a0c0c;
96 --border-color: #282c2b;
97 --border-light: #1f2322;
98 --border-dark: #343938;
99 --accent: #e6e8e8;
100 --accent-hover: #ffffff;
101 --accent-muted: rgba(230, 232, 232, 0.1);
102 --accent-light: #ffffff;
103 --secondary: #e6e8e8;
104 --secondary-hover: #ffffff;
105 --secondary-muted: rgba(230, 232, 232, 0.1);
106 --success-bg: #0f1f1a;
107 --success-border: #1a3d2d;
108 --success-text: #7bc6a0;
109 --error-bg: #1f0f0f;
110 --error-border: #3d1a1a;
111 --error-text: #ff8a8a;
112 --warning-bg: #1f1a0f;
113 --warning-border: #3d351a;
114 --warning-text: #c6b87b;
115 }
116 }
117
118 *, *::before, *::after {
119 box-sizing: border-box;
120 }
121 body {
122 margin: 0;
123 font-family:
124 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
125 sans-serif;
126 background: var(--bg-primary);
127 color: var(--text-primary);
128 line-height: var(--leading-normal);
129 -webkit-font-smoothing: antialiased;
130 }
131
132 .pattern-container {
133 position: fixed;
134 top: -32px;
135 left: -32px;
136 right: -32px;
137 bottom: -32px;
138 pointer-events: none;
139 z-index: 1;
140 overflow: hidden;
141 }
142 .pattern {
143 position: absolute;
144 top: 0;
145 left: 0;
146 width: calc(100% + 500px);
147 height: 100%;
148 animation: drift 80s linear infinite;
149 }
150 .dot {
151 position: absolute;
152 width: 10px;
153 height: 10px;
154 background: rgba(0, 0, 0, 0.06);
155 border-radius: 50%;
156 transition: transform 0.04s linear;
157 }
158 @media (prefers-color-scheme: dark) {
159 .dot {
160 background: rgba(255, 255, 255, 0.1);
161 }
162 }
163 .pattern-fade {
164 position: fixed;
165 top: 0;
166 left: 0;
167 right: 0;
168 bottom: 0;
169 background: linear-gradient(
170 135deg,
171 transparent 50%,
172 var(--bg-primary) 75%
173 );
174 pointer-events: none;
175 z-index: 2;
176 }
177 @keyframes drift {
178 0% {
179 transform: translateX(-500px);
180 }
181 100% {
182 transform: translateX(0);
183 }
184 }
185
186 nav {
187 position: fixed;
188 top: 12px;
189 left: 32px;
190 right: 32px;
191 background: var(--accent);
192 padding: 10px 18px;
193 z-index: 100;
194 border-radius: var(--radius-xl);
195 display: flex;
196 justify-content: space-between;
197 align-items: center;
198 }
199 .nav-left {
200 display: flex;
201 align-items: center;
202 gap: var(--space-3);
203 }
204 .nav-logo {
205 height: 28px;
206 width: auto;
207 object-fit: contain;
208 border-radius: var(--radius-sm);
209 }
210 .hostname {
211 font-weight: var(--font-semibold);
212 font-size: var(--text-base);
213 letter-spacing: 0.08em;
214 color: var(--text-inverse);
215 text-transform: uppercase;
216 }
217 .hostname.placeholder {
218 opacity: 0.4;
219 }
220 .user-count {
221 font-size: var(--text-sm);
222 color: var(--text-inverse);
223 opacity: 0.85;
224 padding: 4px 10px;
225 background: rgba(255, 255, 255, 0.15);
226 border-radius: var(--radius-md);
227 white-space: nowrap;
228 }
229 @media (prefers-color-scheme: dark) {
230 .user-count {
231 background: rgba(0, 0, 0, 0.15);
232 }
233 }
234 .nav-meta {
235 font-size: var(--text-sm);
236 color: var(--text-inverse);
237 opacity: 0.6;
238 letter-spacing: 0.05em;
239 }
240
241 .home {
242 position: relative;
243 z-index: 10;
244 max-width: var(--width-xl);
245 margin: 0 auto;
246 padding: 72px 32px 32px;
247 }
248 .hero {
249 padding: var(--space-7) 0 var(--space-8);
250 border-bottom: 1px solid var(--border-color);
251 margin-bottom: var(--space-8);
252 }
253 h1 {
254 font-size: var(--text-4xl);
255 font-weight: var(--font-semibold);
256 line-height: var(--leading-tight);
257 margin-bottom: var(--space-6);
258 letter-spacing: -0.02em;
259 }
260 .cycling-word-container {
261 display: inline-block;
262 width: 3.9em;
263 text-align: left;
264 }
265 .cycling-word {
266 display: inline-block;
267 transition: opacity 0.1s ease, transform 0.1s ease;
268 }
269 .cycling-word.transitioning {
270 opacity: 0;
271 transform: scale(0.95);
272 }
273 .lede {
274 font-size: var(--text-xl);
275 font-weight: var(--font-medium);
276 color: var(--text-primary);
277 line-height: var(--leading-relaxed);
278 margin-bottom: 0;
279 }
280 .actions {
281 display: flex;
282 gap: var(--space-4);
283 margin-top: var(--space-7);
284 }
285 .btn {
286 font-size: var(--text-sm);
287 font-weight: var(--font-medium);
288 text-transform: uppercase;
289 letter-spacing: 0.06em;
290 padding: var(--space-4) var(--space-6);
291 border-radius: var(--radius-lg);
292 text-decoration: none;
293 transition: all var(--transition-normal);
294 border: 1px solid transparent;
295 }
296 .btn.primary {
297 background: var(--secondary);
298 color: var(--text-inverse);
299 border-color: var(--secondary);
300 }
301 .btn.primary:hover {
302 background: var(--secondary-hover);
303 border-color: var(--secondary-hover);
304 }
305 .btn.secondary {
306 background: transparent;
307 color: var(--text-primary);
308 border-color: var(--border-color);
309 }
310 .btn.secondary:hover {
311 background: var(--secondary-muted);
312 border-color: var(--secondary);
313 color: var(--secondary);
314 }
315 blockquote {
316 margin: var(--space-8) 0 0 0;
317 padding: var(--space-6);
318 background: var(--accent-muted);
319 border-left: 3px solid var(--accent);
320 border-radius: 0 var(--radius-xl) var(--radius-xl) 0;
321 }
322 blockquote p {
323 font-size: var(--text-lg);
324 color: var(--text-primary);
325 font-style: italic;
326 margin-bottom: var(--space-3);
327 }
328 blockquote cite {
329 font-size: var(--text-sm);
330 color: var(--text-secondary);
331 font-style: normal;
332 text-transform: uppercase;
333 letter-spacing: 0.05em;
334 }
335 .content h2 {
336 font-size: var(--text-sm);
337 font-weight: var(--font-bold);
338 text-transform: uppercase;
339 letter-spacing: 0.1em;
340 color: var(--accent-light);
341 margin: var(--space-8) 0 var(--space-5);
342 }
343 .content h2:first-child {
344 margin-top: 0;
345 }
346 .content > p {
347 font-size: var(--text-base);
348 color: var(--text-secondary);
349 margin-bottom: var(--space-5);
350 line-height: var(--leading-relaxed);
351 }
352 .features {
353 display: grid;
354 grid-template-columns: repeat(2, 1fr);
355 gap: var(--space-6);
356 margin: var(--space-6) 0 var(--space-8);
357 }
358 .feature {
359 padding: var(--space-5);
360 background: var(--bg-secondary);
361 border-radius: var(--radius-xl);
362 border: 1px solid var(--border-color);
363 }
364 .feature h3 {
365 font-size: var(--text-base);
366 font-weight: var(--font-semibold);
367 color: var(--text-primary);
368 margin-bottom: var(--space-3);
369 }
370 .feature p {
371 font-size: var(--text-sm);
372 color: var(--text-secondary);
373 margin: 0;
374 line-height: var(--leading-relaxed);
375 }
376 @media (max-width: 700px) {
377 .features {
378 grid-template-columns: 1fr;
379 }
380 h1 {
381 font-size: var(--text-3xl);
382 }
383 .actions {
384 flex-direction: column;
385 }
386 .btn {
387 text-align: center;
388 }
389 .user-count, .nav-meta {
390 display: none;
391 }
392 }
393 .site-footer {
394 margin-top: var(--space-9);
395 padding-top: var(--space-7);
396 display: flex;
397 justify-content: space-between;
398 font-size: var(--text-sm);
399 color: var(--text-muted);
400 text-transform: uppercase;
401 letter-spacing: 0.05em;
402 border-top: 1px solid var(--border-color);
403 }
404 .hidden {
405 display: none !important;
406 }
407 </style>
408 </head>
409 <body>
410 <div class="pattern-container">
411 <div class="pattern" id="dotPattern"></div>
412 </div>
413 <div class="pattern-fade"></div>
414
415 <nav>
416 <div class="nav-left">
417 <img src="/logo" alt="Logo" class="nav-logo hidden" id="navLogo">
418 <span class="hostname" id="hostname">loading...</span>
419 <span class="user-count hidden" id="userCount"></span>
420 </div>
421 <span class="nav-meta" id="version"></span>
422 </nav>
423
424 <div class="home">
425 <section class="hero">
426 <h1>
427 A home for your <span class="cycling-word-container"><span
428 class="cycling-word"
429 id="cyclingWord"
430 >Bluesky</span></span> account
431 </h1>
432
433 <p class="lede">
434 Tranquil PDS is a Personal Data Server, the thing that stores your
435 posts, profile, and keys. Bluesky runs one for you, but you can run
436 your own.
437 </p>
438
439 <div class="actions" id="heroActions">
440 <a href="/app/register" class="btn primary" id="heroPrimary"
441 >Join This Server</a>
442 <a
443 href="https://tangled.org/lewis.moe/bspds-sandbox"
444 class="btn secondary"
445 id="heroSecondary"
446 target="_blank"
447 rel="noopener"
448 >Run Your Own</a>
449 </div>
450
451 <blockquote>
452 <p>"Nature does not hurry, yet everything is accomplished."</p>
453 <cite>Lao Tzu</cite>
454 </blockquote>
455 </section>
456
457 <section class="content">
458 <h2>What you get</h2>
459
460 <div class="features">
461 <div class="feature">
462 <h3>Real security</h3>
463 <p>
464 Sign in with passkeys, add two-factor authentication, set up
465 backup codes, and mark devices you trust. Your account stays
466 yours.
467 </p>
468 </div>
469
470 <div class="feature">
471 <h3>Your own identity</h3>
472 <p>
473 Use your own domain as your handle, or get a subdomain on ours.
474 Either way, your identity moves with you if you ever leave.
475 </p>
476 </div>
477
478 <div class="feature">
479 <h3>Stay in the loop</h3>
480 <p>
481 Get important alerts where you actually see them: email, Discord,
482 Telegram, or Signal.
483 </p>
484 </div>
485
486 <div class="feature">
487 <h3>You decide what apps can do</h3>
488 <p>
489 When an app asks for access, you'll see exactly what it wants in
490 plain language. Grant what makes sense, deny what doesn't.
491 </p>
492 </div>
493
494 <div class="feature">
495 <h3>App passwords with guardrails</h3>
496 <p>
497 Create app passwords that can only do specific things: read-only
498 for feed readers, post-only for bots. Full control over what each
499 password can access.
500 </p>
501 </div>
502
503 <div class="feature">
504 <h3>Delegate without sharing passwords</h3>
505 <p>
506 Let team members or tools manage your account with specific
507 permission levels. They authenticate with their own credentials,
508 you see everything they do in an audit log.
509 </p>
510 </div>
511
512 <div class="feature">
513 <h3>Automatic backups</h3>
514 <p>
515 Your repository is backed up daily to object storage. Download any
516 backup or restore with one click. You own your data, even if the
517 worst happens.
518 </p>
519 </div>
520 </div>
521
522 <h2>Everything in one place</h2>
523
524 <p>
525 Manage your profile, security settings, connected apps, and more from
526 a clean dashboard. No command line or 3rd party apps required.
527 </p>
528
529 <h2>Works with everything</h2>
530
531 <p>
532 Use any ATProto app you already like. Tranquil PDS speaks the same
533 language as Bluesky's servers, so all your favorite clients and tools
534 just work.
535 </p>
536
537 <h2>Ready to try it?</h2>
538
539 <p>
540 Join this server, or grab the source and run your own. Either way, you
541 can migrate an existing account over and your followers, posts, and
542 identity come with you.
543 </p>
544
545 <div class="actions" id="footerActions">
546 <a href="/app/register" class="btn primary" id="footerPrimary"
547 >Join This Server</a>
548 <a
549 href="https://tangled.org/lewis.moe/bspds-sandbox"
550 class="btn secondary"
551 target="_blank"
552 rel="noopener"
553 >View Source</a>
554 </div>
555 </section>
556
557 <footer class="site-footer">
558 <span>Made by people who don't take themselves too seriously</span>
559 <span>Open Source: issues & PRs welcome</span>
560 </footer>
561 </div>
562
563 <script>
564 (function checkSession() {
565 try {
566 const stored = localStorage.getItem("tranquil_pds_session");
567 if (stored) {
568 const session = JSON.parse(stored);
569 if (session && session.handle) {
570 const handle = "@" + session.handle;
571 const heroPrimary = document.getElementById(
572 "heroPrimary",
573 );
574 const footerPrimary = document.getElementById(
575 "footerPrimary",
576 );
577 const heroSecondary = document.getElementById(
578 "heroSecondary",
579 );
580 if (heroPrimary) {
581 heroPrimary.href = "/app/dashboard";
582 heroPrimary.textContent = handle;
583 }
584 if (footerPrimary) {
585 footerPrimary.href = "/app/dashboard";
586 footerPrimary.textContent = handle;
587 }
588 if (heroSecondary) {
589 heroSecondary.classList.add("hidden");
590 }
591 }
592 }
593 } catch (e) {}
594 })();
595
596 const heroWords = ["Bluesky", "Tangled", "Leaflet", "ATProto"];
597 const wordSpacing = {
598 "Bluesky": "0.01em",
599 "Tangled": "0.02em",
600 "Leaflet": "0.05em",
601 "ATProto": "0",
602 };
603 let currentWordIndex = 0;
604 const cyclingWord = document.getElementById("cyclingWord");
605
606 function cycleWord() {
607 cyclingWord.classList.add("transitioning");
608 setTimeout(() => {
609 currentWordIndex = (currentWordIndex + 1) % heroWords.length;
610 const word = heroWords[currentWordIndex];
611 cyclingWord.textContent = word;
612 cyclingWord.style.letterSpacing = wordSpacing[word] || "0";
613 cyclingWord.classList.remove("transitioning");
614 const duration = word === "ATProto" ? 4000 : 2000;
615 setTimeout(cycleWord, duration);
616 }, 100);
617 }
618 setTimeout(cycleWord, 2000);
619
620 fetch("/xrpc/com.atproto.server.describeServer")
621 .then((r) => r.json())
622 .then((info) => {
623 if (info.availableUserDomains?.length) {
624 document.getElementById("hostname").textContent =
625 info.availableUserDomains[0];
626 document.getElementById("hostname").classList.remove(
627 "placeholder",
628 );
629 }
630 if (info.version) {
631 document.getElementById("version").textContent =
632 info.version;
633 }
634 })
635 .catch(() => {});
636
637 fetch("/xrpc/com.atproto.sync.listRepos?limit=1000")
638 .then((r) => r.json())
639 .then((data) => {
640 const count = data.repos?.length || 0;
641 const el = document.getElementById("userCount");
642 el.textContent = count + " " +
643 (count === 1 ? "user" : "users");
644 el.classList.remove("hidden");
645 })
646 .catch(() => {});
647
648 fetch("/logo", { method: "HEAD" })
649 .then((r) => {
650 if (r.ok) {
651 document.getElementById("navLogo").classList.remove(
652 "hidden",
653 );
654 }
655 })
656 .catch(() => {});
657
658 const pattern = document.getElementById("dotPattern");
659 const spacing = 32;
660 const cols = Math.ceil((window.innerWidth + 600) / spacing);
661 const rows = Math.ceil((window.innerHeight + 100) / spacing);
662 const dots = [];
663
664 for (let y = 0; y < rows; y++) {
665 for (let x = 0; x < cols; x++) {
666 const dot = document.createElement("div");
667 dot.className = "dot";
668 dot.style.left = (x * spacing) + "px";
669 dot.style.top = (y * spacing) + "px";
670 pattern.appendChild(dot);
671 dots.push({ el: dot, x: x * spacing, y: y * spacing });
672 }
673 }
674
675 let mouseX = -1000, mouseY = -1000;
676 document.addEventListener("mousemove", (e) => {
677 mouseX = e.clientX;
678 mouseY = e.clientY;
679 });
680
681 function updateDots() {
682 const patternRect = pattern.getBoundingClientRect();
683 dots.forEach((dot) => {
684 const dotX = patternRect.left + dot.x + 5;
685 const dotY = patternRect.top + dot.y + 5;
686 const dist = Math.hypot(mouseX - dotX, mouseY - dotY);
687 const maxDist = 120;
688 const scale = Math.min(1, Math.max(0.1, dist / maxDist));
689 dot.el.style.transform = "scale(" + scale + ")";
690 });
691 requestAnimationFrame(updateDots);
692 }
693 updateDots();
694 </script>
695 </body>
696</html>