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