+5
-2
frontend/index.html
+5
-2
frontend/index.html
···
4
4
<meta charset="UTF-8" />
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
<title>Tranquil PDS</title>
7
+
<link rel="preconnect" href="https://fonts.googleapis.com">
8
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
7
10
<style>
8
-
html { background: #fafafa; }
9
-
@media (prefers-color-scheme: dark) { html { background: #1a1a1a; } }
11
+
html { background: #ffffff; }
12
+
@media (prefers-color-scheme: dark) { html { background: #0a0a0a; } }
10
13
</style>
11
14
</head>
12
15
<body>
+650
frontend/mockups/01-article-style.html
+650
frontend/mockups/01-article-style.html
···
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</title>
7
+
<link rel="preconnect" href="https://fonts.googleapis.com">
8
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
10
+
<style>
11
+
* { margin: 0; padding: 0; box-sizing: border-box; }
12
+
13
+
body {
14
+
font-family: 'JetBrains Mono', monospace;
15
+
line-height: 1.7;
16
+
background: #2c00ff;
17
+
color: #ffffff;
18
+
min-height: 100vh;
19
+
position: relative;
20
+
}
21
+
22
+
.pattern-container {
23
+
position: fixed;
24
+
top: -32px;
25
+
left: -32px;
26
+
right: -32px;
27
+
bottom: -32px;
28
+
pointer-events: none;
29
+
z-index: 1;
30
+
overflow: hidden;
31
+
}
32
+
33
+
.pattern {
34
+
position: absolute;
35
+
top: 0;
36
+
left: 0;
37
+
width: calc(100% + 500px);
38
+
height: 100%;
39
+
animation: drift 80s linear infinite;
40
+
}
41
+
42
+
.dot {
43
+
position: absolute;
44
+
width: 10px;
45
+
height: 10px;
46
+
background: rgba(255,255,255,0.15);
47
+
border-radius: 50%;
48
+
transition: transform 0.04s linear;
49
+
}
50
+
51
+
.pattern-fade {
52
+
position: fixed;
53
+
top: 0;
54
+
left: 0;
55
+
right: 0;
56
+
bottom: 0;
57
+
background: linear-gradient(135deg, transparent 50%, #2c00ff 75%);
58
+
pointer-events: none;
59
+
z-index: 2;
60
+
}
61
+
62
+
@keyframes drift {
63
+
0% { transform: translateX(-500px); }
64
+
100% { transform: translateX(0); }
65
+
}
66
+
67
+
nav { z-index: 100; }
68
+
main { position: relative; z-index: 10; }
69
+
.site-footer { position: relative; z-index: 10; }
70
+
71
+
a { color: #ff2400; text-decoration: none; }
72
+
a:hover { color: #ff5533; }
73
+
74
+
nav {
75
+
position: fixed;
76
+
top: 12px;
77
+
left: 32px;
78
+
right: 32px;
79
+
background: #1a00a3;
80
+
padding: 10px 18px;
81
+
z-index: 100;
82
+
border-radius: 8px;
83
+
border: 1px solid rgba(255, 255, 255, 0.1);
84
+
display: flex;
85
+
justify-content: space-between;
86
+
align-items: center;
87
+
}
88
+
89
+
nav .brand {
90
+
font-weight: 600;
91
+
font-size: 1rem;
92
+
letter-spacing: 0.08em;
93
+
color: #ffffff;
94
+
text-transform: uppercase;
95
+
}
96
+
97
+
nav .nav-meta {
98
+
font-size: 0.85rem;
99
+
color: rgba(255, 255, 255, 0.7);
100
+
letter-spacing: 0.05em;
101
+
}
102
+
103
+
main {
104
+
max-width: 1000px;
105
+
margin: 0 auto;
106
+
padding: 80px 32px 80px;
107
+
}
108
+
109
+
.meta {
110
+
display: flex;
111
+
align-items: center;
112
+
gap: 16px;
113
+
margin-bottom: 32px;
114
+
font-size: 0.8rem;
115
+
font-weight: 500;
116
+
text-transform: uppercase;
117
+
letter-spacing: 0.1em;
118
+
}
119
+
120
+
.category {
121
+
color: #ff2400;
122
+
background: rgba(255, 255, 255, 0.95);
123
+
padding: 4px 10px;
124
+
border-radius: 4px;
125
+
}
126
+
127
+
.read-time {
128
+
color: rgba(255, 255, 255, 0.8);
129
+
}
130
+
131
+
h1 {
132
+
font-size: 2.75rem;
133
+
font-weight: 600;
134
+
line-height: 1.15;
135
+
color: #ffffff;
136
+
margin-bottom: 32px;
137
+
letter-spacing: -0.02em;
138
+
}
139
+
140
+
.byline {
141
+
display: flex;
142
+
align-items: center;
143
+
gap: 16px;
144
+
padding: 24px 0;
145
+
border-top: 1px solid rgba(255, 255, 255, 0.12);
146
+
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
147
+
margin-bottom: 48px;
148
+
}
149
+
150
+
.avatar {
151
+
width: 44px;
152
+
height: 44px;
153
+
border-radius: 50%;
154
+
background: linear-gradient(135deg, #ff2400 0%, #ff6b4a 100%);
155
+
}
156
+
157
+
.author-info {
158
+
flex: 1;
159
+
}
160
+
161
+
.author {
162
+
display: block;
163
+
font-weight: 500;
164
+
color: #ffffff;
165
+
font-size: 1rem;
166
+
}
167
+
168
+
.author-handle {
169
+
display: block;
170
+
font-size: 0.85rem;
171
+
color: rgba(255, 255, 255, 0.8);
172
+
margin-top: 2px;
173
+
}
174
+
175
+
.verification {
176
+
font-size: 0.75rem;
177
+
font-weight: 500;
178
+
color: rgba(255, 255, 255, 0.85);
179
+
text-transform: uppercase;
180
+
letter-spacing: 0.08em;
181
+
}
182
+
183
+
.placeholder-image {
184
+
aspect-ratio: 16 / 9;
185
+
background: rgba(255, 255, 255, 0.08);
186
+
border-radius: 8px;
187
+
display: flex;
188
+
align-items: center;
189
+
justify-content: center;
190
+
font-size: 0.9rem;
191
+
color: rgba(255, 255, 255, 0.6);
192
+
text-transform: uppercase;
193
+
letter-spacing: 0.1em;
194
+
border: 1px solid rgba(255, 255, 255, 0.15);
195
+
}
196
+
197
+
figcaption {
198
+
margin-top: 12px;
199
+
font-size: 0.85rem;
200
+
color: rgba(255, 255, 255, 0.75);
201
+
text-align: center;
202
+
}
203
+
204
+
.carousel {
205
+
margin: 64px 0 0;
206
+
}
207
+
208
+
.carousel-header {
209
+
display: flex;
210
+
justify-content: space-between;
211
+
align-items: center;
212
+
margin-bottom: 20px;
213
+
}
214
+
215
+
.carousel-title {
216
+
font-size: 0.85rem;
217
+
font-weight: 600;
218
+
text-transform: uppercase;
219
+
letter-spacing: 0.1em;
220
+
color: #ffffff;
221
+
}
222
+
223
+
.carousel-nav {
224
+
display: flex;
225
+
gap: 8px;
226
+
}
227
+
228
+
.carousel-nav button {
229
+
font-family: 'JetBrains Mono', monospace;
230
+
width: 36px;
231
+
height: 36px;
232
+
background: rgba(255, 255, 255, 0.08);
233
+
border: 1px solid rgba(255, 255, 255, 0.15);
234
+
border-radius: 6px;
235
+
color: #ffffff;
236
+
cursor: pointer;
237
+
transition: all 0.15s ease;
238
+
font-size: 1rem;
239
+
}
240
+
241
+
.carousel-nav button:hover {
242
+
background: rgba(255, 36, 0, 0.15);
243
+
border-color: #ff2400;
244
+
}
245
+
246
+
.carousel-track {
247
+
display: flex;
248
+
gap: 16px;
249
+
overflow-x: auto;
250
+
scroll-snap-type: x mandatory;
251
+
scrollbar-width: none;
252
+
-ms-overflow-style: none;
253
+
padding-bottom: 8px;
254
+
-webkit-overflow-scrolling: touch;
255
+
user-select: none;
256
+
}
257
+
258
+
.carousel-track::-webkit-scrollbar {
259
+
display: none;
260
+
}
261
+
262
+
.carousel-slide {
263
+
flex: 0 0 70%;
264
+
scroll-snap-align: start;
265
+
}
266
+
267
+
.carousel-slide .placeholder-image {
268
+
aspect-ratio: 16 / 10;
269
+
}
270
+
271
+
.carousel-label {
272
+
margin-top: 12px;
273
+
font-size: 0.8rem;
274
+
font-weight: 500;
275
+
color: rgba(255, 255, 255, 0.85);
276
+
text-transform: uppercase;
277
+
letter-spacing: 0.08em;
278
+
}
279
+
280
+
.content {
281
+
font-size: 1.05rem;
282
+
font-weight: 400;
283
+
}
284
+
285
+
.content p {
286
+
margin-bottom: 28px;
287
+
}
288
+
289
+
.lede {
290
+
font-size: 1.3rem;
291
+
font-weight: 500;
292
+
color: #ffffff;
293
+
line-height: 1.5;
294
+
}
295
+
296
+
.content h2 {
297
+
font-size: 0.9rem;
298
+
font-weight: 600;
299
+
text-transform: uppercase;
300
+
letter-spacing: 0.1em;
301
+
color: #ffffff;
302
+
margin: 56px 0 24px;
303
+
}
304
+
305
+
blockquote {
306
+
margin: 40px 0;
307
+
padding: 32px;
308
+
background: rgba(255, 255, 255, 0.05);
309
+
border-left: 2px solid #ff2400;
310
+
border-radius: 0 8px 8px 0;
311
+
}
312
+
313
+
blockquote p {
314
+
font-size: 1.15rem;
315
+
color: #ffffff;
316
+
font-style: italic;
317
+
margin-bottom: 16px !important;
318
+
}
319
+
320
+
blockquote cite {
321
+
font-size: 0.8rem;
322
+
color: rgba(255, 255, 255, 0.8);
323
+
font-style: normal;
324
+
text-transform: uppercase;
325
+
letter-spacing: 0.05em;
326
+
}
327
+
328
+
.context-panel {
329
+
margin: 40px 0;
330
+
padding: 24px;
331
+
background: rgba(255, 255, 255, 0.05);
332
+
border-radius: 8px;
333
+
border: 1px solid rgba(255, 255, 255, 0.1);
334
+
}
335
+
336
+
.context-panel h3 {
337
+
font-size: 0.8rem;
338
+
font-weight: 600;
339
+
text-transform: uppercase;
340
+
letter-spacing: 0.1em;
341
+
color: #ffffff;
342
+
margin-bottom: 16px;
343
+
}
344
+
345
+
.context-panel ul {
346
+
list-style: none;
347
+
}
348
+
349
+
.context-panel li {
350
+
padding: 10px 0;
351
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
352
+
}
353
+
354
+
.context-panel li:last-child {
355
+
border-bottom: none;
356
+
}
357
+
358
+
.context-panel a {
359
+
font-size: 0.95rem;
360
+
font-weight: 500;
361
+
color: #ff2400;
362
+
text-decoration: none;
363
+
transition: color 0.15s ease;
364
+
}
365
+
366
+
.context-panel a:hover {
367
+
color: #ff5533;
368
+
}
369
+
370
+
.article-footer {
371
+
margin-top: 64px;
372
+
padding-top: 32px;
373
+
border-top: 1px solid rgba(255, 255, 255, 0.12);
374
+
}
375
+
376
+
.actions {
377
+
display: flex;
378
+
gap: 12px;
379
+
margin-bottom: 24px;
380
+
}
381
+
382
+
.actions button {
383
+
font-family: 'JetBrains Mono', monospace;
384
+
font-size: 0.85rem;
385
+
font-weight: 500;
386
+
text-transform: uppercase;
387
+
letter-spacing: 0.06em;
388
+
padding: 14px 24px;
389
+
background: rgba(255, 255, 255, 0.06);
390
+
border: 1px solid rgba(255, 255, 255, 0.12);
391
+
border-radius: 6px;
392
+
color: #ffffff;
393
+
cursor: pointer;
394
+
transition: all 0.15s ease;
395
+
}
396
+
397
+
.actions button:hover {
398
+
background: rgba(255, 36, 0, 0.15);
399
+
border-color: #ff2400;
400
+
color: #ffffff;
401
+
}
402
+
403
+
.attestation-info {
404
+
display: flex;
405
+
flex-wrap: wrap;
406
+
gap: 24px;
407
+
font-size: 0.8rem;
408
+
color: rgba(255, 255, 255, 0.7);
409
+
text-transform: uppercase;
410
+
letter-spacing: 0.05em;
411
+
}
412
+
413
+
.site-footer {
414
+
max-width: 1000px;
415
+
margin: 0 auto;
416
+
padding: 48px 32px;
417
+
display: flex;
418
+
justify-content: space-between;
419
+
font-size: 0.8rem;
420
+
color: rgba(255, 255, 255, 0.65);
421
+
text-transform: uppercase;
422
+
letter-spacing: 0.05em;
423
+
border-top: 1px solid rgba(255, 255, 255, 0.12);
424
+
}
425
+
426
+
::selection {
427
+
background: rgba(255, 36, 0, 0.4);
428
+
}
429
+
</style>
430
+
</head>
431
+
<body>
432
+
433
+
<div class="pattern-container">
434
+
<div class="pattern"></div>
435
+
</div>
436
+
<div class="pattern-fade"></div>
437
+
438
+
<nav>
439
+
<span class="brand">Tranquil</span>
440
+
<span class="nav-meta">0.1.0</span>
441
+
</nav>
442
+
443
+
<main>
444
+
<article>
445
+
<div class="meta">
446
+
<span class="category">Landing page</span>
447
+
<span class="read-time">1 min read</span>
448
+
</div>
449
+
450
+
<h1>Lorem Ipsum Dolor Sit Amet Consectetur</h1>
451
+
452
+
<div class="byline">
453
+
<div class="avatar"></div>
454
+
<div class="author-info">
455
+
<span class="author">Mysterious benefactor</span>
456
+
<span class="author-handle">@lewis.moe</span>
457
+
</div>
458
+
<div class="verification">47 attestations</div>
459
+
</div>
460
+
461
+
<div class="content">
462
+
<blockquote>
463
+
<p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."</p>
464
+
<cite>Cicero, De Finibus Bonorum et Malorum</cite>
465
+
</blockquote>
466
+
467
+
<p class="lede">Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit.</p>
468
+
469
+
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.</p>
470
+
471
+
<p>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
472
+
473
+
<h2>Neque Porro Quisquam</h2>
474
+
475
+
<p>Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.</p>
476
+
477
+
<p>Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur.</p>
478
+
479
+
<h2>Quis Autem Vel Eum</h2>
480
+
481
+
<p>Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur.</p>
482
+
483
+
<p>At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.</p>
484
+
485
+
<p>Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.</p>
486
+
487
+
<p>Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.</p>
488
+
489
+
<p>Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.</p>
490
+
491
+
<div class="carousel">
492
+
<div class="carousel-header">
493
+
<span class="carousel-title">Interface</span>
494
+
<div class="carousel-nav">
495
+
<button class="carousel-prev">←</button>
496
+
<button class="carousel-next">→</button>
497
+
</div>
498
+
</div>
499
+
<div class="carousel-track">
500
+
<div class="carousel-slide">
501
+
<div class="placeholder-image">Dashboard goes here</div>
502
+
<div class="carousel-label">Dashboard</div>
503
+
</div>
504
+
<div class="carousel-slide">
505
+
<div class="placeholder-image">Profile Settings go here</div>
506
+
<div class="carousel-label">Profile Settings</div>
507
+
</div>
508
+
<div class="carousel-slide">
509
+
<div class="placeholder-image">Account Security goes here</div>
510
+
<div class="carousel-label">Account Security</div>
511
+
</div>
512
+
<div class="carousel-slide">
513
+
<div class="placeholder-image">Repository Browser goes here</div>
514
+
<div class="carousel-label">Repository Browser</div>
515
+
</div>
516
+
<div class="carousel-slide">
517
+
<div class="placeholder-image">OAuth Applications go here</div>
518
+
<div class="carousel-label">OAuth Applications</div>
519
+
</div>
520
+
<div class="carousel-slide">
521
+
<div class="placeholder-image">Invite Codes go here</div>
522
+
<div class="carousel-label">Invite Codes</div>
523
+
</div>
524
+
</div>
525
+
</div>
526
+
</div>
527
+
528
+
<footer class="article-footer">
529
+
<div class="actions">
530
+
<button>Propagate</button>
531
+
<button>Annotate</button>
532
+
<button>Verify Source</button>
533
+
</div>
534
+
535
+
<div class="attestation-info">
536
+
<span>hash: 7f3a9c...</span>
537
+
<span>signed: 2847.12.03</span>
538
+
<span>nodes: 12,847</span>
539
+
</div>
540
+
</footer>
541
+
</article>
542
+
</main>
543
+
544
+
<footer class="site-footer">
545
+
<div>Mesh Commons License</div>
546
+
<div>node: local-7f3a</div>
547
+
</footer>
548
+
549
+
<script>
550
+
const pattern = document.querySelector('.pattern');
551
+
const spacing = 32;
552
+
const cols = Math.ceil((window.innerWidth + 600) / spacing);
553
+
const rows = Math.ceil((window.innerHeight + 100) / spacing);
554
+
const dots = [];
555
+
556
+
for (let y = 0; y < rows; y++) {
557
+
for (let x = 0; x < cols; x++) {
558
+
const dot = document.createElement('div');
559
+
dot.className = 'dot';
560
+
dot.style.left = (x * spacing) + 'px';
561
+
dot.style.top = (y * spacing) + 'px';
562
+
pattern.appendChild(dot);
563
+
dots.push({ el: dot, x: x * spacing, y: y * spacing });
564
+
}
565
+
}
566
+
567
+
let mouseX = -1000, mouseY = -1000;
568
+
document.addEventListener('mousemove', e => {
569
+
mouseX = e.clientX;
570
+
mouseY = e.clientY;
571
+
});
572
+
573
+
function updateDots() {
574
+
const patternRect = pattern.getBoundingClientRect();
575
+
dots.forEach(dot => {
576
+
const dotX = patternRect.left + dot.x + 5;
577
+
const dotY = patternRect.top + dot.y + 5;
578
+
const dist = Math.hypot(mouseX - dotX, mouseY - dotY);
579
+
const maxDist = 120;
580
+
const scale = Math.min(1, Math.max(0.1, dist / maxDist));
581
+
dot.el.style.transform = `scale(${scale})`;
582
+
});
583
+
requestAnimationFrame(updateDots);
584
+
}
585
+
updateDots();
586
+
587
+
const track = document.querySelector('.carousel-track');
588
+
const prevBtn = document.querySelector('.carousel-prev');
589
+
const nextBtn = document.querySelector('.carousel-next');
590
+
const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16;
591
+
592
+
prevBtn?.addEventListener('click', () => {
593
+
track.scrollBy({ left: -slideWidth, behavior: 'smooth' });
594
+
});
595
+
nextBtn?.addEventListener('click', () => {
596
+
track.scrollBy({ left: slideWidth, behavior: 'smooth' });
597
+
});
598
+
599
+
let isDragging = false;
600
+
let startX, scrollLeft;
601
+
602
+
track?.addEventListener('mousedown', e => {
603
+
isDragging = true;
604
+
track.style.cursor = 'grabbing';
605
+
track.style.scrollSnapType = 'none';
606
+
startX = e.pageX - track.offsetLeft;
607
+
scrollLeft = track.scrollLeft;
608
+
});
609
+
610
+
track?.addEventListener('mouseleave', () => {
611
+
isDragging = false;
612
+
track.style.cursor = 'grab';
613
+
track.style.scrollSnapType = 'x mandatory';
614
+
});
615
+
616
+
function snapTo(target, duration = 120) {
617
+
const start = track.scrollLeft;
618
+
const distance = target - start;
619
+
const startTime = performance.now();
620
+
function step(currentTime) {
621
+
const elapsed = currentTime - startTime;
622
+
const progress = Math.min(elapsed / duration, 1);
623
+
const ease = 1 - Math.pow(1 - progress, 3);
624
+
track.scrollLeft = start + distance * ease;
625
+
if (progress < 1) requestAnimationFrame(step);
626
+
else track.style.scrollSnapType = 'x mandatory';
627
+
}
628
+
requestAnimationFrame(step);
629
+
}
630
+
631
+
track?.addEventListener('mouseup', () => {
632
+
isDragging = false;
633
+
track.style.cursor = 'grab';
634
+
const slideW = track.querySelector('.carousel-slide').offsetWidth + 16;
635
+
const targetIndex = Math.round(track.scrollLeft / slideW);
636
+
snapTo(targetIndex * slideW);
637
+
});
638
+
639
+
track?.addEventListener('mousemove', e => {
640
+
if (!isDragging) return;
641
+
e.preventDefault();
642
+
const x = e.pageX - track.offsetLeft;
643
+
const walk = (x - startX) * 1.5;
644
+
track.scrollLeft = scrollLeft - walk;
645
+
});
646
+
647
+
if (track) track.style.cursor = 'grab';
648
+
</script>
649
+
</body>
650
+
</html>
+679
frontend/mockups/02-normal-colors.html
+679
frontend/mockups/02-normal-colors.html
···
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</title>
7
+
<link rel="preconnect" href="https://fonts.googleapis.com">
8
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
10
+
<style>
11
+
* { margin: 0; padding: 0; box-sizing: border-box; }
12
+
13
+
:root {
14
+
--primary: #2c00ff;
15
+
--primary-dark: #1a00a3;
16
+
--primary-light: #4d33ff;
17
+
--primary-muted: #e8e5ff;
18
+
--secondary: #ff2400;
19
+
--secondary-hover: #ff5533;
20
+
--bg: #ffffff;
21
+
--bg-subtle: #f8f8fa;
22
+
--text: #1a1a1a;
23
+
--text-muted: #666666;
24
+
--text-light: #999999;
25
+
--border: #e5e5e5;
26
+
--border-light: #f0f0f0;
27
+
}
28
+
29
+
body {
30
+
font-family: 'JetBrains Mono', monospace;
31
+
line-height: 1.7;
32
+
background: var(--bg);
33
+
color: var(--text);
34
+
min-height: 100vh;
35
+
position: relative;
36
+
}
37
+
38
+
.pattern-container {
39
+
position: fixed;
40
+
top: -32px;
41
+
left: -32px;
42
+
right: -32px;
43
+
bottom: -32px;
44
+
pointer-events: none;
45
+
z-index: 1;
46
+
overflow: hidden;
47
+
}
48
+
49
+
.pattern {
50
+
position: absolute;
51
+
top: 0;
52
+
left: 0;
53
+
width: calc(100% + 500px);
54
+
height: 100%;
55
+
animation: drift 80s linear infinite;
56
+
}
57
+
58
+
.dot {
59
+
position: absolute;
60
+
width: 10px;
61
+
height: 10px;
62
+
background: rgba(0, 0, 0, 0.06);
63
+
border-radius: 50%;
64
+
transition: transform 0.04s linear;
65
+
}
66
+
67
+
.pattern-fade {
68
+
position: fixed;
69
+
top: 0;
70
+
left: 0;
71
+
right: 0;
72
+
bottom: 0;
73
+
background: linear-gradient(135deg, transparent 50%, var(--bg) 75%);
74
+
pointer-events: none;
75
+
z-index: 2;
76
+
}
77
+
78
+
@keyframes drift {
79
+
0% { transform: translateX(-500px); }
80
+
100% { transform: translateX(0); }
81
+
}
82
+
83
+
nav { z-index: 100; }
84
+
main { position: relative; z-index: 10; }
85
+
.site-footer { position: relative; z-index: 10; }
86
+
87
+
a { color: var(--secondary); text-decoration: none; }
88
+
a:hover { color: var(--secondary-hover); }
89
+
90
+
nav {
91
+
position: fixed;
92
+
top: 12px;
93
+
left: 32px;
94
+
right: 32px;
95
+
background: var(--primary);
96
+
padding: 10px 18px;
97
+
z-index: 100;
98
+
border-radius: 8px;
99
+
border: 1px solid rgba(0, 0, 0, 0.1);
100
+
display: flex;
101
+
justify-content: space-between;
102
+
align-items: center;
103
+
}
104
+
105
+
nav .brand {
106
+
font-weight: 600;
107
+
font-size: 1rem;
108
+
letter-spacing: 0.08em;
109
+
color: #ffffff;
110
+
text-transform: uppercase;
111
+
}
112
+
113
+
nav .nav-meta {
114
+
font-size: 0.85rem;
115
+
color: rgba(255, 255, 255, 0.7);
116
+
letter-spacing: 0.05em;
117
+
}
118
+
119
+
main {
120
+
max-width: 1000px;
121
+
margin: 0 auto;
122
+
padding: 100px 32px 80px;
123
+
}
124
+
125
+
.meta {
126
+
display: flex;
127
+
align-items: center;
128
+
gap: 16px;
129
+
margin-bottom: 32px;
130
+
font-size: 0.8rem;
131
+
font-weight: 500;
132
+
text-transform: uppercase;
133
+
letter-spacing: 0.1em;
134
+
}
135
+
136
+
.category {
137
+
color: #ffffff;
138
+
background: var(--primary);
139
+
padding: 4px 10px;
140
+
border-radius: 4px;
141
+
}
142
+
143
+
.read-time {
144
+
color: var(--text-muted);
145
+
}
146
+
147
+
h1 {
148
+
font-size: 2.75rem;
149
+
font-weight: 600;
150
+
line-height: 1.15;
151
+
color: var(--text);
152
+
margin-bottom: 32px;
153
+
letter-spacing: -0.02em;
154
+
}
155
+
156
+
.byline {
157
+
display: flex;
158
+
align-items: center;
159
+
gap: 16px;
160
+
padding: 24px 0;
161
+
border-top: 1px solid var(--border);
162
+
border-bottom: 1px solid var(--border);
163
+
margin-bottom: 48px;
164
+
}
165
+
166
+
.avatar {
167
+
width: 44px;
168
+
height: 44px;
169
+
border-radius: 50%;
170
+
background: linear-gradient(135deg, var(--secondary) 0%, #ff6b4a 100%);
171
+
}
172
+
173
+
.author-info {
174
+
flex: 1;
175
+
}
176
+
177
+
.author {
178
+
display: block;
179
+
font-weight: 500;
180
+
color: var(--text);
181
+
font-size: 1rem;
182
+
}
183
+
184
+
.author-handle {
185
+
display: block;
186
+
font-size: 0.85rem;
187
+
color: var(--text-muted);
188
+
margin-top: 2px;
189
+
}
190
+
191
+
.verification {
192
+
font-size: 0.75rem;
193
+
font-weight: 500;
194
+
color: var(--secondary);
195
+
text-transform: uppercase;
196
+
letter-spacing: 0.08em;
197
+
}
198
+
199
+
.placeholder-image {
200
+
aspect-ratio: 16 / 9;
201
+
background: var(--bg-subtle);
202
+
border-radius: 8px;
203
+
display: flex;
204
+
align-items: center;
205
+
justify-content: center;
206
+
font-size: 0.9rem;
207
+
color: var(--text-light);
208
+
text-transform: uppercase;
209
+
letter-spacing: 0.1em;
210
+
border: 1px solid var(--border);
211
+
}
212
+
213
+
figcaption {
214
+
margin-top: 12px;
215
+
font-size: 0.85rem;
216
+
color: var(--text-muted);
217
+
text-align: center;
218
+
}
219
+
220
+
.carousel {
221
+
margin: 64px 0 0;
222
+
}
223
+
224
+
.carousel-header {
225
+
display: flex;
226
+
justify-content: space-between;
227
+
align-items: center;
228
+
margin-bottom: 20px;
229
+
}
230
+
231
+
.carousel-title {
232
+
font-size: 0.85rem;
233
+
font-weight: 600;
234
+
text-transform: uppercase;
235
+
letter-spacing: 0.1em;
236
+
color: var(--text);
237
+
}
238
+
239
+
.carousel-nav {
240
+
display: flex;
241
+
gap: 8px;
242
+
}
243
+
244
+
.carousel-nav button {
245
+
font-family: 'JetBrains Mono', monospace;
246
+
width: 36px;
247
+
height: 36px;
248
+
background: var(--bg);
249
+
border: 1px solid var(--border);
250
+
border-radius: 6px;
251
+
color: var(--text);
252
+
cursor: pointer;
253
+
transition: all 0.15s ease;
254
+
font-size: 1rem;
255
+
}
256
+
257
+
.carousel-nav button:hover {
258
+
background: rgba(255, 36, 0, 0.08);
259
+
border-color: var(--secondary);
260
+
color: var(--secondary);
261
+
}
262
+
263
+
.carousel-track {
264
+
display: flex;
265
+
gap: 16px;
266
+
overflow-x: auto;
267
+
scroll-snap-type: x mandatory;
268
+
scrollbar-width: none;
269
+
-ms-overflow-style: none;
270
+
padding-bottom: 8px;
271
+
-webkit-overflow-scrolling: touch;
272
+
user-select: none;
273
+
}
274
+
275
+
.carousel-track::-webkit-scrollbar {
276
+
display: none;
277
+
}
278
+
279
+
.carousel-slide {
280
+
flex: 0 0 70%;
281
+
scroll-snap-align: start;
282
+
}
283
+
284
+
.carousel-slide .placeholder-image {
285
+
aspect-ratio: 16 / 10;
286
+
}
287
+
288
+
.carousel-label {
289
+
margin-top: 12px;
290
+
font-size: 0.8rem;
291
+
font-weight: 500;
292
+
color: var(--text-muted);
293
+
text-transform: uppercase;
294
+
letter-spacing: 0.08em;
295
+
}
296
+
297
+
.content {
298
+
font-size: 1.05rem;
299
+
font-weight: 400;
300
+
}
301
+
302
+
.content p {
303
+
margin-bottom: 28px;
304
+
}
305
+
306
+
.lede {
307
+
font-size: 1.3rem;
308
+
font-weight: 500;
309
+
color: var(--text);
310
+
line-height: 1.5;
311
+
}
312
+
313
+
.content h2 {
314
+
font-size: 0.9rem;
315
+
font-weight: 600;
316
+
text-transform: uppercase;
317
+
letter-spacing: 0.1em;
318
+
color: var(--primary-dark);
319
+
margin: 56px 0 24px;
320
+
}
321
+
322
+
blockquote {
323
+
margin: 40px 0;
324
+
padding: 32px;
325
+
background: var(--primary-muted);
326
+
border-left: 3px solid var(--primary);
327
+
border-radius: 0 8px 8px 0;
328
+
}
329
+
330
+
blockquote p {
331
+
font-size: 1.15rem;
332
+
color: var(--primary-dark);
333
+
font-style: italic;
334
+
margin-bottom: 16px !important;
335
+
}
336
+
337
+
blockquote cite {
338
+
font-size: 0.8rem;
339
+
color: var(--text-muted);
340
+
font-style: normal;
341
+
text-transform: uppercase;
342
+
letter-spacing: 0.05em;
343
+
}
344
+
345
+
.context-panel {
346
+
margin: 40px 0;
347
+
padding: 24px;
348
+
background: var(--bg-subtle);
349
+
border-radius: 8px;
350
+
border: 1px solid var(--border);
351
+
}
352
+
353
+
.context-panel h3 {
354
+
font-size: 0.8rem;
355
+
font-weight: 600;
356
+
text-transform: uppercase;
357
+
letter-spacing: 0.1em;
358
+
color: var(--text);
359
+
margin-bottom: 16px;
360
+
}
361
+
362
+
.context-panel ul {
363
+
list-style: none;
364
+
}
365
+
366
+
.context-panel li {
367
+
padding: 10px 0;
368
+
border-bottom: 1px solid var(--border-light);
369
+
}
370
+
371
+
.context-panel li:last-child {
372
+
border-bottom: none;
373
+
}
374
+
375
+
.context-panel a {
376
+
font-size: 0.95rem;
377
+
font-weight: 500;
378
+
color: var(--secondary);
379
+
text-decoration: none;
380
+
transition: color 0.15s ease;
381
+
}
382
+
383
+
.context-panel a:hover {
384
+
color: var(--secondary-hover);
385
+
}
386
+
387
+
.article-footer {
388
+
margin-top: 64px;
389
+
padding-top: 32px;
390
+
border-top: 1px solid var(--border);
391
+
}
392
+
393
+
.actions {
394
+
display: flex;
395
+
gap: 12px;
396
+
margin-bottom: 24px;
397
+
}
398
+
399
+
.actions button {
400
+
font-family: 'JetBrains Mono', monospace;
401
+
font-size: 0.85rem;
402
+
font-weight: 500;
403
+
text-transform: uppercase;
404
+
letter-spacing: 0.06em;
405
+
padding: 14px 24px;
406
+
background: var(--bg);
407
+
border: 1px solid var(--border);
408
+
border-radius: 6px;
409
+
color: var(--text);
410
+
cursor: pointer;
411
+
transition: all 0.15s ease;
412
+
}
413
+
414
+
.actions button:hover {
415
+
background: rgba(255, 36, 0, 0.08);
416
+
border-color: var(--secondary);
417
+
color: var(--secondary);
418
+
}
419
+
420
+
.actions button:first-child {
421
+
background: var(--secondary);
422
+
border-color: var(--secondary);
423
+
color: #ffffff;
424
+
}
425
+
426
+
.actions button:first-child:hover {
427
+
background: #cc1d00;
428
+
border-color: #cc1d00;
429
+
}
430
+
431
+
.attestation-info {
432
+
display: flex;
433
+
flex-wrap: wrap;
434
+
gap: 24px;
435
+
font-size: 0.8rem;
436
+
color: var(--text-light);
437
+
text-transform: uppercase;
438
+
letter-spacing: 0.05em;
439
+
}
440
+
441
+
.site-footer {
442
+
max-width: 1000px;
443
+
margin: 0 auto;
444
+
padding: 48px 32px;
445
+
display: flex;
446
+
justify-content: space-between;
447
+
font-size: 0.8rem;
448
+
color: var(--text-light);
449
+
text-transform: uppercase;
450
+
letter-spacing: 0.05em;
451
+
border-top: 1px solid var(--border);
452
+
}
453
+
454
+
::selection {
455
+
background: rgba(255, 36, 0, 0.2);
456
+
color: var(--text);
457
+
}
458
+
</style>
459
+
</head>
460
+
<body>
461
+
462
+
<div class="pattern-container">
463
+
<div class="pattern"></div>
464
+
</div>
465
+
<div class="pattern-fade"></div>
466
+
467
+
<nav>
468
+
<span class="brand">Tranquil PDS</span>
469
+
<span class="nav-meta">0.1.0</span>
470
+
</nav>
471
+
472
+
<main>
473
+
<article>
474
+
<div class="meta">
475
+
<span class="category">Landing page</span>
476
+
<span class="read-time">1 min read</span>
477
+
</div>
478
+
479
+
<h1>Lorem Ipsum Dolor Sit Amet Consectetur</h1>
480
+
481
+
<div class="byline">
482
+
<div class="avatar"></div>
483
+
<div class="author-info">
484
+
<span class="author">Mysterious benefactor</span>
485
+
<span class="author-handle">@lewis.moe</span>
486
+
</div>
487
+
<div class="verification">47 attestations</div>
488
+
</div>
489
+
490
+
<div class="content">
491
+
<blockquote>
492
+
<p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."</p>
493
+
<cite>Cicero, De Finibus Bonorum et Malorum</cite>
494
+
</blockquote>
495
+
496
+
<p class="lede">Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit.</p>
497
+
498
+
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.</p>
499
+
500
+
<p>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
501
+
502
+
<h2>Neque Porro Quisquam</h2>
503
+
504
+
<p>Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.</p>
505
+
506
+
<p>Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur.</p>
507
+
508
+
<h2>Quis Autem Vel Eum</h2>
509
+
510
+
<p>Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur.</p>
511
+
512
+
<p>At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.</p>
513
+
514
+
<p>Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.</p>
515
+
516
+
<p>Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.</p>
517
+
518
+
<p>Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.</p>
519
+
520
+
<div class="carousel">
521
+
<div class="carousel-header">
522
+
<span class="carousel-title">Interface</span>
523
+
<div class="carousel-nav">
524
+
<button class="carousel-prev">←</button>
525
+
<button class="carousel-next">→</button>
526
+
</div>
527
+
</div>
528
+
<div class="carousel-track">
529
+
<div class="carousel-slide">
530
+
<div class="placeholder-image">Dashboard goes here</div>
531
+
<div class="carousel-label">Dashboard</div>
532
+
</div>
533
+
<div class="carousel-slide">
534
+
<div class="placeholder-image">Profile Settings go here</div>
535
+
<div class="carousel-label">Profile Settings</div>
536
+
</div>
537
+
<div class="carousel-slide">
538
+
<div class="placeholder-image">Account Security goes here</div>
539
+
<div class="carousel-label">Account Security</div>
540
+
</div>
541
+
<div class="carousel-slide">
542
+
<div class="placeholder-image">Repository Browser goes here</div>
543
+
<div class="carousel-label">Repository Browser</div>
544
+
</div>
545
+
<div class="carousel-slide">
546
+
<div class="placeholder-image">OAuth Applications goes here</div>
547
+
<div class="carousel-label">OAuth Applications</div>
548
+
</div>
549
+
<div class="carousel-slide">
550
+
<div class="placeholder-image">Invite Codes goes here</div>
551
+
<div class="carousel-label">Invite Codes</div>
552
+
</div>
553
+
</div>
554
+
</div>
555
+
</div>
556
+
557
+
<footer class="article-footer">
558
+
<div class="actions">
559
+
<button>Propagate</button>
560
+
<button>Annotate</button>
561
+
<button>Verify Source</button>
562
+
</div>
563
+
564
+
<div class="attestation-info">
565
+
<span>hash: 7f3a9c...</span>
566
+
<span>signed: 2847.12.03</span>
567
+
<span>nodes: 12,847</span>
568
+
</div>
569
+
</footer>
570
+
</article>
571
+
</main>
572
+
573
+
<footer class="site-footer">
574
+
<div>Mesh Commons License</div>
575
+
<div>node: local-7f3a</div>
576
+
</footer>
577
+
578
+
<script>
579
+
const pattern = document.querySelector('.pattern');
580
+
const spacing = 32;
581
+
const cols = Math.ceil((window.innerWidth + 600) / spacing);
582
+
const rows = Math.ceil((window.innerHeight + 100) / spacing);
583
+
const dots = [];
584
+
585
+
for (let y = 0; y < rows; y++) {
586
+
for (let x = 0; x < cols; x++) {
587
+
const dot = document.createElement('div');
588
+
dot.className = 'dot';
589
+
dot.style.left = (x * spacing) + 'px';
590
+
dot.style.top = (y * spacing) + 'px';
591
+
pattern.appendChild(dot);
592
+
dots.push({ el: dot, x: x * spacing, y: y * spacing });
593
+
}
594
+
}
595
+
596
+
let mouseX = -1000, mouseY = -1000;
597
+
document.addEventListener('mousemove', e => {
598
+
mouseX = e.clientX;
599
+
mouseY = e.clientY;
600
+
});
601
+
602
+
function updateDots() {
603
+
const patternRect = pattern.getBoundingClientRect();
604
+
dots.forEach(dot => {
605
+
const dotX = patternRect.left + dot.x + 5;
606
+
const dotY = patternRect.top + dot.y + 5;
607
+
const dist = Math.hypot(mouseX - dotX, mouseY - dotY);
608
+
const maxDist = 120;
609
+
const scale = Math.min(1, Math.max(0.1, dist / maxDist));
610
+
dot.el.style.transform = `scale(${scale})`;
611
+
});
612
+
requestAnimationFrame(updateDots);
613
+
}
614
+
updateDots();
615
+
616
+
const track = document.querySelector('.carousel-track');
617
+
const prevBtn = document.querySelector('.carousel-prev');
618
+
const nextBtn = document.querySelector('.carousel-next');
619
+
const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16;
620
+
621
+
prevBtn?.addEventListener('click', () => {
622
+
track.scrollBy({ left: -slideWidth, behavior: 'smooth' });
623
+
});
624
+
nextBtn?.addEventListener('click', () => {
625
+
track.scrollBy({ left: slideWidth, behavior: 'smooth' });
626
+
});
627
+
628
+
let isDragging = false;
629
+
let startX, scrollLeft;
630
+
631
+
track?.addEventListener('mousedown', e => {
632
+
isDragging = true;
633
+
track.style.cursor = 'grabbing';
634
+
track.style.scrollSnapType = 'none';
635
+
startX = e.pageX - track.offsetLeft;
636
+
scrollLeft = track.scrollLeft;
637
+
});
638
+
639
+
track?.addEventListener('mouseleave', () => {
640
+
isDragging = false;
641
+
track.style.cursor = 'grab';
642
+
track.style.scrollSnapType = 'x mandatory';
643
+
});
644
+
645
+
function snapTo(target, duration = 120) {
646
+
const start = track.scrollLeft;
647
+
const distance = target - start;
648
+
const startTime = performance.now();
649
+
function step(currentTime) {
650
+
const elapsed = currentTime - startTime;
651
+
const progress = Math.min(elapsed / duration, 1);
652
+
const ease = 1 - Math.pow(1 - progress, 3);
653
+
track.scrollLeft = start + distance * ease;
654
+
if (progress < 1) requestAnimationFrame(step);
655
+
else track.style.scrollSnapType = 'x mandatory';
656
+
}
657
+
requestAnimationFrame(step);
658
+
}
659
+
660
+
track?.addEventListener('mouseup', () => {
661
+
isDragging = false;
662
+
track.style.cursor = 'grab';
663
+
const slideW = track.querySelector('.carousel-slide').offsetWidth + 16;
664
+
const targetIndex = Math.round(track.scrollLeft / slideW);
665
+
snapTo(targetIndex * slideW);
666
+
});
667
+
668
+
track?.addEventListener('mousemove', e => {
669
+
if (!isDragging) return;
670
+
e.preventDefault();
671
+
const x = e.pageX - track.offsetLeft;
672
+
const walk = (x - startX) * 1.5;
673
+
track.scrollLeft = scrollLeft - walk;
674
+
});
675
+
676
+
if (track) track.style.cursor = 'grab';
677
+
</script>
678
+
</body>
679
+
</html>
+714
frontend/mockups/03-landing-page.html
+714
frontend/mockups/03-landing-page.html
···
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</title>
7
+
<link rel="preconnect" href="https://fonts.googleapis.com">
8
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
10
+
<style>
11
+
* { margin: 0; padding: 0; box-sizing: border-box; }
12
+
13
+
:root {
14
+
--primary: #2c00ff;
15
+
--primary-dark: #1a00a3;
16
+
--primary-light: #4d33ff;
17
+
--primary-muted: #e8e5ff;
18
+
--secondary: #ff2400;
19
+
--secondary-hover: #ff5533;
20
+
--bg: #ffffff;
21
+
--bg-subtle: #f8f8fa;
22
+
--text: #1a1a1a;
23
+
--text-muted: #666666;
24
+
--text-light: #999999;
25
+
--border: #e5e5e5;
26
+
--border-light: #f0f0f0;
27
+
}
28
+
29
+
body {
30
+
font-family: 'JetBrains Mono', monospace;
31
+
line-height: 1.7;
32
+
background: var(--bg);
33
+
color: var(--text);
34
+
min-height: 100vh;
35
+
position: relative;
36
+
}
37
+
38
+
.pattern-container {
39
+
position: fixed;
40
+
top: -32px;
41
+
left: -32px;
42
+
right: -32px;
43
+
bottom: -32px;
44
+
pointer-events: none;
45
+
z-index: 1;
46
+
overflow: hidden;
47
+
}
48
+
49
+
.pattern {
50
+
position: absolute;
51
+
top: 0;
52
+
left: 0;
53
+
width: calc(100% + 500px);
54
+
height: 100%;
55
+
animation: drift 80s linear infinite;
56
+
}
57
+
58
+
.dot {
59
+
position: absolute;
60
+
width: 10px;
61
+
height: 10px;
62
+
background: rgba(0, 0, 0, 0.06);
63
+
border-radius: 50%;
64
+
transition: transform 0.04s linear;
65
+
}
66
+
67
+
.pattern-fade {
68
+
position: fixed;
69
+
top: 0;
70
+
left: 0;
71
+
right: 0;
72
+
bottom: 0;
73
+
background: linear-gradient(135deg, transparent 50%, var(--bg) 75%);
74
+
pointer-events: none;
75
+
z-index: 2;
76
+
}
77
+
78
+
@keyframes drift {
79
+
0% { transform: translateX(-500px); }
80
+
100% { transform: translateX(0); }
81
+
}
82
+
83
+
nav { z-index: 100; }
84
+
main { position: relative; z-index: 10; }
85
+
.site-footer { position: relative; z-index: 10; }
86
+
87
+
a { color: var(--secondary); text-decoration: none; }
88
+
a:hover { color: var(--secondary-hover); }
89
+
90
+
nav {
91
+
position: fixed;
92
+
top: 12px;
93
+
left: 32px;
94
+
right: 32px;
95
+
background: var(--primary);
96
+
padding: 10px 18px;
97
+
z-index: 100;
98
+
border-radius: 8px;
99
+
border: 1px solid rgba(0, 0, 0, 0.1);
100
+
display: flex;
101
+
justify-content: space-between;
102
+
align-items: center;
103
+
}
104
+
105
+
nav .brand {
106
+
font-weight: 600;
107
+
font-size: 1rem;
108
+
letter-spacing: 0.08em;
109
+
color: #ffffff;
110
+
text-transform: uppercase;
111
+
}
112
+
113
+
nav .nav-meta {
114
+
font-size: 0.85rem;
115
+
color: rgba(255, 255, 255, 0.7);
116
+
letter-spacing: 0.05em;
117
+
}
118
+
119
+
main {
120
+
max-width: 1000px;
121
+
margin: 0 auto;
122
+
padding: 72px 32px 80px;
123
+
}
124
+
125
+
.meta {
126
+
display: flex;
127
+
align-items: center;
128
+
gap: 16px;
129
+
margin-bottom: 32px;
130
+
font-size: 0.8rem;
131
+
font-weight: 500;
132
+
text-transform: uppercase;
133
+
letter-spacing: 0.1em;
134
+
}
135
+
136
+
.category {
137
+
color: #ffffff;
138
+
background: var(--primary);
139
+
padding: 4px 10px;
140
+
border-radius: 4px;
141
+
}
142
+
143
+
.read-time {
144
+
color: var(--text-muted);
145
+
}
146
+
147
+
h1 {
148
+
font-size: 2.75rem;
149
+
font-weight: 600;
150
+
line-height: 1.15;
151
+
color: var(--text);
152
+
margin-bottom: 32px;
153
+
letter-spacing: -0.02em;
154
+
}
155
+
156
+
.byline {
157
+
display: flex;
158
+
align-items: center;
159
+
gap: 16px;
160
+
padding: 24px 0;
161
+
border-top: 1px solid var(--border);
162
+
border-bottom: 1px solid var(--border);
163
+
margin-bottom: 48px;
164
+
}
165
+
166
+
.avatar {
167
+
width: 44px;
168
+
height: 44px;
169
+
border-radius: 50%;
170
+
background: linear-gradient(135deg, var(--secondary) 0%, #ff6b4a 100%);
171
+
}
172
+
173
+
.author-info {
174
+
flex: 1;
175
+
}
176
+
177
+
.author {
178
+
display: block;
179
+
font-weight: 500;
180
+
color: var(--text);
181
+
font-size: 1rem;
182
+
}
183
+
184
+
.author-handle {
185
+
display: block;
186
+
font-size: 0.85rem;
187
+
color: var(--text-muted);
188
+
margin-top: 2px;
189
+
}
190
+
191
+
.verification {
192
+
font-size: 0.75rem;
193
+
font-weight: 500;
194
+
color: var(--secondary);
195
+
text-transform: uppercase;
196
+
letter-spacing: 0.08em;
197
+
}
198
+
199
+
.placeholder-image {
200
+
aspect-ratio: 16 / 9;
201
+
background: var(--bg-subtle);
202
+
border-radius: 8px;
203
+
display: flex;
204
+
align-items: center;
205
+
justify-content: center;
206
+
font-size: 0.9rem;
207
+
color: var(--text-light);
208
+
text-transform: uppercase;
209
+
letter-spacing: 0.1em;
210
+
border: 1px solid var(--border);
211
+
}
212
+
213
+
figcaption {
214
+
margin-top: 12px;
215
+
font-size: 0.85rem;
216
+
color: var(--text-muted);
217
+
text-align: center;
218
+
}
219
+
220
+
.carousel {
221
+
margin: 64px 0 0;
222
+
}
223
+
224
+
.carousel-header {
225
+
display: flex;
226
+
justify-content: space-between;
227
+
align-items: center;
228
+
margin-bottom: 20px;
229
+
}
230
+
231
+
.carousel-title {
232
+
font-size: 0.85rem;
233
+
font-weight: 600;
234
+
text-transform: uppercase;
235
+
letter-spacing: 0.1em;
236
+
color: var(--text);
237
+
}
238
+
239
+
.carousel-nav {
240
+
display: flex;
241
+
gap: 8px;
242
+
}
243
+
244
+
.carousel-nav button {
245
+
font-family: 'JetBrains Mono', monospace;
246
+
width: 36px;
247
+
height: 36px;
248
+
background: var(--bg);
249
+
border: 1px solid var(--border);
250
+
border-radius: 6px;
251
+
color: var(--text);
252
+
cursor: pointer;
253
+
transition: all 0.15s ease;
254
+
font-size: 1rem;
255
+
}
256
+
257
+
.carousel-nav button:hover {
258
+
background: rgba(255, 36, 0, 0.08);
259
+
border-color: var(--secondary);
260
+
color: var(--secondary);
261
+
}
262
+
263
+
.carousel-track {
264
+
display: flex;
265
+
gap: 16px;
266
+
overflow-x: auto;
267
+
scroll-snap-type: x mandatory;
268
+
scrollbar-width: none;
269
+
-ms-overflow-style: none;
270
+
padding-bottom: 8px;
271
+
-webkit-overflow-scrolling: touch;
272
+
user-select: none;
273
+
}
274
+
275
+
.carousel-track::-webkit-scrollbar {
276
+
display: none;
277
+
}
278
+
279
+
.carousel-slide {
280
+
flex: 0 0 70%;
281
+
scroll-snap-align: start;
282
+
}
283
+
284
+
.carousel-slide .placeholder-image {
285
+
aspect-ratio: 16 / 10;
286
+
}
287
+
288
+
.carousel-label {
289
+
margin-top: 12px;
290
+
font-size: 0.8rem;
291
+
font-weight: 500;
292
+
color: var(--text-muted);
293
+
text-transform: uppercase;
294
+
letter-spacing: 0.08em;
295
+
}
296
+
297
+
.content {
298
+
font-size: 1.05rem;
299
+
font-weight: 400;
300
+
}
301
+
302
+
.content p {
303
+
margin-bottom: 28px;
304
+
}
305
+
306
+
.lede {
307
+
font-size: 1.3rem;
308
+
font-weight: 500;
309
+
color: var(--text);
310
+
line-height: 1.5;
311
+
}
312
+
313
+
.hero {
314
+
padding: 32px 0 40px;
315
+
border-bottom: 1px solid var(--border);
316
+
margin-bottom: 40px;
317
+
}
318
+
319
+
.content h2 {
320
+
font-size: 0.9rem;
321
+
font-weight: 600;
322
+
text-transform: uppercase;
323
+
letter-spacing: 0.1em;
324
+
color: var(--primary-dark);
325
+
margin: 56px 0 24px;
326
+
}
327
+
328
+
.content h2:first-child {
329
+
margin-top: 0;
330
+
}
331
+
332
+
.features {
333
+
display: grid;
334
+
grid-template-columns: repeat(2, 1fr);
335
+
gap: 32px;
336
+
margin: 32px 0 56px;
337
+
}
338
+
339
+
.feature {
340
+
padding: 24px;
341
+
background: var(--bg-subtle);
342
+
border-radius: 8px;
343
+
border: 1px solid var(--border);
344
+
}
345
+
346
+
.feature h3 {
347
+
font-size: 1rem;
348
+
font-weight: 600;
349
+
color: var(--text);
350
+
margin-bottom: 12px;
351
+
}
352
+
353
+
.feature p {
354
+
font-size: 0.95rem;
355
+
color: var(--text-muted);
356
+
margin-bottom: 0;
357
+
line-height: 1.6;
358
+
}
359
+
360
+
@media (max-width: 700px) {
361
+
.features {
362
+
grid-template-columns: 1fr;
363
+
}
364
+
}
365
+
366
+
blockquote {
367
+
margin: 40px 0;
368
+
padding: 32px;
369
+
background: var(--primary-muted);
370
+
border-left: 3px solid var(--primary);
371
+
border-radius: 0 8px 8px 0;
372
+
}
373
+
374
+
blockquote p {
375
+
font-size: 1.15rem;
376
+
color: var(--primary-dark);
377
+
font-style: italic;
378
+
margin-bottom: 16px !important;
379
+
}
380
+
381
+
blockquote cite {
382
+
font-size: 0.8rem;
383
+
color: var(--text-muted);
384
+
font-style: normal;
385
+
text-transform: uppercase;
386
+
letter-spacing: 0.05em;
387
+
}
388
+
389
+
.context-panel {
390
+
margin: 40px 0;
391
+
padding: 24px;
392
+
background: var(--bg-subtle);
393
+
border-radius: 8px;
394
+
border: 1px solid var(--border);
395
+
}
396
+
397
+
.context-panel h3 {
398
+
font-size: 0.8rem;
399
+
font-weight: 600;
400
+
text-transform: uppercase;
401
+
letter-spacing: 0.1em;
402
+
color: var(--text);
403
+
margin-bottom: 16px;
404
+
}
405
+
406
+
.context-panel ul {
407
+
list-style: none;
408
+
}
409
+
410
+
.context-panel li {
411
+
padding: 10px 0;
412
+
border-bottom: 1px solid var(--border-light);
413
+
}
414
+
415
+
.context-panel li:last-child {
416
+
border-bottom: none;
417
+
}
418
+
419
+
.context-panel a {
420
+
font-size: 0.95rem;
421
+
font-weight: 500;
422
+
color: var(--secondary);
423
+
text-decoration: none;
424
+
transition: color 0.15s ease;
425
+
}
426
+
427
+
.context-panel a:hover {
428
+
color: var(--secondary-hover);
429
+
}
430
+
431
+
.article-footer {
432
+
margin-top: 64px;
433
+
padding-top: 32px;
434
+
border-top: 1px solid var(--border);
435
+
}
436
+
437
+
.actions {
438
+
display: flex;
439
+
gap: 12px;
440
+
margin-bottom: 24px;
441
+
}
442
+
443
+
.actions button {
444
+
font-family: 'JetBrains Mono', monospace;
445
+
font-size: 0.85rem;
446
+
font-weight: 500;
447
+
text-transform: uppercase;
448
+
letter-spacing: 0.06em;
449
+
padding: 14px 24px;
450
+
background: var(--bg);
451
+
border: 1px solid var(--border);
452
+
border-radius: 6px;
453
+
color: var(--text);
454
+
cursor: pointer;
455
+
transition: all 0.15s ease;
456
+
}
457
+
458
+
.actions button:hover {
459
+
background: rgba(255, 36, 0, 0.08);
460
+
border-color: var(--secondary);
461
+
color: var(--secondary);
462
+
}
463
+
464
+
.actions button:first-child {
465
+
background: var(--secondary);
466
+
border-color: var(--secondary);
467
+
color: #ffffff;
468
+
}
469
+
470
+
.actions button:first-child:hover {
471
+
background: #cc1d00;
472
+
border-color: #cc1d00;
473
+
}
474
+
475
+
.attestation-info {
476
+
display: flex;
477
+
flex-wrap: wrap;
478
+
gap: 24px;
479
+
font-size: 0.8rem;
480
+
color: var(--text-light);
481
+
text-transform: uppercase;
482
+
letter-spacing: 0.05em;
483
+
}
484
+
485
+
.site-footer {
486
+
max-width: 1000px;
487
+
margin: 0 auto;
488
+
padding: 48px 32px;
489
+
display: flex;
490
+
justify-content: space-between;
491
+
font-size: 0.8rem;
492
+
color: var(--text-light);
493
+
text-transform: uppercase;
494
+
letter-spacing: 0.05em;
495
+
border-top: 1px solid var(--border);
496
+
}
497
+
498
+
::selection {
499
+
background: rgba(255, 36, 0, 0.2);
500
+
color: var(--text);
501
+
}
502
+
</style>
503
+
</head>
504
+
<body>
505
+
506
+
<div class="pattern-container">
507
+
<div class="pattern"></div>
508
+
</div>
509
+
<div class="pattern-fade"></div>
510
+
511
+
<nav>
512
+
<span class="brand">Tranquil PDS</span>
513
+
<span class="nav-meta">0.1.0</span>
514
+
</nav>
515
+
516
+
<main>
517
+
<section class="hero">
518
+
<h1>A home for your ATProto account</h1>
519
+
520
+
<p class="lede">Tranquil PDS is a Personal Data Server, the thing that stores your posts, profile, and keys. Bluesky runs one for you, but you can run your own.</p>
521
+
522
+
<div class="actions" style="margin-top: 40px; margin-bottom: 0;">
523
+
<button>Join This Server</button>
524
+
<button>Run Your Own</button>
525
+
</div>
526
+
<blockquote>
527
+
<p>"Nature does not hurry, yet everything is accomplished."</p>
528
+
<cite>Lao Tzu</cite>
529
+
</blockquote>
530
+
</section>
531
+
532
+
<section class="content">
533
+
<h2>What you get</h2>
534
+
535
+
<div class="features">
536
+
<div class="feature">
537
+
<h3>Real security</h3>
538
+
<p>Sign in with passkeys, add two-factor authentication, set up backup codes, and mark devices you trust. Your account stays yours.</p>
539
+
</div>
540
+
541
+
<div class="feature">
542
+
<h3>Your own identity</h3>
543
+
<p>Use your own domain as your handle, or get a subdomain on ours. Either way, your identity moves with you if you ever leave.</p>
544
+
</div>
545
+
546
+
<div class="feature">
547
+
<h3>Stay in the loop</h3>
548
+
<p>Get important alerts where you actually see them: email, Discord, Telegram, or Signal.</p>
549
+
</div>
550
+
551
+
<div class="feature">
552
+
<h3>You decide what apps can do</h3>
553
+
<p>When an app asks for access, you'll see exactly what it wants in plain language. Grant what makes sense, deny what doesn't.</p>
554
+
</div>
555
+
</div>
556
+
557
+
<h2>Everything in one place</h2>
558
+
559
+
<p>Manage your profile, security settings, connected apps, and more from a clean dashboard. No command line or 3rd party apps required.</p>
560
+
561
+
<div class="carousel">
562
+
<div class="carousel-header">
563
+
<span class="carousel-title">Interface</span>
564
+
<div class="carousel-nav">
565
+
<button class="carousel-prev">←</button>
566
+
<button class="carousel-next">→</button>
567
+
</div>
568
+
</div>
569
+
<div class="carousel-track">
570
+
<div class="carousel-slide">
571
+
<div class="placeholder-image">Dashboard</div>
572
+
<div class="carousel-label">Dashboard</div>
573
+
</div>
574
+
<div class="carousel-slide">
575
+
<div class="placeholder-image">Profile Settings</div>
576
+
<div class="carousel-label">Profile Settings</div>
577
+
</div>
578
+
<div class="carousel-slide">
579
+
<div class="placeholder-image">Account Security</div>
580
+
<div class="carousel-label">Account Security</div>
581
+
</div>
582
+
<div class="carousel-slide">
583
+
<div class="placeholder-image">Connected Apps</div>
584
+
<div class="carousel-label">Connected Apps</div>
585
+
</div>
586
+
<div class="carousel-slide">
587
+
<div class="placeholder-image">Invite Friends</div>
588
+
<div class="carousel-label">Invite Friends</div>
589
+
</div>
590
+
</div>
591
+
</div>
592
+
593
+
<h2>Works with everything</h2>
594
+
595
+
<p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients, tools, and bots just work.</p>
596
+
597
+
<h2>Ready to try it?</h2>
598
+
599
+
<p>Join this server, or grab the source and run your own. Either way, you can migrate an existing account over and your followers, posts, and identity come with you.</p>
600
+
601
+
<div class="actions" style="margin-top: 32px;">
602
+
<button>Join This Server</button>
603
+
<button>View Source</button>
604
+
</div>
605
+
</section>
606
+
</main>
607
+
608
+
<footer class="site-footer">
609
+
<div>Open Source</div>
610
+
<div>Made with care</div>
611
+
</footer>
612
+
613
+
<script>
614
+
const pattern = document.querySelector('.pattern');
615
+
const spacing = 32;
616
+
const cols = Math.ceil((window.innerWidth + 600) / spacing);
617
+
const rows = Math.ceil((window.innerHeight + 100) / spacing);
618
+
const dots = [];
619
+
620
+
for (let y = 0; y < rows; y++) {
621
+
for (let x = 0; x < cols; x++) {
622
+
const dot = document.createElement('div');
623
+
dot.className = 'dot';
624
+
dot.style.left = (x * spacing) + 'px';
625
+
dot.style.top = (y * spacing) + 'px';
626
+
pattern.appendChild(dot);
627
+
dots.push({ el: dot, x: x * spacing, y: y * spacing });
628
+
}
629
+
}
630
+
631
+
let mouseX = -1000, mouseY = -1000;
632
+
document.addEventListener('mousemove', e => {
633
+
mouseX = e.clientX;
634
+
mouseY = e.clientY;
635
+
});
636
+
637
+
function updateDots() {
638
+
const patternRect = pattern.getBoundingClientRect();
639
+
dots.forEach(dot => {
640
+
const dotX = patternRect.left + dot.x + 5;
641
+
const dotY = patternRect.top + dot.y + 5;
642
+
const dist = Math.hypot(mouseX - dotX, mouseY - dotY);
643
+
const maxDist = 120;
644
+
const scale = Math.min(1, Math.max(0.1, dist / maxDist));
645
+
dot.el.style.transform = `scale(${scale})`;
646
+
});
647
+
requestAnimationFrame(updateDots);
648
+
}
649
+
updateDots();
650
+
651
+
const track = document.querySelector('.carousel-track');
652
+
const prevBtn = document.querySelector('.carousel-prev');
653
+
const nextBtn = document.querySelector('.carousel-next');
654
+
const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16;
655
+
656
+
prevBtn?.addEventListener('click', () => {
657
+
track.scrollBy({ left: -slideWidth, behavior: 'smooth' });
658
+
});
659
+
nextBtn?.addEventListener('click', () => {
660
+
track.scrollBy({ left: slideWidth, behavior: 'smooth' });
661
+
});
662
+
663
+
let isDragging = false;
664
+
let startX, scrollLeft;
665
+
666
+
track?.addEventListener('mousedown', e => {
667
+
isDragging = true;
668
+
track.style.cursor = 'grabbing';
669
+
track.style.scrollSnapType = 'none';
670
+
startX = e.pageX - track.offsetLeft;
671
+
scrollLeft = track.scrollLeft;
672
+
});
673
+
674
+
track?.addEventListener('mouseleave', () => {
675
+
isDragging = false;
676
+
track.style.cursor = 'grab';
677
+
track.style.scrollSnapType = 'x mandatory';
678
+
});
679
+
680
+
function snapTo(target, duration = 120) {
681
+
const start = track.scrollLeft;
682
+
const distance = target - start;
683
+
const startTime = performance.now();
684
+
function step(currentTime) {
685
+
const elapsed = currentTime - startTime;
686
+
const progress = Math.min(elapsed / duration, 1);
687
+
const ease = 1 - Math.pow(1 - progress, 3);
688
+
track.scrollLeft = start + distance * ease;
689
+
if (progress < 1) requestAnimationFrame(step);
690
+
else track.style.scrollSnapType = 'x mandatory';
691
+
}
692
+
requestAnimationFrame(step);
693
+
}
694
+
695
+
track?.addEventListener('mouseup', () => {
696
+
isDragging = false;
697
+
track.style.cursor = 'grab';
698
+
const slideW = track.querySelector('.carousel-slide').offsetWidth + 16;
699
+
const targetIndex = Math.round(track.scrollLeft / slideW);
700
+
snapTo(targetIndex * slideW);
701
+
});
702
+
703
+
track?.addEventListener('mousemove', e => {
704
+
if (!isDragging) return;
705
+
e.preventDefault();
706
+
const x = e.pageX - track.offsetLeft;
707
+
const walk = (x - startX) * 1.5;
708
+
track.scrollLeft = scrollLeft - walk;
709
+
});
710
+
711
+
if (track) track.style.cursor = 'grab';
712
+
</script>
713
+
</body>
714
+
</html>
+15
-3
frontend/src/App.svelte
+15
-3
frontend/src/App.svelte
···
1
1
<script lang="ts">
2
-
import { getCurrentPath } from './lib/router.svelte'
2
+
import { getCurrentPath, navigate } from './lib/router.svelte'
3
3
import { initAuth, getAuthState } from './lib/auth.svelte'
4
4
import { initI18n, _ } from './lib/i18n'
5
5
import { isLoading as i18nLoading } from 'svelte-i18n'
···
33
33
34
34
const auth = getAuthState()
35
35
36
+
let oauthCallbackPending = $state(hasOAuthCallback())
37
+
38
+
function hasOAuthCallback(): boolean {
39
+
const params = new URLSearchParams(window.location.search)
40
+
return !!(params.get('code') && params.get('state'))
41
+
}
42
+
36
43
$effect(() => {
37
-
initAuth()
44
+
initAuth().then(({ oauthLoginCompleted }) => {
45
+
if (oauthLoginCompleted) {
46
+
navigate('/dashboard')
47
+
}
48
+
oauthCallbackPending = false
49
+
})
38
50
})
39
51
40
52
function getComponent(path: string) {
···
97
109
</script>
98
110
99
111
<main>
100
-
{#if auth.loading || $i18nLoading}
112
+
{#if auth.loading || $i18nLoading || oauthCallbackPending}
101
113
<div class="loading">
102
114
<p>Loading...</p>
103
115
</div>
+4
-3
frontend/src/lib/auth.svelte.ts
+4
-3
frontend/src/lib/auth.svelte.ts
···
111
111
}
112
112
}
113
113
114
-
export async function initAuth() {
114
+
export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
115
115
setTokenRefreshCallback(tryRefreshToken)
116
116
state.loading = true
117
117
state.error = null
···
133
133
addOrUpdateSavedAccount(session)
134
134
applyLocaleFromSession(sessionInfo)
135
135
state.loading = false
136
-
return
136
+
return { oauthLoginCompleted: true }
137
137
} catch (e) {
138
138
state.error = e instanceof Error ? e.message : 'OAuth login failed'
139
139
state.loading = false
140
-
return
140
+
return { oauthLoginCompleted: false }
141
141
}
142
142
}
143
143
···
175
175
}
176
176
}
177
177
state.loading = false
178
+
return { oauthLoginCompleted: false }
178
179
}
179
180
180
181
export async function login(identifier: string, password: string): Promise<void> {
+1
frontend/src/lib/router.svelte.ts
+1
frontend/src/lib/router.svelte.ts
+341
-90
frontend/src/routes/Home.svelte
+341
-90
frontend/src/routes/Home.svelte
···
1
1
<script lang="ts">
2
+
import { onMount } from 'svelte'
2
3
import { _ } from '../lib/i18n'
3
4
import { getAuthState } from '../lib/auth.svelte'
5
+
4
6
const auth = getAuthState()
7
+
const sourceUrl = 'https://tangled.org/lewis.moe/bspds-sandbox'
8
+
9
+
onMount(() => {
10
+
const pattern = document.getElementById('dotPattern')
11
+
if (!pattern) return
12
+
13
+
const spacing = 32
14
+
const cols = Math.ceil((window.innerWidth + 600) / spacing)
15
+
const rows = Math.ceil((window.innerHeight + 100) / spacing)
16
+
const dots: { el: HTMLElement; x: number; y: number }[] = []
17
+
18
+
for (let y = 0; y < rows; y++) {
19
+
for (let x = 0; x < cols; x++) {
20
+
const dot = document.createElement('div')
21
+
dot.className = 'dot'
22
+
dot.style.left = (x * spacing) + 'px'
23
+
dot.style.top = (y * spacing) + 'px'
24
+
pattern.appendChild(dot)
25
+
dots.push({ el: dot, x: x * spacing, y: y * spacing })
26
+
}
27
+
}
28
+
29
+
let mouseX = -1000
30
+
let mouseY = -1000
31
+
32
+
const handleMouseMove = (e: MouseEvent) => {
33
+
mouseX = e.clientX
34
+
mouseY = e.clientY
35
+
}
36
+
37
+
document.addEventListener('mousemove', handleMouseMove)
38
+
39
+
let animationId: number
40
+
41
+
function updateDots() {
42
+
const patternRect = pattern.getBoundingClientRect()
43
+
dots.forEach(dot => {
44
+
const dotX = patternRect.left + dot.x + 5
45
+
const dotY = patternRect.top + dot.y + 5
46
+
const dist = Math.hypot(mouseX - dotX, mouseY - dotY)
47
+
const maxDist = 120
48
+
const scale = Math.min(1, Math.max(0.1, dist / maxDist))
49
+
dot.el.style.transform = `scale(${scale})`
50
+
})
51
+
animationId = requestAnimationFrame(updateDots)
52
+
}
53
+
updateDots()
54
+
55
+
return () => {
56
+
document.removeEventListener('mousemove', handleMouseMove)
57
+
cancelAnimationFrame(animationId)
58
+
}
59
+
})
5
60
</script>
61
+
62
+
<div class="pattern-container">
63
+
<div class="pattern" id="dotPattern"></div>
64
+
</div>
65
+
<div class="pattern-fade"></div>
66
+
67
+
<nav>
68
+
<span class="brand">Tranquil PDS</span>
69
+
<span class="nav-meta">0.1.0</span>
70
+
</nav>
71
+
6
72
<div class="home">
7
-
<header class="hero">
8
-
<h1>Tranquil PDS</h1>
9
-
<p class="tagline">A Personal Data Server for the AT Protocol</p>
10
-
</header>
11
-
<section>
12
-
<h2>What is a PDS?</h2>
13
-
<p>
14
-
Bluesky runs on a federated protocol called AT Protocol. Your account lives on a PDS,
15
-
a server that stores your posts, profile, follows, and cryptographic keys. Bluesky hosts
16
-
one for you at bsky.social, but you can run your own. Self-hosting means you control your
17
-
data; you're not dependent on any company's servers, and your account + data is actually yours.
18
-
</p>
73
+
<section class="hero">
74
+
<h1>A home for your ATProto account</h1>
75
+
76
+
<p class="lede">Tranquil PDS is a Personal Data Server, the thing that stores your posts, profile, and keys. Bluesky runs one for you, but you can run your own.</p>
77
+
78
+
<div class="actions">
79
+
{#if auth.session}
80
+
<a href="#/dashboard" class="btn primary">@{auth.session.handle}</a>
81
+
{:else}
82
+
<a href="#/register" class="btn primary">Join This Server</a>
83
+
<a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">Run Your Own</a>
84
+
{/if}
85
+
</div>
86
+
87
+
<blockquote>
88
+
<p>"Nature does not hurry, yet everything is accomplished."</p>
89
+
<cite>Lao Tzu</cite>
90
+
</blockquote>
19
91
</section>
20
-
<section>
21
-
<h2>What's different about Tranquil?</h2>
22
-
<p>
23
-
This software isn't an afterthought by a company with limited resources.
24
-
It is a superset of the reference PDS, including:
25
-
</p>
26
-
<ul>
27
-
<li>Passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices)</li>
28
-
<li>did:web support (PDS-hosted subdomains or bring-your-own)</li>
29
-
<li>Multi-channel notifications (email, discord, telegram, signal)</li>
30
-
<li>Granular OAuth scopes with a consent UI</li>
31
-
<li>Built-in web UI for account management, repo browsing, and admin</li>
32
-
</ul>
33
-
<p>
34
-
Full compatibility with Bluesky's reference PDS: same endpoints, same behavior,
35
-
same client compatibility. Everything works.
36
-
</p>
92
+
93
+
<section class="content">
94
+
<h2>What you get</h2>
95
+
96
+
<div class="features">
97
+
<div class="feature">
98
+
<h3>Real security</h3>
99
+
<p>Sign in with passkeys, add two-factor authentication, set up backup codes, and mark devices you trust. Your account stays yours.</p>
100
+
</div>
101
+
102
+
<div class="feature">
103
+
<h3>Your own identity</h3>
104
+
<p>Use your own domain as your handle, or get a subdomain on ours. Either way, your identity moves with you if you ever leave.</p>
105
+
</div>
106
+
107
+
<div class="feature">
108
+
<h3>Stay in the loop</h3>
109
+
<p>Get important alerts where you actually see them: email, Discord, Telegram, or Signal.</p>
110
+
</div>
111
+
112
+
<div class="feature">
113
+
<h3>You decide what apps can do</h3>
114
+
<p>When an app asks for access, you'll see exactly what it wants in plain language. Grant what makes sense, deny what doesn't.</p>
115
+
</div>
116
+
</div>
117
+
118
+
<h2>Everything in one place</h2>
119
+
120
+
<p>Manage your profile, security settings, connected apps, and more from a clean dashboard. No command line or 3rd party apps required.</p>
121
+
122
+
<h2>Works with everything</h2>
123
+
124
+
<p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients, tools, and bots just work.</p>
125
+
126
+
<h2>Ready to try it?</h2>
127
+
128
+
<p>Join this server, or grab the source and run your own. Either way, you can migrate an existing account over and your followers, posts, and identity come with you.</p>
129
+
130
+
<div class="actions">
131
+
{#if auth.session}
132
+
<a href="#/dashboard" class="btn primary">@{auth.session.handle}</a>
133
+
{:else}
134
+
<a href="#/register" class="btn primary">Join This Server</a>
135
+
<a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">View Source</a>
136
+
{/if}
137
+
</div>
37
138
</section>
38
-
<div class="cta">
39
-
{#if auth.session}
40
-
<a href="#/dashboard" class="btn">@{auth.session.handle}</a>
41
-
{:else}
42
-
<a href="#/login" class="btn">{$_('login.button')}</a>
43
-
<a href="#/register" class="btn secondary">{$_('login.createAccount')}</a>
44
-
{/if}
45
-
</div>
46
-
<footer>
47
-
<a href="https://tangled.org/lewis.moe/bspds-sandbox" target="_blank" rel="noopener">Source code</a>
139
+
140
+
<footer class="site-footer">
141
+
<span>Open Source</span>
142
+
<span>Made with care</span>
48
143
</footer>
49
144
</div>
145
+
50
146
<style>
51
-
.home {
52
-
max-width: var(--width-md);
53
-
margin: 0 auto;
54
-
padding: var(--space-7);
147
+
.pattern-container {
148
+
position: fixed;
149
+
top: -32px;
150
+
left: -32px;
151
+
right: -32px;
152
+
bottom: -32px;
153
+
pointer-events: none;
154
+
z-index: 1;
155
+
overflow: hidden;
156
+
}
157
+
158
+
.pattern {
159
+
position: absolute;
160
+
top: 0;
161
+
left: 0;
162
+
width: calc(100% + 500px);
163
+
height: 100%;
164
+
animation: drift 80s linear infinite;
165
+
}
166
+
167
+
.pattern :global(.dot) {
168
+
position: absolute;
169
+
width: 10px;
170
+
height: 10px;
171
+
background: rgba(0, 0, 0, 0.06);
172
+
border-radius: 50%;
173
+
transition: transform 0.04s linear;
174
+
}
175
+
176
+
@media (prefers-color-scheme: dark) {
177
+
.pattern :global(.dot) {
178
+
background: rgba(255, 255, 255, 0.1);
179
+
}
180
+
}
181
+
182
+
.pattern-fade {
183
+
position: fixed;
184
+
top: 0;
185
+
left: 0;
186
+
right: 0;
187
+
bottom: 0;
188
+
background: linear-gradient(135deg, transparent 50%, var(--bg-primary) 75%);
189
+
pointer-events: none;
190
+
z-index: 2;
55
191
}
56
192
57
-
.hero {
58
-
text-align: center;
59
-
margin-bottom: var(--space-8);
60
-
padding-top: var(--space-7);
193
+
@keyframes drift {
194
+
0% { transform: translateX(-500px); }
195
+
100% { transform: translateX(0); }
61
196
}
62
197
63
-
.hero h1 {
64
-
font-size: var(--text-4xl);
65
-
margin-bottom: var(--space-3);
198
+
nav {
199
+
position: fixed;
200
+
top: 12px;
201
+
left: 32px;
202
+
right: 32px;
203
+
background: var(--accent);
204
+
padding: 10px 18px;
205
+
z-index: 100;
206
+
border-radius: var(--radius-xl);
207
+
display: flex;
208
+
justify-content: space-between;
209
+
align-items: center;
66
210
}
67
211
68
-
.tagline {
69
-
color: var(--text-secondary);
70
-
font-size: var(--text-xl);
212
+
.brand {
213
+
font-weight: var(--font-semibold);
214
+
font-size: var(--text-base);
215
+
letter-spacing: 0.08em;
216
+
color: var(--text-inverse);
217
+
text-transform: uppercase;
71
218
}
72
219
73
-
section {
74
-
margin-bottom: var(--space-7);
220
+
.nav-meta {
221
+
font-size: var(--text-sm);
222
+
color: rgba(255, 255, 255, 0.7);
223
+
letter-spacing: 0.05em;
75
224
}
76
225
77
-
h2 {
78
-
margin-bottom: var(--space-4);
226
+
.home {
227
+
position: relative;
228
+
z-index: 10;
229
+
max-width: var(--width-xl);
230
+
margin: 0 auto;
231
+
padding: 72px 32px 32px;
79
232
}
80
233
81
-
p {
82
-
color: var(--text-secondary);
83
-
margin-bottom: var(--space-4);
234
+
.hero {
235
+
padding: var(--space-7) 0 var(--space-8);
236
+
border-bottom: 1px solid var(--border-color);
237
+
margin-bottom: var(--space-8);
84
238
}
85
239
86
-
ul {
87
-
color: var(--text-secondary);
88
-
margin: 0 0 var(--space-4) 0;
89
-
padding-left: var(--space-6);
90
-
line-height: var(--leading-relaxed);
240
+
h1 {
241
+
font-size: var(--text-4xl);
242
+
font-weight: var(--font-semibold);
243
+
line-height: var(--leading-tight);
244
+
margin-bottom: var(--space-6);
245
+
letter-spacing: -0.02em;
91
246
}
92
247
93
-
li {
94
-
margin-bottom: var(--space-2);
248
+
.lede {
249
+
font-size: var(--text-xl);
250
+
font-weight: var(--font-medium);
251
+
color: var(--text-primary);
252
+
line-height: var(--leading-relaxed);
253
+
margin-bottom: 0;
95
254
}
96
255
97
-
.cta {
256
+
.actions {
98
257
display: flex;
99
258
gap: var(--space-4);
100
-
justify-content: center;
101
-
margin: var(--space-8) 0;
259
+
margin-top: var(--space-7);
102
260
}
103
261
104
262
.btn {
105
-
display: inline-block;
106
-
padding: var(--space-4) var(--space-7);
107
-
border-radius: var(--radius-md);
108
-
font-size: var(--text-base);
263
+
font-size: var(--text-sm);
109
264
font-weight: var(--font-medium);
265
+
text-transform: uppercase;
266
+
letter-spacing: 0.06em;
267
+
padding: var(--space-4) var(--space-6);
268
+
border-radius: var(--radius-lg);
110
269
text-decoration: none;
111
-
transition: background var(--transition-normal), border-color var(--transition-normal);
112
-
background: var(--accent);
270
+
transition: all var(--transition-normal);
271
+
border: 1px solid transparent;
272
+
}
273
+
274
+
.btn.primary {
275
+
background: var(--secondary);
113
276
color: var(--text-inverse);
277
+
border-color: var(--secondary);
114
278
}
115
279
116
-
.btn:hover {
117
-
background: var(--accent-hover);
118
-
text-decoration: none;
280
+
.btn.primary:hover {
281
+
background: var(--secondary-hover);
282
+
border-color: var(--secondary-hover);
119
283
}
120
284
121
285
.btn.secondary {
122
286
background: transparent;
123
-
color: var(--accent);
124
-
border: 1px solid var(--accent);
287
+
color: var(--text-primary);
288
+
border-color: var(--border-color);
125
289
}
126
290
127
291
.btn.secondary:hover {
128
-
background: var(--accent);
129
-
color: var(--text-inverse);
292
+
background: var(--secondary-muted);
293
+
border-color: var(--secondary);
294
+
color: var(--secondary);
295
+
}
296
+
297
+
blockquote {
298
+
margin: var(--space-8) 0 0 0;
299
+
padding: var(--space-6);
300
+
background: var(--accent-muted);
301
+
border-left: 3px solid var(--accent);
302
+
border-radius: 0 var(--radius-xl) var(--radius-xl) 0;
130
303
}
131
304
132
-
footer {
133
-
text-align: center;
134
-
padding-top: var(--space-7);
135
-
border-top: 1px solid var(--border-color);
305
+
blockquote p {
306
+
font-size: var(--text-lg);
307
+
color: var(--text-primary);
308
+
font-style: italic;
309
+
margin-bottom: var(--space-3);
136
310
}
137
311
138
-
footer a {
139
-
color: var(--text-muted);
312
+
blockquote cite {
140
313
font-size: var(--text-sm);
314
+
color: var(--text-secondary);
315
+
font-style: normal;
316
+
text-transform: uppercase;
317
+
letter-spacing: 0.05em;
141
318
}
142
319
143
-
footer a:hover {
320
+
.content h2 {
321
+
font-size: var(--text-sm);
322
+
font-weight: var(--font-semibold);
323
+
text-transform: uppercase;
324
+
letter-spacing: 0.1em;
144
325
color: var(--accent);
326
+
margin: var(--space-8) 0 var(--space-5);
327
+
}
328
+
329
+
.content h2:first-child {
330
+
margin-top: 0;
331
+
}
332
+
333
+
.content > p {
334
+
font-size: var(--text-base);
335
+
color: var(--text-secondary);
336
+
margin-bottom: var(--space-5);
337
+
line-height: var(--leading-relaxed);
338
+
}
339
+
340
+
.features {
341
+
display: grid;
342
+
grid-template-columns: repeat(2, 1fr);
343
+
gap: var(--space-6);
344
+
margin: var(--space-6) 0 var(--space-8);
345
+
}
346
+
347
+
.feature {
348
+
padding: var(--space-5);
349
+
background: var(--bg-secondary);
350
+
border-radius: var(--radius-xl);
351
+
border: 1px solid var(--border-color);
352
+
}
353
+
354
+
.feature h3 {
355
+
font-size: var(--text-base);
356
+
font-weight: var(--font-semibold);
357
+
color: var(--text-primary);
358
+
margin-bottom: var(--space-3);
359
+
}
360
+
361
+
.feature p {
362
+
font-size: var(--text-sm);
363
+
color: var(--text-secondary);
364
+
margin: 0;
365
+
line-height: var(--leading-relaxed);
366
+
}
367
+
368
+
@media (max-width: 700px) {
369
+
.features {
370
+
grid-template-columns: 1fr;
371
+
}
372
+
373
+
h1 {
374
+
font-size: var(--text-3xl);
375
+
}
376
+
377
+
.actions {
378
+
flex-direction: column;
379
+
}
380
+
381
+
.btn {
382
+
text-align: center;
383
+
}
384
+
}
385
+
386
+
.site-footer {
387
+
margin-top: var(--space-9);
388
+
padding-top: var(--space-7);
389
+
display: flex;
390
+
justify-content: space-between;
391
+
font-size: var(--text-sm);
392
+
color: var(--text-muted);
393
+
text-transform: uppercase;
394
+
letter-spacing: 0.05em;
395
+
border-top: 1px solid var(--border-color);
145
396
}
146
397
</style>
+11
-6
frontend/src/styles/base.css
+11
-6
frontend/src/styles/base.css
···
8
8
9
9
body {
10
10
margin: 0;
11
-
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
12
12
font-size: var(--text-base);
13
13
line-height: var(--leading-normal);
14
14
color: var(--text-primary);
···
32
32
}
33
33
34
34
a {
35
-
color: var(--accent);
35
+
color: var(--secondary);
36
36
text-decoration: none;
37
37
}
38
38
39
39
a:hover {
40
-
text-decoration: underline;
40
+
color: var(--secondary-hover);
41
+
text-decoration: none;
42
+
}
43
+
44
+
::selection {
45
+
background: var(--secondary-muted);
41
46
}
42
47
43
48
input,
···
171
176
}
172
177
173
178
code {
174
-
font-family: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', monospace;
179
+
font-family: inherit;
175
180
font-size: 0.9em;
176
181
background: var(--bg-tertiary);
177
182
padding: var(--space-1) var(--space-2);
···
179
184
}
180
185
181
186
pre {
182
-
font-family: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', monospace;
187
+
font-family: inherit;
183
188
font-size: var(--text-sm);
184
189
background: var(--bg-tertiary);
185
190
padding: var(--space-4);
···
338
343
}
339
344
340
345
.mono {
341
-
font-family: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', monospace;
346
+
font-family: inherit;
342
347
}
343
348
344
349
.mt-4 { margin-top: var(--space-4); }
+35
-25
frontend/src/styles/tokens.css
+35
-25
frontend/src/styles/tokens.css
···
48
48
--transition-normal: 0.15s ease;
49
49
--transition-slow: 0.25s ease;
50
50
51
-
--bg-primary: #fafafa;
52
-
--bg-secondary: #f5f5f5;
53
-
--bg-tertiary: #eeeeee;
51
+
--bg-primary: #ffffff;
52
+
--bg-secondary: #f8f8fa;
53
+
--bg-tertiary: #f0f0f2;
54
54
--bg-card: #ffffff;
55
55
--bg-input: #ffffff;
56
-
--bg-input-disabled: #f5f5f5;
56
+
--bg-input-disabled: #f8f8fa;
57
57
58
-
--text-primary: #333333;
58
+
--text-primary: #1a1a1a;
59
59
--text-secondary: #666666;
60
60
--text-muted: #999999;
61
61
--text-inverse: #ffffff;
62
62
63
-
--border-color: #dddddd;
64
-
--border-light: #eeeeee;
63
+
--border-color: #e5e5e5;
64
+
--border-light: #f0f0f0;
65
65
--border-dark: #cccccc;
66
66
67
-
--accent: #0066cc;
68
-
--accent-hover: #0052a3;
69
-
--accent-muted: rgba(0, 102, 204, 0.15);
67
+
--accent: #2c00ff;
68
+
--accent-hover: #1a00a3;
69
+
--accent-muted: rgba(44, 0, 255, 0.08);
70
+
--accent-light: #4d33ff;
71
+
72
+
--secondary: #ff2400;
73
+
--secondary-hover: #cc1d00;
74
+
--secondary-muted: rgba(255, 36, 0, 0.08);
70
75
71
76
--success-bg: #dfd;
72
77
--success-border: #8c8;
···
85
90
86
91
@media (prefers-color-scheme: dark) {
87
92
:root {
88
-
--bg-primary: #1a1a1a;
89
-
--bg-secondary: #222222;
90
-
--bg-tertiary: #2a2a2a;
91
-
--bg-card: #2a2a2a;
92
-
--bg-input: #333333;
93
-
--bg-input-disabled: #2a2a2a;
93
+
--bg-primary: #0a0a0a;
94
+
--bg-secondary: #141414;
95
+
--bg-tertiary: #1a1a1a;
96
+
--bg-card: #141414;
97
+
--bg-input: #1a1a1a;
98
+
--bg-input-disabled: #141414;
94
99
95
-
--text-primary: #e0e0e0;
100
+
--text-primary: #e8e8e8;
96
101
--text-secondary: #a0a0a0;
97
-
--text-muted: #707070;
98
-
--text-inverse: #1a1a1a;
102
+
--text-muted: #666666;
103
+
--text-inverse: #0a0a0a;
99
104
100
-
--border-color: #404040;
101
-
--border-light: #333333;
102
-
--border-dark: #505050;
105
+
--border-color: #2a2a2a;
106
+
--border-light: #222222;
107
+
--border-dark: #333333;
108
+
109
+
--accent: #2c00ff;
110
+
--accent-hover: #4d33ff;
111
+
--accent-muted: rgba(44, 0, 255, 0.15);
112
+
--accent-light: #4d33ff;
103
113
104
-
--accent: #4da6ff;
105
-
--accent-hover: #7abbff;
106
-
--accent-muted: rgba(77, 166, 255, 0.2);
114
+
--secondary: #ff2400;
115
+
--secondary-hover: #ff5533;
116
+
--secondary-muted: rgba(255, 36, 0, 0.15);
107
117
108
118
--success-bg: #1a3d1a;
109
119
--success-border: #2d5a2d;
+8
-8
justfile
+8
-8
justfile
···
45
45
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pds" sqlx database drop -y
46
46
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pds" sqlx database create
47
47
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pds" sqlx migrate run
48
-
docker-up:
49
-
docker compose up -d
50
-
docker-down:
51
-
docker compose down
52
-
docker-logs:
53
-
docker compose logs -f
54
-
docker-build:
55
-
docker compose build
48
+
podman-up:
49
+
podman compose up -d
50
+
podman-down:
51
+
podman compose down
52
+
podman-logs:
53
+
podman compose logs -f
54
+
podman-build:
55
+
podman compose build
56
56
# Frontend commands (Deno)
57
57
frontend-dev:
58
58
. ~/.deno/env && cd frontend && deno task dev
+3
-14
src/oauth/client.rs
+3
-14
src/oauth/client.rs
···
88
88
89
89
fn is_loopback_client(client_id: &str) -> bool {
90
90
if let Ok(url) = reqwest::Url::parse(client_id) {
91
-
url.scheme() == "http" && url.host_str() == Some("localhost") && url.port().is_none()
91
+
url.scheme() == "http"
92
+
&& matches!(url.host_str(), Some("localhost") | Some("127.0.0.1"))
92
93
} else {
93
94
false
94
95
}
···
310
311
let is_loopback_redirect = req_url.scheme() == "http"
311
312
&& (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]");
312
313
if is_loopback_redirect {
313
-
for registered in &metadata.redirect_uris {
314
-
if let Ok(reg_url) = reqwest::Url::parse(registered) {
315
-
let reg_host = reg_url.host_str().unwrap_or("");
316
-
let hosts_match = (req_host == "localhost" && reg_host == "localhost")
317
-
|| (req_host == "127.0.0.1" && reg_host == "127.0.0.1")
318
-
|| (req_host == "[::1]" && reg_host == "[::1]")
319
-
|| (req_host == "localhost" && reg_host == "127.0.0.1")
320
-
|| (req_host == "127.0.0.1" && reg_host == "localhost");
321
-
if hosts_match && req_url.path() == reg_url.path() {
322
-
return Ok(());
323
-
}
324
-
}
325
-
}
314
+
return Ok(());
326
315
}
327
316
}
328
317
Err(OAuthError::InvalidRequest(