Some actual styling

This commit is contained in:
lewis
2025-12-23 03:08:03 +02:00
parent 097ca28879
commit cb98398494
12 changed files with 2474 additions and 159 deletions

View File

@@ -4,9 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tranquil PDS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
html { background: #fafafa; }
@media (prefers-color-scheme: dark) { html { background: #1a1a1a; } }
html { background: #ffffff; }
@media (prefers-color-scheme: dark) { html { background: #0a0a0a; } }
</style>
</head>
<body>

View File

@@ -0,0 +1,650 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tranquil</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'JetBrains Mono', monospace;
line-height: 1.7;
background: #2c00ff;
color: #ffffff;
min-height: 100vh;
position: relative;
}
.pattern-container {
position: fixed;
top: -32px;
left: -32px;
right: -32px;
bottom: -32px;
pointer-events: none;
z-index: 1;
overflow: hidden;
}
.pattern {
position: absolute;
top: 0;
left: 0;
width: calc(100% + 500px);
height: 100%;
animation: drift 80s linear infinite;
}
.dot {
position: absolute;
width: 10px;
height: 10px;
background: rgba(255,255,255,0.15);
border-radius: 50%;
transition: transform 0.04s linear;
}
.pattern-fade {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, transparent 50%, #2c00ff 75%);
pointer-events: none;
z-index: 2;
}
@keyframes drift {
0% { transform: translateX(-500px); }
100% { transform: translateX(0); }
}
nav { z-index: 100; }
main { position: relative; z-index: 10; }
.site-footer { position: relative; z-index: 10; }
a { color: #ff2400; text-decoration: none; }
a:hover { color: #ff5533; }
nav {
position: fixed;
top: 12px;
left: 32px;
right: 32px;
background: #1a00a3;
padding: 10px 18px;
z-index: 100;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
nav .brand {
font-weight: 600;
font-size: 1rem;
letter-spacing: 0.08em;
color: #ffffff;
text-transform: uppercase;
}
nav .nav-meta {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.05em;
}
main {
max-width: 1000px;
margin: 0 auto;
padding: 80px 32px 80px;
}
.meta {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.category {
color: #ff2400;
background: rgba(255, 255, 255, 0.95);
padding: 4px 10px;
border-radius: 4px;
}
.read-time {
color: rgba(255, 255, 255, 0.8);
}
h1 {
font-size: 2.75rem;
font-weight: 600;
line-height: 1.15;
color: #ffffff;
margin-bottom: 32px;
letter-spacing: -0.02em;
}
.byline {
display: flex;
align-items: center;
gap: 16px;
padding: 24px 0;
border-top: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
margin-bottom: 48px;
}
.avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #ff2400 0%, #ff6b4a 100%);
}
.author-info {
flex: 1;
}
.author {
display: block;
font-weight: 500;
color: #ffffff;
font-size: 1rem;
}
.author-handle {
display: block;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.8);
margin-top: 2px;
}
.verification {
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.placeholder-image {
aspect-ratio: 16 / 9;
background: rgba(255, 255, 255, 0.08);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.1em;
border: 1px solid rgba(255, 255, 255, 0.15);
}
figcaption {
margin-top: 12px;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.75);
text-align: center;
}
.carousel {
margin: 64px 0 0;
}
.carousel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.carousel-title {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #ffffff;
}
.carousel-nav {
display: flex;
gap: 8px;
}
.carousel-nav button {
font-family: 'JetBrains Mono', monospace;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #ffffff;
cursor: pointer;
transition: all 0.15s ease;
font-size: 1rem;
}
.carousel-nav button:hover {
background: rgba(255, 36, 0, 0.15);
border-color: #ff2400;
}
.carousel-track {
display: flex;
gap: 16px;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
-ms-overflow-style: none;
padding-bottom: 8px;
-webkit-overflow-scrolling: touch;
user-select: none;
}
.carousel-track::-webkit-scrollbar {
display: none;
}
.carousel-slide {
flex: 0 0 70%;
scroll-snap-align: start;
}
.carousel-slide .placeholder-image {
aspect-ratio: 16 / 10;
}
.carousel-label {
margin-top: 12px;
font-size: 0.8rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.content {
font-size: 1.05rem;
font-weight: 400;
}
.content p {
margin-bottom: 28px;
}
.lede {
font-size: 1.3rem;
font-weight: 500;
color: #ffffff;
line-height: 1.5;
}
.content h2 {
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #ffffff;
margin: 56px 0 24px;
}
blockquote {
margin: 40px 0;
padding: 32px;
background: rgba(255, 255, 255, 0.05);
border-left: 2px solid #ff2400;
border-radius: 0 8px 8px 0;
}
blockquote p {
font-size: 1.15rem;
color: #ffffff;
font-style: italic;
margin-bottom: 16px !important;
}
blockquote cite {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
font-style: normal;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.context-panel {
margin: 40px 0;
padding: 24px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.context-panel h3 {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #ffffff;
margin-bottom: 16px;
}
.context-panel ul {
list-style: none;
}
.context-panel li {
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.context-panel li:last-child {
border-bottom: none;
}
.context-panel a {
font-size: 0.95rem;
font-weight: 500;
color: #ff2400;
text-decoration: none;
transition: color 0.15s ease;
}
.context-panel a:hover {
color: #ff5533;
}
.article-footer {
margin-top: 64px;
padding-top: 32px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.actions {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.actions button {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 14px 24px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: #ffffff;
cursor: pointer;
transition: all 0.15s ease;
}
.actions button:hover {
background: rgba(255, 36, 0, 0.15);
border-color: #ff2400;
color: #ffffff;
}
.attestation-info {
display: flex;
flex-wrap: wrap;
gap: 24px;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.site-footer {
max-width: 1000px;
margin: 0 auto;
padding: 48px 32px;
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.65);
text-transform: uppercase;
letter-spacing: 0.05em;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
::selection {
background: rgba(255, 36, 0, 0.4);
}
</style>
</head>
<body>
<div class="pattern-container">
<div class="pattern"></div>
</div>
<div class="pattern-fade"></div>
<nav>
<span class="brand">Tranquil</span>
<span class="nav-meta">0.1.0</span>
</nav>
<main>
<article>
<div class="meta">
<span class="category">Landing page</span>
<span class="read-time">1 min read</span>
</div>
<h1>Lorem Ipsum Dolor Sit Amet Consectetur</h1>
<div class="byline">
<div class="avatar"></div>
<div class="author-info">
<span class="author">Mysterious benefactor</span>
<span class="author-handle">@lewis.moe</span>
</div>
<div class="verification">47 attestations</div>
</div>
<div class="content">
<blockquote>
<p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."</p>
<cite>Cicero, De Finibus Bonorum et Malorum</cite>
</blockquote>
<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>
<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>
<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>
<h2>Neque Porro Quisquam</h2>
<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>
<p>Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur.</p>
<h2>Quis Autem Vel Eum</h2>
<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>
<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>
<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>
<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>
<p>Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.</p>
<div class="carousel">
<div class="carousel-header">
<span class="carousel-title">Interface</span>
<div class="carousel-nav">
<button class="carousel-prev"></button>
<button class="carousel-next"></button>
</div>
</div>
<div class="carousel-track">
<div class="carousel-slide">
<div class="placeholder-image">Dashboard goes here</div>
<div class="carousel-label">Dashboard</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Profile Settings go here</div>
<div class="carousel-label">Profile Settings</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Account Security goes here</div>
<div class="carousel-label">Account Security</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Repository Browser goes here</div>
<div class="carousel-label">Repository Browser</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">OAuth Applications go here</div>
<div class="carousel-label">OAuth Applications</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Invite Codes go here</div>
<div class="carousel-label">Invite Codes</div>
</div>
</div>
</div>
</div>
<footer class="article-footer">
<div class="actions">
<button>Propagate</button>
<button>Annotate</button>
<button>Verify Source</button>
</div>
<div class="attestation-info">
<span>hash: 7f3a9c...</span>
<span>signed: 2847.12.03</span>
<span>nodes: 12,847</span>
</div>
</footer>
</article>
</main>
<footer class="site-footer">
<div>Mesh Commons License</div>
<div>node: local-7f3a</div>
</footer>
<script>
const pattern = document.querySelector('.pattern');
const spacing = 32;
const cols = Math.ceil((window.innerWidth + 600) / spacing);
const rows = Math.ceil((window.innerHeight + 100) / spacing);
const dots = [];
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const dot = document.createElement('div');
dot.className = 'dot';
dot.style.left = (x * spacing) + 'px';
dot.style.top = (y * spacing) + 'px';
pattern.appendChild(dot);
dots.push({ el: dot, x: x * spacing, y: y * spacing });
}
}
let mouseX = -1000, mouseY = -1000;
document.addEventListener('mousemove', e => {
mouseX = e.clientX;
mouseY = e.clientY;
});
function updateDots() {
const patternRect = pattern.getBoundingClientRect();
dots.forEach(dot => {
const dotX = patternRect.left + dot.x + 5;
const dotY = patternRect.top + dot.y + 5;
const dist = Math.hypot(mouseX - dotX, mouseY - dotY);
const maxDist = 120;
const scale = Math.min(1, Math.max(0.1, dist / maxDist));
dot.el.style.transform = `scale(${scale})`;
});
requestAnimationFrame(updateDots);
}
updateDots();
const track = document.querySelector('.carousel-track');
const prevBtn = document.querySelector('.carousel-prev');
const nextBtn = document.querySelector('.carousel-next');
const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16;
prevBtn?.addEventListener('click', () => {
track.scrollBy({ left: -slideWidth, behavior: 'smooth' });
});
nextBtn?.addEventListener('click', () => {
track.scrollBy({ left: slideWidth, behavior: 'smooth' });
});
let isDragging = false;
let startX, scrollLeft;
track?.addEventListener('mousedown', e => {
isDragging = true;
track.style.cursor = 'grabbing';
track.style.scrollSnapType = 'none';
startX = e.pageX - track.offsetLeft;
scrollLeft = track.scrollLeft;
});
track?.addEventListener('mouseleave', () => {
isDragging = false;
track.style.cursor = 'grab';
track.style.scrollSnapType = 'x mandatory';
});
function snapTo(target, duration = 120) {
const start = track.scrollLeft;
const distance = target - start;
const startTime = performance.now();
function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - progress, 3);
track.scrollLeft = start + distance * ease;
if (progress < 1) requestAnimationFrame(step);
else track.style.scrollSnapType = 'x mandatory';
}
requestAnimationFrame(step);
}
track?.addEventListener('mouseup', () => {
isDragging = false;
track.style.cursor = 'grab';
const slideW = track.querySelector('.carousel-slide').offsetWidth + 16;
const targetIndex = Math.round(track.scrollLeft / slideW);
snapTo(targetIndex * slideW);
});
track?.addEventListener('mousemove', e => {
if (!isDragging) return;
e.preventDefault();
const x = e.pageX - track.offsetLeft;
const walk = (x - startX) * 1.5;
track.scrollLeft = scrollLeft - walk;
});
if (track) track.style.cursor = 'grab';
</script>
</body>
</html>

View File

@@ -0,0 +1,679 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tranquil</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #2c00ff;
--primary-dark: #1a00a3;
--primary-light: #4d33ff;
--primary-muted: #e8e5ff;
--secondary: #ff2400;
--secondary-hover: #ff5533;
--bg: #ffffff;
--bg-subtle: #f8f8fa;
--text: #1a1a1a;
--text-muted: #666666;
--text-light: #999999;
--border: #e5e5e5;
--border-light: #f0f0f0;
}
body {
font-family: 'JetBrains Mono', monospace;
line-height: 1.7;
background: var(--bg);
color: var(--text);
min-height: 100vh;
position: relative;
}
.pattern-container {
position: fixed;
top: -32px;
left: -32px;
right: -32px;
bottom: -32px;
pointer-events: none;
z-index: 1;
overflow: hidden;
}
.pattern {
position: absolute;
top: 0;
left: 0;
width: calc(100% + 500px);
height: 100%;
animation: drift 80s linear infinite;
}
.dot {
position: absolute;
width: 10px;
height: 10px;
background: rgba(0, 0, 0, 0.06);
border-radius: 50%;
transition: transform 0.04s linear;
}
.pattern-fade {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, transparent 50%, var(--bg) 75%);
pointer-events: none;
z-index: 2;
}
@keyframes drift {
0% { transform: translateX(-500px); }
100% { transform: translateX(0); }
}
nav { z-index: 100; }
main { position: relative; z-index: 10; }
.site-footer { position: relative; z-index: 10; }
a { color: var(--secondary); text-decoration: none; }
a:hover { color: var(--secondary-hover); }
nav {
position: fixed;
top: 12px;
left: 32px;
right: 32px;
background: var(--primary);
padding: 10px 18px;
z-index: 100;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
nav .brand {
font-weight: 600;
font-size: 1rem;
letter-spacing: 0.08em;
color: #ffffff;
text-transform: uppercase;
}
nav .nav-meta {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.05em;
}
main {
max-width: 1000px;
margin: 0 auto;
padding: 100px 32px 80px;
}
.meta {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.category {
color: #ffffff;
background: var(--primary);
padding: 4px 10px;
border-radius: 4px;
}
.read-time {
color: var(--text-muted);
}
h1 {
font-size: 2.75rem;
font-weight: 600;
line-height: 1.15;
color: var(--text);
margin-bottom: 32px;
letter-spacing: -0.02em;
}
.byline {
display: flex;
align-items: center;
gap: 16px;
padding: 24px 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
margin-bottom: 48px;
}
.avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, var(--secondary) 0%, #ff6b4a 100%);
}
.author-info {
flex: 1;
}
.author {
display: block;
font-weight: 500;
color: var(--text);
font-size: 1rem;
}
.author-handle {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 2px;
}
.verification {
font-size: 0.75rem;
font-weight: 500;
color: var(--secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.placeholder-image {
aspect-ratio: 16 / 9;
background: var(--bg-subtle);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.1em;
border: 1px solid var(--border);
}
figcaption {
margin-top: 12px;
font-size: 0.85rem;
color: var(--text-muted);
text-align: center;
}
.carousel {
margin: 64px 0 0;
}
.carousel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.carousel-title {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text);
}
.carousel-nav {
display: flex;
gap: 8px;
}
.carousel-nav button {
font-family: 'JetBrains Mono', monospace;
width: 36px;
height: 36px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
transition: all 0.15s ease;
font-size: 1rem;
}
.carousel-nav button:hover {
background: rgba(255, 36, 0, 0.08);
border-color: var(--secondary);
color: var(--secondary);
}
.carousel-track {
display: flex;
gap: 16px;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
-ms-overflow-style: none;
padding-bottom: 8px;
-webkit-overflow-scrolling: touch;
user-select: none;
}
.carousel-track::-webkit-scrollbar {
display: none;
}
.carousel-slide {
flex: 0 0 70%;
scroll-snap-align: start;
}
.carousel-slide .placeholder-image {
aspect-ratio: 16 / 10;
}
.carousel-label {
margin-top: 12px;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.content {
font-size: 1.05rem;
font-weight: 400;
}
.content p {
margin-bottom: 28px;
}
.lede {
font-size: 1.3rem;
font-weight: 500;
color: var(--text);
line-height: 1.5;
}
.content h2 {
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--primary-dark);
margin: 56px 0 24px;
}
blockquote {
margin: 40px 0;
padding: 32px;
background: var(--primary-muted);
border-left: 3px solid var(--primary);
border-radius: 0 8px 8px 0;
}
blockquote p {
font-size: 1.15rem;
color: var(--primary-dark);
font-style: italic;
margin-bottom: 16px !important;
}
blockquote cite {
font-size: 0.8rem;
color: var(--text-muted);
font-style: normal;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.context-panel {
margin: 40px 0;
padding: 24px;
background: var(--bg-subtle);
border-radius: 8px;
border: 1px solid var(--border);
}
.context-panel h3 {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text);
margin-bottom: 16px;
}
.context-panel ul {
list-style: none;
}
.context-panel li {
padding: 10px 0;
border-bottom: 1px solid var(--border-light);
}
.context-panel li:last-child {
border-bottom: none;
}
.context-panel a {
font-size: 0.95rem;
font-weight: 500;
color: var(--secondary);
text-decoration: none;
transition: color 0.15s ease;
}
.context-panel a:hover {
color: var(--secondary-hover);
}
.article-footer {
margin-top: 64px;
padding-top: 32px;
border-top: 1px solid var(--border);
}
.actions {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.actions button {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 14px 24px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
transition: all 0.15s ease;
}
.actions button:hover {
background: rgba(255, 36, 0, 0.08);
border-color: var(--secondary);
color: var(--secondary);
}
.actions button:first-child {
background: var(--secondary);
border-color: var(--secondary);
color: #ffffff;
}
.actions button:first-child:hover {
background: #cc1d00;
border-color: #cc1d00;
}
.attestation-info {
display: flex;
flex-wrap: wrap;
gap: 24px;
font-size: 0.8rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.site-footer {
max-width: 1000px;
margin: 0 auto;
padding: 48px 32px;
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.05em;
border-top: 1px solid var(--border);
}
::selection {
background: rgba(255, 36, 0, 0.2);
color: var(--text);
}
</style>
</head>
<body>
<div class="pattern-container">
<div class="pattern"></div>
</div>
<div class="pattern-fade"></div>
<nav>
<span class="brand">Tranquil PDS</span>
<span class="nav-meta">0.1.0</span>
</nav>
<main>
<article>
<div class="meta">
<span class="category">Landing page</span>
<span class="read-time">1 min read</span>
</div>
<h1>Lorem Ipsum Dolor Sit Amet Consectetur</h1>
<div class="byline">
<div class="avatar"></div>
<div class="author-info">
<span class="author">Mysterious benefactor</span>
<span class="author-handle">@lewis.moe</span>
</div>
<div class="verification">47 attestations</div>
</div>
<div class="content">
<blockquote>
<p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."</p>
<cite>Cicero, De Finibus Bonorum et Malorum</cite>
</blockquote>
<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>
<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>
<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>
<h2>Neque Porro Quisquam</h2>
<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>
<p>Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur.</p>
<h2>Quis Autem Vel Eum</h2>
<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>
<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>
<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>
<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>
<p>Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.</p>
<div class="carousel">
<div class="carousel-header">
<span class="carousel-title">Interface</span>
<div class="carousel-nav">
<button class="carousel-prev"></button>
<button class="carousel-next"></button>
</div>
</div>
<div class="carousel-track">
<div class="carousel-slide">
<div class="placeholder-image">Dashboard goes here</div>
<div class="carousel-label">Dashboard</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Profile Settings go here</div>
<div class="carousel-label">Profile Settings</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Account Security goes here</div>
<div class="carousel-label">Account Security</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Repository Browser goes here</div>
<div class="carousel-label">Repository Browser</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">OAuth Applications goes here</div>
<div class="carousel-label">OAuth Applications</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Invite Codes goes here</div>
<div class="carousel-label">Invite Codes</div>
</div>
</div>
</div>
</div>
<footer class="article-footer">
<div class="actions">
<button>Propagate</button>
<button>Annotate</button>
<button>Verify Source</button>
</div>
<div class="attestation-info">
<span>hash: 7f3a9c...</span>
<span>signed: 2847.12.03</span>
<span>nodes: 12,847</span>
</div>
</footer>
</article>
</main>
<footer class="site-footer">
<div>Mesh Commons License</div>
<div>node: local-7f3a</div>
</footer>
<script>
const pattern = document.querySelector('.pattern');
const spacing = 32;
const cols = Math.ceil((window.innerWidth + 600) / spacing);
const rows = Math.ceil((window.innerHeight + 100) / spacing);
const dots = [];
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const dot = document.createElement('div');
dot.className = 'dot';
dot.style.left = (x * spacing) + 'px';
dot.style.top = (y * spacing) + 'px';
pattern.appendChild(dot);
dots.push({ el: dot, x: x * spacing, y: y * spacing });
}
}
let mouseX = -1000, mouseY = -1000;
document.addEventListener('mousemove', e => {
mouseX = e.clientX;
mouseY = e.clientY;
});
function updateDots() {
const patternRect = pattern.getBoundingClientRect();
dots.forEach(dot => {
const dotX = patternRect.left + dot.x + 5;
const dotY = patternRect.top + dot.y + 5;
const dist = Math.hypot(mouseX - dotX, mouseY - dotY);
const maxDist = 120;
const scale = Math.min(1, Math.max(0.1, dist / maxDist));
dot.el.style.transform = `scale(${scale})`;
});
requestAnimationFrame(updateDots);
}
updateDots();
const track = document.querySelector('.carousel-track');
const prevBtn = document.querySelector('.carousel-prev');
const nextBtn = document.querySelector('.carousel-next');
const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16;
prevBtn?.addEventListener('click', () => {
track.scrollBy({ left: -slideWidth, behavior: 'smooth' });
});
nextBtn?.addEventListener('click', () => {
track.scrollBy({ left: slideWidth, behavior: 'smooth' });
});
let isDragging = false;
let startX, scrollLeft;
track?.addEventListener('mousedown', e => {
isDragging = true;
track.style.cursor = 'grabbing';
track.style.scrollSnapType = 'none';
startX = e.pageX - track.offsetLeft;
scrollLeft = track.scrollLeft;
});
track?.addEventListener('mouseleave', () => {
isDragging = false;
track.style.cursor = 'grab';
track.style.scrollSnapType = 'x mandatory';
});
function snapTo(target, duration = 120) {
const start = track.scrollLeft;
const distance = target - start;
const startTime = performance.now();
function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - progress, 3);
track.scrollLeft = start + distance * ease;
if (progress < 1) requestAnimationFrame(step);
else track.style.scrollSnapType = 'x mandatory';
}
requestAnimationFrame(step);
}
track?.addEventListener('mouseup', () => {
isDragging = false;
track.style.cursor = 'grab';
const slideW = track.querySelector('.carousel-slide').offsetWidth + 16;
const targetIndex = Math.round(track.scrollLeft / slideW);
snapTo(targetIndex * slideW);
});
track?.addEventListener('mousemove', e => {
if (!isDragging) return;
e.preventDefault();
const x = e.pageX - track.offsetLeft;
const walk = (x - startX) * 1.5;
track.scrollLeft = scrollLeft - walk;
});
if (track) track.style.cursor = 'grab';
</script>
</body>
</html>

View File

@@ -0,0 +1,714 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tranquil</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #2c00ff;
--primary-dark: #1a00a3;
--primary-light: #4d33ff;
--primary-muted: #e8e5ff;
--secondary: #ff2400;
--secondary-hover: #ff5533;
--bg: #ffffff;
--bg-subtle: #f8f8fa;
--text: #1a1a1a;
--text-muted: #666666;
--text-light: #999999;
--border: #e5e5e5;
--border-light: #f0f0f0;
}
body {
font-family: 'JetBrains Mono', monospace;
line-height: 1.7;
background: var(--bg);
color: var(--text);
min-height: 100vh;
position: relative;
}
.pattern-container {
position: fixed;
top: -32px;
left: -32px;
right: -32px;
bottom: -32px;
pointer-events: none;
z-index: 1;
overflow: hidden;
}
.pattern {
position: absolute;
top: 0;
left: 0;
width: calc(100% + 500px);
height: 100%;
animation: drift 80s linear infinite;
}
.dot {
position: absolute;
width: 10px;
height: 10px;
background: rgba(0, 0, 0, 0.06);
border-radius: 50%;
transition: transform 0.04s linear;
}
.pattern-fade {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, transparent 50%, var(--bg) 75%);
pointer-events: none;
z-index: 2;
}
@keyframes drift {
0% { transform: translateX(-500px); }
100% { transform: translateX(0); }
}
nav { z-index: 100; }
main { position: relative; z-index: 10; }
.site-footer { position: relative; z-index: 10; }
a { color: var(--secondary); text-decoration: none; }
a:hover { color: var(--secondary-hover); }
nav {
position: fixed;
top: 12px;
left: 32px;
right: 32px;
background: var(--primary);
padding: 10px 18px;
z-index: 100;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
nav .brand {
font-weight: 600;
font-size: 1rem;
letter-spacing: 0.08em;
color: #ffffff;
text-transform: uppercase;
}
nav .nav-meta {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.05em;
}
main {
max-width: 1000px;
margin: 0 auto;
padding: 72px 32px 80px;
}
.meta {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.category {
color: #ffffff;
background: var(--primary);
padding: 4px 10px;
border-radius: 4px;
}
.read-time {
color: var(--text-muted);
}
h1 {
font-size: 2.75rem;
font-weight: 600;
line-height: 1.15;
color: var(--text);
margin-bottom: 32px;
letter-spacing: -0.02em;
}
.byline {
display: flex;
align-items: center;
gap: 16px;
padding: 24px 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
margin-bottom: 48px;
}
.avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, var(--secondary) 0%, #ff6b4a 100%);
}
.author-info {
flex: 1;
}
.author {
display: block;
font-weight: 500;
color: var(--text);
font-size: 1rem;
}
.author-handle {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 2px;
}
.verification {
font-size: 0.75rem;
font-weight: 500;
color: var(--secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.placeholder-image {
aspect-ratio: 16 / 9;
background: var(--bg-subtle);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.1em;
border: 1px solid var(--border);
}
figcaption {
margin-top: 12px;
font-size: 0.85rem;
color: var(--text-muted);
text-align: center;
}
.carousel {
margin: 64px 0 0;
}
.carousel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.carousel-title {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text);
}
.carousel-nav {
display: flex;
gap: 8px;
}
.carousel-nav button {
font-family: 'JetBrains Mono', monospace;
width: 36px;
height: 36px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
transition: all 0.15s ease;
font-size: 1rem;
}
.carousel-nav button:hover {
background: rgba(255, 36, 0, 0.08);
border-color: var(--secondary);
color: var(--secondary);
}
.carousel-track {
display: flex;
gap: 16px;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
-ms-overflow-style: none;
padding-bottom: 8px;
-webkit-overflow-scrolling: touch;
user-select: none;
}
.carousel-track::-webkit-scrollbar {
display: none;
}
.carousel-slide {
flex: 0 0 70%;
scroll-snap-align: start;
}
.carousel-slide .placeholder-image {
aspect-ratio: 16 / 10;
}
.carousel-label {
margin-top: 12px;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.content {
font-size: 1.05rem;
font-weight: 400;
}
.content p {
margin-bottom: 28px;
}
.lede {
font-size: 1.3rem;
font-weight: 500;
color: var(--text);
line-height: 1.5;
}
.hero {
padding: 32px 0 40px;
border-bottom: 1px solid var(--border);
margin-bottom: 40px;
}
.content h2 {
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--primary-dark);
margin: 56px 0 24px;
}
.content h2:first-child {
margin-top: 0;
}
.features {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 32px;
margin: 32px 0 56px;
}
.feature {
padding: 24px;
background: var(--bg-subtle);
border-radius: 8px;
border: 1px solid var(--border);
}
.feature h3 {
font-size: 1rem;
font-weight: 600;
color: var(--text);
margin-bottom: 12px;
}
.feature p {
font-size: 0.95rem;
color: var(--text-muted);
margin-bottom: 0;
line-height: 1.6;
}
@media (max-width: 700px) {
.features {
grid-template-columns: 1fr;
}
}
blockquote {
margin: 40px 0;
padding: 32px;
background: var(--primary-muted);
border-left: 3px solid var(--primary);
border-radius: 0 8px 8px 0;
}
blockquote p {
font-size: 1.15rem;
color: var(--primary-dark);
font-style: italic;
margin-bottom: 16px !important;
}
blockquote cite {
font-size: 0.8rem;
color: var(--text-muted);
font-style: normal;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.context-panel {
margin: 40px 0;
padding: 24px;
background: var(--bg-subtle);
border-radius: 8px;
border: 1px solid var(--border);
}
.context-panel h3 {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text);
margin-bottom: 16px;
}
.context-panel ul {
list-style: none;
}
.context-panel li {
padding: 10px 0;
border-bottom: 1px solid var(--border-light);
}
.context-panel li:last-child {
border-bottom: none;
}
.context-panel a {
font-size: 0.95rem;
font-weight: 500;
color: var(--secondary);
text-decoration: none;
transition: color 0.15s ease;
}
.context-panel a:hover {
color: var(--secondary-hover);
}
.article-footer {
margin-top: 64px;
padding-top: 32px;
border-top: 1px solid var(--border);
}
.actions {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.actions button {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 14px 24px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
transition: all 0.15s ease;
}
.actions button:hover {
background: rgba(255, 36, 0, 0.08);
border-color: var(--secondary);
color: var(--secondary);
}
.actions button:first-child {
background: var(--secondary);
border-color: var(--secondary);
color: #ffffff;
}
.actions button:first-child:hover {
background: #cc1d00;
border-color: #cc1d00;
}
.attestation-info {
display: flex;
flex-wrap: wrap;
gap: 24px;
font-size: 0.8rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.site-footer {
max-width: 1000px;
margin: 0 auto;
padding: 48px 32px;
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.05em;
border-top: 1px solid var(--border);
}
::selection {
background: rgba(255, 36, 0, 0.2);
color: var(--text);
}
</style>
</head>
<body>
<div class="pattern-container">
<div class="pattern"></div>
</div>
<div class="pattern-fade"></div>
<nav>
<span class="brand">Tranquil PDS</span>
<span class="nav-meta">0.1.0</span>
</nav>
<main>
<section class="hero">
<h1>A home for your ATProto account</h1>
<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>
<div class="actions" style="margin-top: 40px; margin-bottom: 0;">
<button>Join This Server</button>
<button>Run Your Own</button>
</div>
<blockquote>
<p>"Nature does not hurry, yet everything is accomplished."</p>
<cite>Lao Tzu</cite>
</blockquote>
</section>
<section class="content">
<h2>What you get</h2>
<div class="features">
<div class="feature">
<h3>Real security</h3>
<p>Sign in with passkeys, add two-factor authentication, set up backup codes, and mark devices you trust. Your account stays yours.</p>
</div>
<div class="feature">
<h3>Your own identity</h3>
<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>
</div>
<div class="feature">
<h3>Stay in the loop</h3>
<p>Get important alerts where you actually see them: email, Discord, Telegram, or Signal.</p>
</div>
<div class="feature">
<h3>You decide what apps can do</h3>
<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>
</div>
</div>
<h2>Everything in one place</h2>
<p>Manage your profile, security settings, connected apps, and more from a clean dashboard. No command line or 3rd party apps required.</p>
<div class="carousel">
<div class="carousel-header">
<span class="carousel-title">Interface</span>
<div class="carousel-nav">
<button class="carousel-prev"></button>
<button class="carousel-next"></button>
</div>
</div>
<div class="carousel-track">
<div class="carousel-slide">
<div class="placeholder-image">Dashboard</div>
<div class="carousel-label">Dashboard</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Profile Settings</div>
<div class="carousel-label">Profile Settings</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Account Security</div>
<div class="carousel-label">Account Security</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Connected Apps</div>
<div class="carousel-label">Connected Apps</div>
</div>
<div class="carousel-slide">
<div class="placeholder-image">Invite Friends</div>
<div class="carousel-label">Invite Friends</div>
</div>
</div>
</div>
<h2>Works with everything</h2>
<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>
<h2>Ready to try it?</h2>
<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>
<div class="actions" style="margin-top: 32px;">
<button>Join This Server</button>
<button>View Source</button>
</div>
</section>
</main>
<footer class="site-footer">
<div>Open Source</div>
<div>Made with care</div>
</footer>
<script>
const pattern = document.querySelector('.pattern');
const spacing = 32;
const cols = Math.ceil((window.innerWidth + 600) / spacing);
const rows = Math.ceil((window.innerHeight + 100) / spacing);
const dots = [];
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const dot = document.createElement('div');
dot.className = 'dot';
dot.style.left = (x * spacing) + 'px';
dot.style.top = (y * spacing) + 'px';
pattern.appendChild(dot);
dots.push({ el: dot, x: x * spacing, y: y * spacing });
}
}
let mouseX = -1000, mouseY = -1000;
document.addEventListener('mousemove', e => {
mouseX = e.clientX;
mouseY = e.clientY;
});
function updateDots() {
const patternRect = pattern.getBoundingClientRect();
dots.forEach(dot => {
const dotX = patternRect.left + dot.x + 5;
const dotY = patternRect.top + dot.y + 5;
const dist = Math.hypot(mouseX - dotX, mouseY - dotY);
const maxDist = 120;
const scale = Math.min(1, Math.max(0.1, dist / maxDist));
dot.el.style.transform = `scale(${scale})`;
});
requestAnimationFrame(updateDots);
}
updateDots();
const track = document.querySelector('.carousel-track');
const prevBtn = document.querySelector('.carousel-prev');
const nextBtn = document.querySelector('.carousel-next');
const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16;
prevBtn?.addEventListener('click', () => {
track.scrollBy({ left: -slideWidth, behavior: 'smooth' });
});
nextBtn?.addEventListener('click', () => {
track.scrollBy({ left: slideWidth, behavior: 'smooth' });
});
let isDragging = false;
let startX, scrollLeft;
track?.addEventListener('mousedown', e => {
isDragging = true;
track.style.cursor = 'grabbing';
track.style.scrollSnapType = 'none';
startX = e.pageX - track.offsetLeft;
scrollLeft = track.scrollLeft;
});
track?.addEventListener('mouseleave', () => {
isDragging = false;
track.style.cursor = 'grab';
track.style.scrollSnapType = 'x mandatory';
});
function snapTo(target, duration = 120) {
const start = track.scrollLeft;
const distance = target - start;
const startTime = performance.now();
function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - progress, 3);
track.scrollLeft = start + distance * ease;
if (progress < 1) requestAnimationFrame(step);
else track.style.scrollSnapType = 'x mandatory';
}
requestAnimationFrame(step);
}
track?.addEventListener('mouseup', () => {
isDragging = false;
track.style.cursor = 'grab';
const slideW = track.querySelector('.carousel-slide').offsetWidth + 16;
const targetIndex = Math.round(track.scrollLeft / slideW);
snapTo(targetIndex * slideW);
});
track?.addEventListener('mousemove', e => {
if (!isDragging) return;
e.preventDefault();
const x = e.pageX - track.offsetLeft;
const walk = (x - startX) * 1.5;
track.scrollLeft = scrollLeft - walk;
});
if (track) track.style.cursor = 'grab';
</script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { getCurrentPath } from './lib/router.svelte'
import { getCurrentPath, navigate } from './lib/router.svelte'
import { initAuth, getAuthState } from './lib/auth.svelte'
import { initI18n, _ } from './lib/i18n'
import { isLoading as i18nLoading } from 'svelte-i18n'
@@ -33,8 +33,20 @@
const auth = getAuthState()
let oauthCallbackPending = $state(hasOAuthCallback())
function hasOAuthCallback(): boolean {
const params = new URLSearchParams(window.location.search)
return !!(params.get('code') && params.get('state'))
}
$effect(() => {
initAuth()
initAuth().then(({ oauthLoginCompleted }) => {
if (oauthLoginCompleted) {
navigate('/dashboard')
}
oauthCallbackPending = false
})
})
function getComponent(path: string) {
@@ -97,7 +109,7 @@
</script>
<main>
{#if auth.loading || $i18nLoading}
{#if auth.loading || $i18nLoading || oauthCallbackPending}
<div class="loading">
<p>Loading...</p>
</div>

View File

@@ -111,7 +111,7 @@ async function tryRefreshToken(): Promise<string | null> {
}
}
export async function initAuth() {
export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
setTokenRefreshCallback(tryRefreshToken)
state.loading = true
state.error = null
@@ -133,11 +133,11 @@ export async function initAuth() {
addOrUpdateSavedAccount(session)
applyLocaleFromSession(sessionInfo)
state.loading = false
return
return { oauthLoginCompleted: true }
} catch (e) {
state.error = e instanceof Error ? e.message : 'OAuth login failed'
state.loading = false
return
return { oauthLoginCompleted: false }
}
}
@@ -175,6 +175,7 @@ export async function initAuth() {
}
}
state.loading = false
return { oauthLoginCompleted: false }
}
export async function login(identifier: string, password: string): Promise<void> {

View File

@@ -10,6 +10,7 @@ window.addEventListener('hashchange', () => {
})
export function navigate(path: string) {
currentPath = path
window.location.hash = path
}

View File

@@ -1,146 +1,397 @@
<script lang="ts">
import { onMount } from 'svelte'
import { _ } from '../lib/i18n'
import { getAuthState } from '../lib/auth.svelte'
const auth = getAuthState()
const sourceUrl = 'https://tangled.org/lewis.moe/bspds-sandbox'
onMount(() => {
const pattern = document.getElementById('dotPattern')
if (!pattern) return
const spacing = 32
const cols = Math.ceil((window.innerWidth + 600) / spacing)
const rows = Math.ceil((window.innerHeight + 100) / spacing)
const dots: { el: HTMLElement; x: number; y: number }[] = []
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const dot = document.createElement('div')
dot.className = 'dot'
dot.style.left = (x * spacing) + 'px'
dot.style.top = (y * spacing) + 'px'
pattern.appendChild(dot)
dots.push({ el: dot, x: x * spacing, y: y * spacing })
}
}
let mouseX = -1000
let mouseY = -1000
const handleMouseMove = (e: MouseEvent) => {
mouseX = e.clientX
mouseY = e.clientY
}
document.addEventListener('mousemove', handleMouseMove)
let animationId: number
function updateDots() {
const patternRect = pattern.getBoundingClientRect()
dots.forEach(dot => {
const dotX = patternRect.left + dot.x + 5
const dotY = patternRect.top + dot.y + 5
const dist = Math.hypot(mouseX - dotX, mouseY - dotY)
const maxDist = 120
const scale = Math.min(1, Math.max(0.1, dist / maxDist))
dot.el.style.transform = `scale(${scale})`
})
animationId = requestAnimationFrame(updateDots)
}
updateDots()
return () => {
document.removeEventListener('mousemove', handleMouseMove)
cancelAnimationFrame(animationId)
}
})
</script>
<div class="pattern-container">
<div class="pattern" id="dotPattern"></div>
</div>
<div class="pattern-fade"></div>
<nav>
<span class="brand">Tranquil PDS</span>
<span class="nav-meta">0.1.0</span>
</nav>
<div class="home">
<header class="hero">
<h1>Tranquil PDS</h1>
<p class="tagline">A Personal Data Server for the AT Protocol</p>
</header>
<section>
<h2>What is a PDS?</h2>
<p>
Bluesky runs on a federated protocol called AT Protocol. Your account lives on a PDS,
a server that stores your posts, profile, follows, and cryptographic keys. Bluesky hosts
one for you at bsky.social, but you can run your own. Self-hosting means you control your
data; you're not dependent on any company's servers, and your account + data is actually yours.
</p>
<section class="hero">
<h1>A home for your ATProto account</h1>
<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>
<div class="actions">
{#if auth.session}
<a href="#/dashboard" class="btn primary">@{auth.session.handle}</a>
{:else}
<a href="#/register" class="btn primary">Join This Server</a>
<a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">Run Your Own</a>
{/if}
</div>
<blockquote>
<p>"Nature does not hurry, yet everything is accomplished."</p>
<cite>Lao Tzu</cite>
</blockquote>
</section>
<section>
<h2>What's different about Tranquil?</h2>
<p>
This software isn't an afterthought by a company with limited resources.
It is a superset of the reference PDS, including:
</p>
<ul>
<li>Passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices)</li>
<li>did:web support (PDS-hosted subdomains or bring-your-own)</li>
<li>Multi-channel notifications (email, discord, telegram, signal)</li>
<li>Granular OAuth scopes with a consent UI</li>
<li>Built-in web UI for account management, repo browsing, and admin</li>
</ul>
<p>
Full compatibility with Bluesky's reference PDS: same endpoints, same behavior,
same client compatibility. Everything works.
</p>
<section class="content">
<h2>What you get</h2>
<div class="features">
<div class="feature">
<h3>Real security</h3>
<p>Sign in with passkeys, add two-factor authentication, set up backup codes, and mark devices you trust. Your account stays yours.</p>
</div>
<div class="feature">
<h3>Your own identity</h3>
<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>
</div>
<div class="feature">
<h3>Stay in the loop</h3>
<p>Get important alerts where you actually see them: email, Discord, Telegram, or Signal.</p>
</div>
<div class="feature">
<h3>You decide what apps can do</h3>
<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>
</div>
</div>
<h2>Everything in one place</h2>
<p>Manage your profile, security settings, connected apps, and more from a clean dashboard. No command line or 3rd party apps required.</p>
<h2>Works with everything</h2>
<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>
<h2>Ready to try it?</h2>
<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>
<div class="actions">
{#if auth.session}
<a href="#/dashboard" class="btn primary">@{auth.session.handle}</a>
{:else}
<a href="#/register" class="btn primary">Join This Server</a>
<a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">View Source</a>
{/if}
</div>
</section>
<div class="cta">
{#if auth.session}
<a href="#/dashboard" class="btn">@{auth.session.handle}</a>
{:else}
<a href="#/login" class="btn">{$_('login.button')}</a>
<a href="#/register" class="btn secondary">{$_('login.createAccount')}</a>
{/if}
</div>
<footer>
<a href="https://tangled.org/lewis.moe/bspds-sandbox" target="_blank" rel="noopener">Source code</a>
<footer class="site-footer">
<span>Open Source</span>
<span>Made with care</span>
</footer>
</div>
<style>
.pattern-container {
position: fixed;
top: -32px;
left: -32px;
right: -32px;
bottom: -32px;
pointer-events: none;
z-index: 1;
overflow: hidden;
}
.pattern {
position: absolute;
top: 0;
left: 0;
width: calc(100% + 500px);
height: 100%;
animation: drift 80s linear infinite;
}
.pattern :global(.dot) {
position: absolute;
width: 10px;
height: 10px;
background: rgba(0, 0, 0, 0.06);
border-radius: 50%;
transition: transform 0.04s linear;
}
@media (prefers-color-scheme: dark) {
.pattern :global(.dot) {
background: rgba(255, 255, 255, 0.1);
}
}
.pattern-fade {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, transparent 50%, var(--bg-primary) 75%);
pointer-events: none;
z-index: 2;
}
@keyframes drift {
0% { transform: translateX(-500px); }
100% { transform: translateX(0); }
}
nav {
position: fixed;
top: 12px;
left: 32px;
right: 32px;
background: var(--accent);
padding: 10px 18px;
z-index: 100;
border-radius: var(--radius-xl);
display: flex;
justify-content: space-between;
align-items: center;
}
.brand {
font-weight: var(--font-semibold);
font-size: var(--text-base);
letter-spacing: 0.08em;
color: var(--text-inverse);
text-transform: uppercase;
}
.nav-meta {
font-size: var(--text-sm);
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.05em;
}
.home {
max-width: var(--width-md);
position: relative;
z-index: 10;
max-width: var(--width-xl);
margin: 0 auto;
padding: var(--space-7);
padding: 72px 32px 32px;
}
.hero {
text-align: center;
padding: var(--space-7) 0 var(--space-8);
border-bottom: 1px solid var(--border-color);
margin-bottom: var(--space-8);
padding-top: var(--space-7);
}
.hero h1 {
h1 {
font-size: var(--text-4xl);
margin-bottom: var(--space-3);
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
margin-bottom: var(--space-6);
letter-spacing: -0.02em;
}
.tagline {
color: var(--text-secondary);
.lede {
font-size: var(--text-xl);
}
section {
margin-bottom: var(--space-7);
}
h2 {
margin-bottom: var(--space-4);
}
p {
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
ul {
color: var(--text-secondary);
margin: 0 0 var(--space-4) 0;
padding-left: var(--space-6);
font-weight: var(--font-medium);
color: var(--text-primary);
line-height: var(--leading-relaxed);
margin-bottom: 0;
}
li {
margin-bottom: var(--space-2);
}
.cta {
.actions {
display: flex;
gap: var(--space-4);
justify-content: center;
margin: var(--space-8) 0;
margin-top: var(--space-7);
}
.btn {
display: inline-block;
padding: var(--space-4) var(--space-7);
border-radius: var(--radius-md);
font-size: var(--text-base);
font-size: var(--text-sm);
font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: var(--space-4) var(--space-6);
border-radius: var(--radius-lg);
text-decoration: none;
transition: background var(--transition-normal), border-color var(--transition-normal);
background: var(--accent);
color: var(--text-inverse);
transition: all var(--transition-normal);
border: 1px solid transparent;
}
.btn:hover {
background: var(--accent-hover);
text-decoration: none;
.btn.primary {
background: var(--secondary);
color: var(--text-inverse);
border-color: var(--secondary);
}
.btn.primary:hover {
background: var(--secondary-hover);
border-color: var(--secondary-hover);
}
.btn.secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
color: var(--text-primary);
border-color: var(--border-color);
}
.btn.secondary:hover {
background: var(--accent);
color: var(--text-inverse);
background: var(--secondary-muted);
border-color: var(--secondary);
color: var(--secondary);
}
footer {
text-align: center;
blockquote {
margin: var(--space-8) 0 0 0;
padding: var(--space-6);
background: var(--accent-muted);
border-left: 3px solid var(--accent);
border-radius: 0 var(--radius-xl) var(--radius-xl) 0;
}
blockquote p {
font-size: var(--text-lg);
color: var(--text-primary);
font-style: italic;
margin-bottom: var(--space-3);
}
blockquote cite {
font-size: var(--text-sm);
color: var(--text-secondary);
font-style: normal;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.content h2 {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin: var(--space-8) 0 var(--space-5);
}
.content h2:first-child {
margin-top: 0;
}
.content > p {
font-size: var(--text-base);
color: var(--text-secondary);
margin-bottom: var(--space-5);
line-height: var(--leading-relaxed);
}
.features {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-6);
margin: var(--space-6) 0 var(--space-8);
}
.feature {
padding: var(--space-5);
background: var(--bg-secondary);
border-radius: var(--radius-xl);
border: 1px solid var(--border-color);
}
.feature h3 {
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin-bottom: var(--space-3);
}
.feature p {
font-size: var(--text-sm);
color: var(--text-secondary);
margin: 0;
line-height: var(--leading-relaxed);
}
@media (max-width: 700px) {
.features {
grid-template-columns: 1fr;
}
h1 {
font-size: var(--text-3xl);
}
.actions {
flex-direction: column;
}
.btn {
text-align: center;
}
}
.site-footer {
margin-top: var(--space-9);
padding-top: var(--space-7);
display: flex;
justify-content: space-between;
font-size: var(--text-sm);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-top: 1px solid var(--border-color);
}
footer a {
color: var(--text-muted);
font-size: var(--text-sm);
}
footer a:hover {
color: var(--accent);
}
</style>

View File

@@ -8,7 +8,7 @@
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--text-primary);
@@ -32,12 +32,17 @@ p {
}
a {
color: var(--accent);
color: var(--secondary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
color: var(--secondary-hover);
text-decoration: none;
}
::selection {
background: var(--secondary-muted);
}
input,
@@ -171,7 +176,7 @@ fieldset legend {
}
code {
font-family: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', monospace;
font-family: inherit;
font-size: 0.9em;
background: var(--bg-tertiary);
padding: var(--space-1) var(--space-2);
@@ -179,7 +184,7 @@ code {
}
pre {
font-family: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', monospace;
font-family: inherit;
font-size: var(--text-sm);
background: var(--bg-tertiary);
padding: var(--space-4);
@@ -338,7 +343,7 @@ hr {
}
.mono {
font-family: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', monospace;
font-family: inherit;
}
.mt-4 { margin-top: var(--space-4); }

View File

@@ -48,25 +48,30 @@
--transition-normal: 0.15s ease;
--transition-slow: 0.25s ease;
--bg-primary: #fafafa;
--bg-secondary: #f5f5f5;
--bg-tertiary: #eeeeee;
--bg-primary: #ffffff;
--bg-secondary: #f8f8fa;
--bg-tertiary: #f0f0f2;
--bg-card: #ffffff;
--bg-input: #ffffff;
--bg-input-disabled: #f5f5f5;
--bg-input-disabled: #f8f8fa;
--text-primary: #333333;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #999999;
--text-inverse: #ffffff;
--border-color: #dddddd;
--border-light: #eeeeee;
--border-color: #e5e5e5;
--border-light: #f0f0f0;
--border-dark: #cccccc;
--accent: #0066cc;
--accent-hover: #0052a3;
--accent-muted: rgba(0, 102, 204, 0.15);
--accent: #2c00ff;
--accent-hover: #1a00a3;
--accent-muted: rgba(44, 0, 255, 0.08);
--accent-light: #4d33ff;
--secondary: #ff2400;
--secondary-hover: #cc1d00;
--secondary-muted: rgba(255, 36, 0, 0.08);
--success-bg: #dfd;
--success-border: #8c8;
@@ -85,25 +90,30 @@
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #222222;
--bg-tertiary: #2a2a2a;
--bg-card: #2a2a2a;
--bg-input: #333333;
--bg-input-disabled: #2a2a2a;
--bg-primary: #0a0a0a;
--bg-secondary: #141414;
--bg-tertiary: #1a1a1a;
--bg-card: #141414;
--bg-input: #1a1a1a;
--bg-input-disabled: #141414;
--text-primary: #e0e0e0;
--text-primary: #e8e8e8;
--text-secondary: #a0a0a0;
--text-muted: #707070;
--text-inverse: #1a1a1a;
--text-muted: #666666;
--text-inverse: #0a0a0a;
--border-color: #404040;
--border-light: #333333;
--border-dark: #505050;
--border-color: #2a2a2a;
--border-light: #222222;
--border-dark: #333333;
--accent: #4da6ff;
--accent-hover: #7abbff;
--accent-muted: rgba(77, 166, 255, 0.2);
--accent: #2c00ff;
--accent-hover: #4d33ff;
--accent-muted: rgba(44, 0, 255, 0.15);
--accent-light: #4d33ff;
--secondary: #ff2400;
--secondary-hover: #ff5533;
--secondary-muted: rgba(255, 36, 0, 0.15);
--success-bg: #1a3d1a;
--success-border: #2d5a2d;

View File

@@ -45,14 +45,14 @@ db-reset:
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pds" sqlx database drop -y
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pds" sqlx database create
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pds" sqlx migrate run
docker-up:
docker compose up -d
docker-down:
docker compose down
docker-logs:
docker compose logs -f
docker-build:
docker compose build
podman-up:
podman compose up -d
podman-down:
podman compose down
podman-logs:
podman compose logs -f
podman-build:
podman compose build
# Frontend commands (Deno)
frontend-dev:
. ~/.deno/env && cd frontend && deno task dev

View File

@@ -88,7 +88,8 @@ impl ClientMetadataCache {
fn is_loopback_client(client_id: &str) -> bool {
if let Ok(url) = reqwest::Url::parse(client_id) {
url.scheme() == "http" && url.host_str() == Some("localhost") && url.port().is_none()
url.scheme() == "http"
&& matches!(url.host_str(), Some("localhost") | Some("127.0.0.1"))
} else {
false
}
@@ -310,19 +311,7 @@ impl ClientMetadataCache {
let is_loopback_redirect = req_url.scheme() == "http"
&& (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]");
if is_loopback_redirect {
for registered in &metadata.redirect_uris {
if let Ok(reg_url) = reqwest::Url::parse(registered) {
let reg_host = reg_url.host_str().unwrap_or("");
let hosts_match = (req_host == "localhost" && reg_host == "localhost")
|| (req_host == "127.0.0.1" && reg_host == "127.0.0.1")
|| (req_host == "[::1]" && reg_host == "[::1]")
|| (req_host == "localhost" && reg_host == "127.0.0.1")
|| (req_host == "127.0.0.1" && reg_host == "localhost");
if hosts_match && req_url.path() == reg_url.path() {
return Ok(());
}
}
}
return Ok(());
}
}
Err(OAuthError::InvalidRequest(