fix: BUG-33 CPU threshold, TASK-27 tab icons, TASK-36 iframe errors
- BUG-33: CPU load alert threshold increased from 2x to 4x core count (8→16 on 4-core machine) to reduce false alerts during container ops - TASK-27: Launch buttons for new-tab apps now show external link icon (BTCPay, Grafana, PhotoPrism, Portainer, OnlyOffice, etc.) - TASK-36: Iframe error screen now distinguishes between X-Frame-Options blocked vs container not reachable, with appropriate messaging Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1ffc377a9c
commit
25ad68ac4c
+642
-66
@@ -6,33 +6,39 @@
|
||||
<title>Archipelago — Architecture Review & Learning Guide</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--surface: #12121a;
|
||||
--surface-2: #1a1a26;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--border-bright: rgba(255,255,255,0.15);
|
||||
--text: rgba(255,255,255,0.88);
|
||||
--text-muted: rgba(255,255,255,0.55);
|
||||
--bg: #000000;
|
||||
--glass-card: rgba(0, 0, 0, 0.65);
|
||||
--glass-dark: rgba(0, 0, 0, 0.35);
|
||||
--glass-darker: rgba(0, 0, 0, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.18);
|
||||
--glass-highlight: rgba(255, 255, 255, 0.22);
|
||||
--glass-blur: 18px;
|
||||
--glass-blur-strong: 24px;
|
||||
--shadow-glass: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow-glass-inset: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
--text: rgba(255, 255, 255, 0.9);
|
||||
--text-muted: rgba(255, 255, 255, 0.6);
|
||||
--accent: #fb923c;
|
||||
--accent-dim: rgba(251,146,60,0.15);
|
||||
--accent-dim: rgba(251, 146, 60, 0.15);
|
||||
--green: #4ade80;
|
||||
--green-dim: rgba(74,222,128,0.12);
|
||||
--green-dim: rgba(74, 222, 128, 0.15);
|
||||
--red: #ef4444;
|
||||
--red-dim: rgba(239,68,68,0.12);
|
||||
--red-dim: rgba(239, 68, 68, 0.12);
|
||||
--blue: #3b82f6;
|
||||
--blue-dim: rgba(59,130,246,0.12);
|
||||
--blue-dim: rgba(59, 130, 246, 0.12);
|
||||
--yellow: #facc15;
|
||||
--yellow-dim: rgba(250,204,21,0.12);
|
||||
--yellow-dim: rgba(250, 204, 21, 0.12);
|
||||
--purple: #a78bfa;
|
||||
--purple-dim: rgba(167,139,250,0.12);
|
||||
--glass: rgba(255,255,255,0.04);
|
||||
--radius: 12px;
|
||||
--purple-dim: rgba(167, 139, 250, 0.12);
|
||||
--radius: 16px;
|
||||
--radius-sm: 12px;
|
||||
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-family: 'Avenir Next', system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.7;
|
||||
@@ -46,22 +52,25 @@
|
||||
left: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur-strong));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||
border-right: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-glass);
|
||||
overflow-y: auto;
|
||||
padding: 24px 0;
|
||||
z-index: 100;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-bright) transparent;
|
||||
scrollbar-color: rgba(255,255,255,0.15) transparent;
|
||||
}
|
||||
|
||||
nav .logo {
|
||||
padding: 0 24px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
nav .logo h1 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
@@ -89,13 +98,13 @@
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition);
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
nav a:hover, nav a.active {
|
||||
color: var(--text);
|
||||
background: var(--glass);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -107,18 +116,19 @@
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 64px 0 8px;
|
||||
padding-top: 24px;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
h2:first-of-type { border-top: none; margin-top: 0; }
|
||||
h2:first-of-type { margin-top: 0; }
|
||||
|
||||
h3 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 40px 0 12px;
|
||||
@@ -148,6 +158,7 @@
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 42px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--accent), #f59e0b);
|
||||
@@ -177,14 +188,20 @@
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-bright);
|
||||
background: var(--glass-dark);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── Cards ─── */
|
||||
/* ─── Cards (Glass) ─── */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin: 16px 0;
|
||||
@@ -198,10 +215,19 @@
|
||||
}
|
||||
|
||||
.card-sm {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--glass-darker);
|
||||
backdrop-filter: blur(var(--glass-blur-strong));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
transition: transform var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.card-sm:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.card-sm h4 { margin: 0 0 6px; font-size: 14px; }
|
||||
@@ -228,50 +254,62 @@
|
||||
/* ─── Tables ─── */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-glass);
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
background: var(--surface-2);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:hover td { background: var(--glass); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(255, 255, 255, 0.04); }
|
||||
|
||||
/* ─── Code ─── */
|
||||
code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
background: var(--surface-2);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
margin: 16px 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
box-shadow: var(--shadow-glass);
|
||||
}
|
||||
|
||||
pre code {
|
||||
@@ -287,13 +325,16 @@
|
||||
|
||||
/* ─── Diagrams (ASCII art in pre) ─── */
|
||||
.diagram {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-bright);
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur-strong));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin: 20px 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Menlo', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
@@ -307,18 +348,23 @@
|
||||
|
||||
/* ─── Callouts ─── */
|
||||
.callout {
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 16px 20px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
border-left: 3px solid;
|
||||
box-shadow: var(--shadow-glass);
|
||||
}
|
||||
|
||||
.callout-info { background: var(--blue-dim); border-color: var(--blue); }
|
||||
.callout-warn { background: var(--yellow-dim); border-color: var(--yellow); }
|
||||
.callout-danger { background: var(--red-dim); border-color: var(--red); }
|
||||
.callout-success { background: var(--green-dim); border-color: var(--green); }
|
||||
.callout-learn { background: var(--purple-dim); border-color: var(--purple); }
|
||||
.callout-info { border-color: var(--blue); }
|
||||
.callout-warn { border-color: var(--yellow); }
|
||||
.callout-danger { border-color: var(--red); }
|
||||
.callout-success { border-color: var(--green); }
|
||||
.callout-learn { border-color: var(--purple); }
|
||||
|
||||
.callout strong { display: block; margin-bottom: 4px; }
|
||||
|
||||
@@ -331,14 +377,21 @@
|
||||
}
|
||||
|
||||
.score-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--glass-darker);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
.score-card:hover { transform: translateY(-2px); }
|
||||
|
||||
.score-card .score {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
margin: 4px 0;
|
||||
@@ -362,11 +415,14 @@
|
||||
|
||||
/* ─── Analogy boxes ─── */
|
||||
.analogy {
|
||||
background: var(--purple-dim);
|
||||
border: 1px solid rgba(167,139,250,0.2);
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid rgba(167, 139, 250, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 20px;
|
||||
margin: 16px 0;
|
||||
box-shadow: var(--shadow-glass);
|
||||
}
|
||||
|
||||
.analogy::before {
|
||||
@@ -397,7 +453,7 @@
|
||||
/* ─── Separator ─── */
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
@@ -425,6 +481,139 @@
|
||||
.flow-step .content { flex: 1; }
|
||||
.flow-step .content strong { color: var(--accent); }
|
||||
.flow-step .content p { margin: 0; font-size: 14px; }
|
||||
|
||||
/* ─── Comparison helpers ─── */
|
||||
.check { color: var(--green); font-weight: 700; }
|
||||
.cross { color: var(--red); opacity: 0.7; }
|
||||
.partial { color: var(--yellow); }
|
||||
|
||||
td.archy-col { background: var(--accent-dim); }
|
||||
th.archy-col { color: var(--accent); background: var(--accent-dim); }
|
||||
|
||||
.card-sm .item-list { margin: 0; padding-left: 16px; }
|
||||
.card-sm .item-list li { font-size: 13px; margin: 4px 0; color: var(--text-muted); }
|
||||
|
||||
/* ─── Theme toggle ─── */
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: 16px 16px 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-toggle .icon { font-size: 14px; }
|
||||
|
||||
/* ─── Light mode ─── */
|
||||
[data-theme="light"] {
|
||||
--bg: #FAFAFA;
|
||||
--glass-card: rgba(255, 255, 255, 0.7);
|
||||
--glass-dark: rgba(255, 255, 255, 0.5);
|
||||
--glass-darker: rgba(255, 255, 255, 0.65);
|
||||
--glass-border: rgba(0, 0, 0, 0.1);
|
||||
--glass-highlight: rgba(255, 255, 255, 0.8);
|
||||
--shadow-glass: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
--shadow-glass-inset: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
--text: rgba(0, 0, 0, 0.88);
|
||||
--text-muted: rgba(0, 0, 0, 0.5);
|
||||
--accent: #ea7c1f;
|
||||
--accent-dim: rgba(234, 124, 31, 0.1);
|
||||
--green: #16a34a;
|
||||
--green-dim: rgba(22, 163, 74, 0.1);
|
||||
--red: #dc2626;
|
||||
--red-dim: rgba(220, 38, 38, 0.08);
|
||||
--blue: #2563eb;
|
||||
--blue-dim: rgba(37, 99, 235, 0.08);
|
||||
--yellow: #ca8a04;
|
||||
--yellow-dim: rgba(202, 138, 4, 0.08);
|
||||
--purple: #7c3aed;
|
||||
--purple-dim: rgba(124, 58, 237, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] body { background: var(--bg); }
|
||||
|
||||
[data-theme="light"] nav {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-right-color: rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 0 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="light"] nav a:hover,
|
||||
[data-theme="light"] nav a.active {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
[data-theme="light"] th {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] td {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="light"] tr:hover td {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
[data-theme="light"] code {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
[data-theme="light"] .hero h1 {
|
||||
background: linear-gradient(135deg, #c2410c, #ea580c);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
[data-theme="light"] .hero .meta span {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
[data-theme="light"] .analogy {
|
||||
border-color: rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-toggle {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-toggle:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .diagram { color: rgba(0, 0, 0, 0.5); }
|
||||
|
||||
[data-theme="light"] hr { border-top-color: rgba(0, 0, 0, 0.08); }
|
||||
|
||||
[data-theme="light"] .score-card .score { filter: none; }
|
||||
|
||||
/* ─── Reduce motion ─── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -436,6 +625,11 @@
|
||||
<p>Architecture Review & Guide</p>
|
||||
</div>
|
||||
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle light/dark mode">
|
||||
<span class="icon" id="theme-icon">☾</span>
|
||||
<span id="theme-label">Light mode</span>
|
||||
</button>
|
||||
|
||||
<div class="nav-section">Overview</div>
|
||||
<a href="#what-is-it">What Is Archipelago?</a>
|
||||
<a href="#big-picture">The Big Picture</a>
|
||||
@@ -794,25 +988,375 @@ Body: { <span class="string">"method"</span>: <span class="string">"package.inst
|
||||
|
||||
<h3 id="nginx">Layer 4: Nginx (The Traffic Cop)</h3>
|
||||
|
||||
<p><strong>Nginx</strong> (pronounced "engine-X") is a web server that sits between the internet and everything else. Every single request goes through it first.</p>
|
||||
<p><strong>Nginx</strong> (pronounced "engine-X") is a web server that sits between the internet and everything else. Every single request goes through it first. Archipelago's nginx config is ~1,100 lines — one of the most complex parts of the system.</p>
|
||||
|
||||
<div class="analogy">
|
||||
<p>Nginx is like the receptionist at a hospital. You walk in and say what you need. "I need the API" — they send you to the Rust backend. "I need the Bitcoin app" — they send you to the Bitcoin container. "I need the website" — they hand you the static files. Without the receptionist, you'd be wandering the hallways lost.</p>
|
||||
</div>
|
||||
|
||||
<h4>How Nginx routes traffic</h4>
|
||||
<!-- ─── Comparison: Why Nginx? ─── -->
|
||||
<h4>Why Nginx? Comparing Reverse Proxies</h4>
|
||||
|
||||
<p>Every node OS needs a reverse proxy to route traffic. Here's how the major projects differ:</p>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="card-sm" style="border-color: var(--accent); border-width: 2px;">
|
||||
<h4>Nginx <span class="badge badge-accent">Archipelago</span></h4>
|
||||
<p><span class="check">✓</span> Battle-tested (30+ years)<br>
|
||||
<span class="check">✓</span> Sub-millisecond routing<br>
|
||||
<span class="check">✓</span> Fine-grained rate limiting<br>
|
||||
<span class="check">✓</span> sub_filter HTML rewriting<br>
|
||||
<span class="check">✓</span> Full CSP / HSTS control<br>
|
||||
<span class="partial">~</span> Manual config (1,100 lines)<br>
|
||||
<span class="cross">✗</span> No auto-TLS (manual certs)</p>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>Caddy <span class="badge badge-blue">Umbrel</span></h4>
|
||||
<p><span class="check">✓</span> Automatic HTTPS / Let's Encrypt<br>
|
||||
<span class="check">✓</span> Simple Caddyfile syntax<br>
|
||||
<span class="check">✓</span> Built-in HTTP/3 support<br>
|
||||
<span class="cross">✗</span> No sub_filter (needs plugins)<br>
|
||||
<span class="cross">✗</span> Higher memory footprint<br>
|
||||
<span class="cross">✗</span> Less granular rate limiting<br>
|
||||
<span class="partial">~</span> Newer, smaller ecosystem</p>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>Tor-only <span class="badge badge-purple">StartOS</span></h4>
|
||||
<p><span class="check">✓</span> Maximum privacy (no clearnet)<br>
|
||||
<span class="check">✓</span> No port forwarding needed<br>
|
||||
<span class="check">✓</span> Built-in NAT traversal<br>
|
||||
<span class="cross">✗</span> Slow (500ms–3s latency)<br>
|
||||
<span class="cross">✗</span> No LAN access without config<br>
|
||||
<span class="cross">✗</span> Requires .onion browser support<br>
|
||||
<span class="cross">✗</span> No WebSocket over Tor (flaky)</p>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>NixOS Module <span class="badge badge-green">Nix-Bitcoin</span></h4>
|
||||
<p><span class="check">✓</span> Declarative, reproducible<br>
|
||||
<span class="check">✓</span> Atomic rollbacks<br>
|
||||
<span class="check">✓</span> Any proxy (Nginx/Caddy/HAProxy)<br>
|
||||
<span class="partial">~</span> Steep learning curve (Nix lang)<br>
|
||||
<span class="cross">✗</span> No web UI (CLI only)<br>
|
||||
<span class="cross">✗</span> Not beginner-friendly<br>
|
||||
<span class="cross">✗</span> Long rebuild times</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout callout-info">
|
||||
<strong>Archipelago's choice:</strong> Nginx gives the most control over security headers, rate limiting, and HTML rewriting (injecting Nostr provider scripts into app iframes). The tradeoff is a 1,100-line config instead of a 50-line Caddyfile — but for a Bitcoin node OS, that control is worth it.
|
||||
</div>
|
||||
|
||||
<!-- ─── Full Comparison Table ─── -->
|
||||
<h4>Head-to-Head: Architecture Decisions</h4>
|
||||
|
||||
<table>
|
||||
<tr><th>URL Pattern</th><th>Goes To</th><th>Why</th></tr>
|
||||
<tr><td><code>/rpc/v1</code></td><td>Rust backend (:5678)</td><td>All API calls</td></tr>
|
||||
<tr><td><code>/health</code></td><td>Rust backend (:5678)</td><td>Health checks (no auth needed)</td></tr>
|
||||
<tr><td><code>/app/bitcoin-ui/</code></td><td>Bitcoin container (:8334)</td><td>Bitcoin web interface</td></tr>
|
||||
<tr><td><code>/app/mempool/</code></td><td>Mempool container (:4080)</td><td>Mempool explorer</td></tr>
|
||||
<tr><td><code>/app/filebrowser/</code></td><td>FileBrowser container (:8083)</td><td>File manager</td></tr>
|
||||
<tr><td><code>/aiui/</code></td><td>Static files on disk</td><td>AI chat interface</td></tr>
|
||||
<tr><td><code>/</code> (everything else)</td><td>Vue.js SPA files on disk</td><td>The main dashboard</td></tr>
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th class="archy-col">Archipelago</th>
|
||||
<th>Umbrel</th>
|
||||
<th>StartOS</th>
|
||||
<th>Nix-Bitcoin</th>
|
||||
<th>RaspiBlitz</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reverse Proxy</td>
|
||||
<td class="archy-col"><strong>Nginx</strong></td>
|
||||
<td>Caddy</td>
|
||||
<td>Tor hidden svc</td>
|
||||
<td>Nginx (Nix module)</td>
|
||||
<td>Nginx</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Backend</td>
|
||||
<td class="archy-col"><strong>Rust</strong></td>
|
||||
<td>Node.js + Go</td>
|
||||
<td>Rust (startos)</td>
|
||||
<td>Shell/Nix</td>
|
||||
<td>Shell scripts</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Containers</td>
|
||||
<td class="archy-col"><strong>Rootless Podman</strong></td>
|
||||
<td>Docker (root)</td>
|
||||
<td>Docker (root)</td>
|
||||
<td>None (native pkgs)</td>
|
||||
<td>Docker (root)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>TLS/HTTPS</td>
|
||||
<td class="archy-col"><strong>Self-signed + HSTS</strong></td>
|
||||
<td>Auto (Let's Encrypt)</td>
|
||||
<td>Tor-only (no TLS)</td>
|
||||
<td>Let's Encrypt</td>
|
||||
<td>Self-signed</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rate Limiting</td>
|
||||
<td class="archy-col"><strong>Dual-zone (RPC 20r/s + Auth 3r/s)</strong></td>
|
||||
<td>None</td>
|
||||
<td>None</td>
|
||||
<td>Optional (manual)</td>
|
||||
<td>None</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Security Headers</td>
|
||||
<td class="archy-col"><strong>Full CSP + HSTS + Permissions</strong></td>
|
||||
<td>Basic</td>
|
||||
<td>N/A (Tor)</td>
|
||||
<td>Configurable</td>
|
||||
<td>Minimal</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>App Isolation</td>
|
||||
<td class="archy-col"><strong>Cap-drop, readonly root, non-root UID</strong></td>
|
||||
<td>Docker defaults</td>
|
||||
<td>Docker + sandboxing</td>
|
||||
<td>systemd sandboxing</td>
|
||||
<td>Docker defaults</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>LAN + Remote</td>
|
||||
<td class="archy-col"><strong>LAN + Tailscale + Tor</strong></td>
|
||||
<td>LAN + Tor + Tailscale</td>
|
||||
<td>Tor-only (LAN optional)</td>
|
||||
<td>LAN + WireGuard</td>
|
||||
<td>LAN + Tor</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>WebSocket</td>
|
||||
<td class="archy-col"><strong>Native (24h timeout)</strong></td>
|
||||
<td>Polling + WS</td>
|
||||
<td>SSE over Tor</td>
|
||||
<td>N/A</td>
|
||||
<td>Polling</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>App UI Injection</td>
|
||||
<td class="archy-col"><strong>sub_filter (Nostr NIP-07)</strong></td>
|
||||
<td>None</td>
|
||||
<td>None</td>
|
||||
<td>N/A</td>
|
||||
<td>None</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Nginx also handles <strong>rate limiting</strong> (blocking too many requests), <strong>security headers</strong> (preventing attacks), and <strong>WebSocket upgrades</strong> (for real-time updates).</p>
|
||||
<!-- ─── Routing Map ─── -->
|
||||
<h4>How Nginx Routes Traffic</h4>
|
||||
|
||||
<p>The config defines 30+ <code>location</code> blocks across HTTP (port 80) and HTTPS (port 443). Here are the major routing categories:</p>
|
||||
|
||||
<div class="card">
|
||||
<h4>Backend & API Routes</h4>
|
||||
<table>
|
||||
<tr><th>URL Pattern</th><th>Backend</th><th>Rate Limit</th><th>Timeout</th><th>Purpose</th></tr>
|
||||
<tr><td><code>/rpc/</code></td><td>:5678</td><td>20r/s (burst 40)</td><td>600s</td><td>All RPC API calls (1MB body limit)</td></tr>
|
||||
<tr><td><code>/ws</code></td><td>:5678</td><td>—</td><td>86,400s (24h)</td><td>WebSocket — real-time state updates</td></tr>
|
||||
<tr><td><code>/health</code></td><td>:5678</td><td>—</td><td>default</td><td>Health check (no auth)</td></tr>
|
||||
<tr><td><code>/archipelago/</code></td><td>:5678</td><td>—</td><td>default</td><td>System endpoints</td></tr>
|
||||
<tr><td><code>/content</code></td><td>:5678</td><td>—</td><td>default</td><td>Peer content sharing</td></tr>
|
||||
<tr><td><code>/dwn</code></td><td>:5678</td><td>—</td><td>default</td><td>Decentralized Web Node</td></tr>
|
||||
<tr><td><code>/electrs-status</code></td><td>:5678</td><td>—</td><td>default</td><td>Electrum sync status (CORS enabled)</td></tr>
|
||||
<tr><td><code>/lnd-connect-info</code></td><td>:5678</td><td>—</td><td>default</td><td>LND connection URI (CORS enabled)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h4>App Proxies — 24 Container Apps</h4>
|
||||
<p>Every <code>/app/{id}/</code> route proxies into a container. All share a common pattern: strip the upstream <code>X-Frame-Options</code>, set <code>SAMEORIGIN</code>, inject the Nostr provider script, and forward real IP headers.</p>
|
||||
<table>
|
||||
<tr><th>App</th><th>Port</th><th>Special Config</th></tr>
|
||||
<tr><td><code>bitcoin-ui</code></td><td>8334</td><td>—</td></tr>
|
||||
<tr><td><code>mempool</code></td><td>4080</td><td>300s timeouts</td></tr>
|
||||
<tr><td><code>lnd</code></td><td>8081</td><td>300s timeouts</td></tr>
|
||||
<tr><td><code>electrumx</code></td><td>50002</td><td>—</td></tr>
|
||||
<tr><td><code>btcpay</code></td><td>23000</td><td>—</td></tr>
|
||||
<tr><td><code>fedimint</code></td><td>8175</td><td>300s timeouts</td></tr>
|
||||
<tr><td><code>fedimint-gateway</code></td><td>8176</td><td>300s timeouts</td></tr>
|
||||
<tr><td><code>filebrowser</code></td><td>8083</td><td>10GB uploads, path traversal blocking</td></tr>
|
||||
<tr><td><code>nextcloud</code></td><td>8085</td><td>300s timeouts</td></tr>
|
||||
<tr><td><code>vaultwarden</code></td><td>8082</td><td>—</td></tr>
|
||||
<tr><td><code>immich</code></td><td>2283</td><td>300s timeouts</td></tr>
|
||||
<tr><td><code>jellyfin</code></td><td>8096</td><td>—</td></tr>
|
||||
<tr><td><code>grafana</code></td><td>3000</td><td>—</td></tr>
|
||||
<tr><td><code>portainer</code></td><td>9000</td><td>—</td></tr>
|
||||
<tr><td><code>uptime-kuma</code></td><td>3001</td><td>—</td></tr>
|
||||
<tr><td><code>searxng</code></td><td>8888</td><td>—</td></tr>
|
||||
<tr><td><code>ollama</code></td><td>11434</td><td>—</td></tr>
|
||||
<tr><td><code>indeedhub</code></td><td>7777</td><td>URL rewriting, WS, 30-day asset cache</td></tr>
|
||||
<tr><td><code>homeassistant</code></td><td>8123</td><td>86,400s timeout (persistent)</td></tr>
|
||||
<tr><td><code>penpot</code></td><td>9001</td><td>300s timeouts</td></tr>
|
||||
<tr><td><code>photoprism</code></td><td>2342</td><td>—</td></tr>
|
||||
<tr><td><code>onlyoffice</code></td><td>8044</td><td>—</td></tr>
|
||||
<tr><td><code>endurain</code></td><td>8080</td><td>—</td></tr>
|
||||
<tr><td><code>nginx-proxy-manager</code></td><td>8181</td><td>—</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h4>AIUI Routes (AI Chat Interface)</h4>
|
||||
<p>The AI chat UI has its own set of proxied API backends — all require a valid session cookie or return <code>401</code>.</p>
|
||||
<table>
|
||||
<tr><th>URL Pattern</th><th>Backend</th><th>Timeout</th><th>Purpose</th></tr>
|
||||
<tr><td><code>/aiui/</code></td><td>Static files</td><td>—</td><td>Chat UI (no-cache for HTML)</td></tr>
|
||||
<tr><td><code>/aiui/api/claude/</code></td><td>:3142</td><td>300s read</td><td>Claude proxy (streaming, no buffering)</td></tr>
|
||||
<tr><td><code>/aiui/api/ollama/</code></td><td>:11434</td><td>300s read</td><td>Local Ollama model (streaming)</td></tr>
|
||||
<tr><td><code>/aiui/api/openrouter/</code></td><td>openrouter.ai</td><td>120s</td><td>External AI API (SSL passthrough)</td></tr>
|
||||
<tr><td><code>/aiui/api/web-search</code></td><td>:8888</td><td>30s</td><td>SearXNG search (503 JSON on failure)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ─── Security Headers Comparison ─── -->
|
||||
<h4>Security Headers — How Archipelago Compares</h4>
|
||||
|
||||
<p>Security headers tell the browser what's allowed and what isn't. Here's what each node OS sends:</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Header</th>
|
||||
<th class="archy-col">Archipelago</th>
|
||||
<th>Umbrel</th>
|
||||
<th>StartOS</th>
|
||||
<th>RaspiBlitz</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Content-Security-Policy</td>
|
||||
<td class="archy-col"><span class="badge badge-green">Full</span> self + WS + frame-src</td>
|
||||
<td><span class="badge badge-yellow">Basic</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>HSTS</td>
|
||||
<td class="archy-col"><span class="badge badge-green">1 year</span> + includeSubDomains</td>
|
||||
<td><span class="badge badge-green">Yes</span></td>
|
||||
<td><span class="badge badge-red">N/A</span> (Tor)</td>
|
||||
<td><span class="badge badge-red">No</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>X-Frame-Options</td>
|
||||
<td class="archy-col"><span class="badge badge-green">SAMEORIGIN</span></td>
|
||||
<td><span class="badge badge-yellow">Varies</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>X-Content-Type-Options</td>
|
||||
<td class="archy-col"><span class="badge badge-green">nosniff</span></td>
|
||||
<td><span class="badge badge-green">nosniff</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Permissions-Policy</td>
|
||||
<td class="archy-col"><span class="badge badge-green">All blocked</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Referrer-Policy</td>
|
||||
<td class="archy-col"><span class="badge badge-green">strict-origin</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rate Limiting</td>
|
||||
<td class="archy-col"><span class="badge badge-green">Dual-zone</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
<td><span class="badge badge-red">None</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="callout callout-success">
|
||||
<strong>Archipelago leads on security headers.</strong>
|
||||
Most node OS projects ship with minimal or no HTTP security headers. Archipelago sets a full Content-Security-Policy, HSTS with 1-year max-age, Permissions-Policy blocking camera/microphone/geolocation/payment, and dual-zone rate limiting — defense-in-depth at the proxy layer.
|
||||
</div>
|
||||
|
||||
<!-- ─── Unique Features ─── -->
|
||||
<h4>Unique Nginx Features in Archipelago</h4>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="card-sm">
|
||||
<h4>Nostr NIP-07 Injection</h4>
|
||||
<ul class="item-list">
|
||||
<li>Every app proxy uses <code>sub_filter</code> to inject <code>nostr-provider.js</code> into <code></head></code></li>
|
||||
<li>Gives all container apps <code>window.nostr</code> for signing</li>
|
||||
<li>No other node OS does this — unique to Archipelago</li>
|
||||
<li><code>Accept-Encoding</code> disabled to enable text rewriting</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>Dual Rate Limit Zones</h4>
|
||||
<ul class="item-list">
|
||||
<li><strong>rpc zone:</strong> 20 req/s base, burst of 40 — for API calls</li>
|
||||
<li><strong>auth zone:</strong> 3 req/s — for login/auth endpoints (brute-force protection)</li>
|
||||
<li>Returns HTTP 429 on violation</li>
|
||||
<li>Per-IP tracking with 10MB shared memory zone</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>External Site Proxying</h4>
|
||||
<ul class="item-list">
|
||||
<li><code>/ext/botfights/</code>, <code>/ext/484-kitchen/</code>, etc. proxy external HTTPS sites</li>
|
||||
<li>Strips CORS/COEP/COOP headers for iframe embedding</li>
|
||||
<li>Rewrites <code>href</code>/<code>src</code> attributes to rebase paths</li>
|
||||
<li>Standalone proxy servers on ports 8901–8903</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>FileBrowser Security</h4>
|
||||
<ul class="item-list">
|
||||
<li>Path traversal blocked: <code>/\.\.</code> patterns return 403</li>
|
||||
<li>10GB upload limit (<code>client_max_body_size 10G</code>)</li>
|
||||
<li><code>proxy_request_buffering off</code> for streaming large uploads</li>
|
||||
<li>Separate protection for <code>/api/resources/</code> and <code>/api/raw/</code> paths</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>SSL/TLS Configuration</h4>
|
||||
<ul class="item-list">
|
||||
<li>TLSv1.2 + TLSv1.3 only (no older protocols)</li>
|
||||
<li>Modern cipher suite: ECDHE-ECDSA + ECDHE-RSA with AES-GCM</li>
|
||||
<li>Self-signed certificate at <code>/etc/archipelago/ssl/</code></li>
|
||||
<li>Dual-server setup: port 80 (HTTP) + port 443 (HTTPS)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>IndeedhHub Complexity</h4>
|
||||
<ul class="item-list">
|
||||
<li>Most complex app proxy: URL rewriting, WebSocket, caching</li>
|
||||
<li><code>_next/</code> assets cached 30 days with <code>immutable</code></li>
|
||||
<li>WebSocket at <code>/app/indeedhub/ws/</code> with 24h timeout</li>
|
||||
<li>Rewrites both single and double quoted <code>href</code>/<code>src</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Config Files Map ─── -->
|
||||
<h4>Nginx Config File Map</h4>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<tr><th>File</th><th>Lines</th><th>Purpose</th></tr>
|
||||
<tr><td><code>image-recipe/configs/nginx-archipelago.conf</code></td><td>~1,100</td><td>Production config — HTTP + HTTPS servers, all routing</td></tr>
|
||||
<tr><td><code>image-recipe/configs/snippets/archipelago-https-app-proxies.conf</code></td><td>~400</td><td>HTTPS app proxy blocks (included in main config)</td></tr>
|
||||
<tr><td><code>image-recipe/configs/snippets/archipelago-pwa.conf</code></td><td>~30</td><td>PWA service worker and manifest caching</td></tr>
|
||||
<tr><td><code>image-recipe/configs/external-app-proxies.conf</code></td><td>~200</td><td>External site reverse proxies (BotFights, 484 Kitchen)</td></tr>
|
||||
<tr><td><code>neode-ui/docker/nginx.conf</code></td><td>~60</td><td>Dev Docker config (mock backend on :5959)</td></tr>
|
||||
<tr><td><code>neode-ui/docker/nginx-demo.conf</code></td><td>~80</td><td>Demo mode config (no security, mock backend)</td></tr>
|
||||
<tr><td><code>docker/bitcoin-ui/nginx.conf</code></td><td>~50</td><td>Bitcoin UI container — RPC proxy with CORS</td></tr>
|
||||
<tr><td><code>docker/electrs-ui/nginx.conf</code></td><td>~30</td><td>Electrs UI container — status endpoint</td></tr>
|
||||
<tr><td><code>docker/lnd-ui/nginx.conf</code></td><td>~30</td><td>LND UI container — connect info</td></tr>
|
||||
<tr><td><code>indeedhub/nginx.conf</code></td><td>~200</td><td>IndeedhHub container — MinIO, API, relay, SPA</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="callout callout-learn">
|
||||
<strong>Why so many nginx configs?</strong>
|
||||
There are three layers of nginx: (1) the <strong>main server nginx</strong> that routes all traffic, (2) <strong>per-app container nginx</strong> configs inside some containers (bitcoin-ui, electrs-ui, lnd-ui, indeedhub) that serve their own SPAs and proxy to internal services, and (3) <strong>dev/demo nginx</strong> configs for local development. Changes to app routing require updating BOTH the main config AND the relevant container config.
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════ -->
|
||||
<h2 id="data-flow">How Data Flows Through the System</h2>
|
||||
@@ -1524,5 +2068,37 @@ function updateActiveNav() {
|
||||
window.addEventListener('scroll', updateActiveNav, { passive: true });
|
||||
updateActiveNav();
|
||||
</script>
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const isLight = html.getAttribute('data-theme') === 'light';
|
||||
const next = isLight ? 'dark' : 'light';
|
||||
html.setAttribute('data-theme', next);
|
||||
localStorage.setItem('archy-review-theme', next);
|
||||
updateToggleUI(next);
|
||||
}
|
||||
|
||||
function updateToggleUI(theme) {
|
||||
const icon = document.getElementById('theme-icon');
|
||||
const label = document.getElementById('theme-label');
|
||||
if (theme === 'light') {
|
||||
icon.innerHTML = '☀';
|
||||
label.textContent = 'Dark mode';
|
||||
} else {
|
||||
icon.innerHTML = '☾';
|
||||
label.textContent = 'Light mode';
|
||||
}
|
||||
}
|
||||
|
||||
(function() {
|
||||
const saved = localStorage.getItem('archy-review-theme');
|
||||
const preferred = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
const theme = saved || preferred;
|
||||
if (theme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
updateToggleUI(theme);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user