landing: structured feature groups, single-source comparison table (#11)
Lint / Shell (shellcheck) (push) Successful in 17s
Lint / JS (eslint) (push) Successful in 11s
Lint / No secrets in webhook configs (push) Successful in 10s
Lint / Landing page is rendered (matrix (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 11s
Lint / Python deps (pip-audit) (push) Successful in 54s
Lint / Secret scan (gitleaks) (push) Successful in 13s

- The ~1,800-word fork paragraph becomes 7 titled groups, one short line
  per feature, plus a link to LOTUS_FEATURES.md.
- Single source: landing/data/features.json + comparison.json, rendered
  into index.html by landing/build.py between BEGIN/END markers (all 71
  table rows re-render with identical cell text).
- CSS moved to landing/style.css; build.py inlines it because the LXC 139
  deploy copies index.html only.
- "Client comparison last reviewed" stamp driven by comparison.json.
- CI job `landing-fresh` fails if index.html drifts from its sources.
- Feature list brought up to date: 629 avatar decorations in 28
  categories, mic level meter + remembered per-person volume, search
  operators, paste-as-code, thread "Mark all read", pinch-zoom/swipe media
  viewer, room widgets with a permission prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-26 15:11:50 -04:00
co-authored by Claude Opus 5.5
parent e28256ab3a
commit f0017f4d5a
6 changed files with 2700 additions and 19 deletions
+12
View File
@@ -46,6 +46,18 @@ jobs:
exit 1 exit 1
fi fi
landing-fresh:
name: Landing page is rendered (matrix #11)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# index.html's feature groups, comparison table and inlined CSS are
# generated from landing/data/*.json + landing/style.css. Fail if someone
# edited the data without re-running the renderer (or edited the output).
- name: Check landing/index.html matches its sources
run: python3 landing/build.py --check
python-lint: python-lint:
name: Python (ruff) name: Python (ruff)
runs-on: ubuntu-latest runs-on: ubuntu-latest
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Render the data-driven sections of landing/index.html.
The fork feature list and the client comparison table used to be edited by
hand in two places per feature (matrix #11). They now live in landing/data/:
features.json - the "our fork adds" groups, one short line per feature
comparison.json - the client comparison table + its "reviewed" date
and the stylesheet lives in landing/style.css. It is inlined into index.html
rather than linked, because the LXC 139 deploy copies index.html only.
Run `python3 landing/build.py` after editing either file and commit the
regenerated index.html (LXC 139 serves the files as-is; there is no build step
there). `--check` exits 1 if index.html is out of date, for CI.
Only the text between `<!-- BEGIN:name -->` and `<!-- END:name -->` markers is
rewritten; everything else in index.html stays hand-edited.
"""
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
INDEX = ROOT / "index.html"
MARK = {"yes": "✓", "no": "✗", "part": "~"}
def render_features(groups):
out = ['<div class="fork-features">']
for g in groups:
out.append(' <div class="fork-group">')
out.append(f' <h4>{g["title"]}</h4>')
out.append(" <ul>")
out.extend(f" <li>{item}</li>" for item in g["items"])
out.append(" </ul>")
out.append(" </div>")
out.append("</div>")
return "\n".join(out)
def render_cell(cell, ours):
cls = ' class="ours"' if ours else ""
if "mark" in cell:
inner = f'<span class="{cell["mark"]}">{MARK[cell["mark"]]}</span>'
else:
inner = cell["text"]
if cell.get("note"):
inner += f'<small>{cell["note"]}</small>'
return f"<td{cls}>{inner}</td>"
def render_comparison(data):
clients = data["clients"]
cols = len(clients) + 1
out = ["<table>", " <thead>", " <tr>", " <th></th>"]
for i, c in enumerate(clients):
cls = ' class="ours"' if i == 0 else ""
sub = f'<small>{c["sub"]}</small>' if c["sub"] else ""
out.append(f' <th{cls}>{c["name"]}{sub}</th>')
out += [" </tr>", " </thead>", " <tbody>"]
for sec in data["sections"]:
out.append(f' <tr class="section-header"><td colspan="{cols}">{sec["title"]}</td></tr>')
for row in sec["rows"]:
if len(row["cells"]) != len(clients):
sys.exit(f'comparison.json: "{row["feature"]}" has {len(row["cells"])} cells, expected {len(clients)}')
out.append(" <tr>")
out.append(f' <td>{row["feature"]}</td>')
out.extend(f" {render_cell(c, i == 0)}" for i, c in enumerate(row["cells"]))
out.append(" </tr>")
out += [" </tbody>", "</table>"]
return "\n".join(out)
def replace_block(html, name, content):
pattern = re.compile(
rf"(?P<indent>[ \t]*)<!-- BEGIN:{name} -->.*?<!-- END:{name} -->", re.S
)
m = pattern.search(html)
if not m:
sys.exit(f"index.html: missing <!-- BEGIN:{name} --> / <!-- END:{name} --> markers")
indent = m.group("indent")
body = "\n".join(indent + line if line else line for line in content.split("\n"))
block = f"{indent}<!-- BEGIN:{name} -->\n{body}\n{indent}<!-- END:{name} -->"
return html[: m.start()] + block + html[m.end() :]
def main():
features = json.loads((ROOT / "data" / "features.json").read_text(encoding="utf-8"))
comparison = json.loads((ROOT / "data" / "comparison.json").read_text(encoding="utf-8"))
css = (ROOT / "style.css").read_text(encoding="utf-8").rstrip("\n")
html = INDEX.read_text(encoding="utf-8")
styles = "<style>\n" + "\n".join(" " + line if line else line for line in css.split("\n")) + "\n</style>"
new = replace_block(html, "styles", styles)
new = replace_block(new, "features", render_features(features))
new = replace_block(new, "comparison", render_comparison(comparison))
new = replace_block(
new, "reviewed", f'Client comparison last reviewed <time datetime="{comparison["reviewed"]}">{comparison["reviewed"]}</time>'
)
if "--check" in sys.argv:
if new != html:
sys.exit("landing/index.html is out of date: run python3 landing/build.py")
print("landing/index.html is up to date")
return
INDEX.write_text(new, encoding="utf-8")
print("wrote", INDEX)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
[
{
"title": "Calls &amp; Voice",
"items": [
"Voice rooms with our own Element Call fork on a self-hosted LiveKit SFU",
"Push-to-talk and push-to-deafen, with system-wide hotkeys on the Windows app (work while a game has focus)",
"Mic level meter on the mute button; per-person call volume that's remembered between calls",
"On-device noise suppression: Off, browser-native, or ML (RNNoise, Speex, DTLN, DeepFilterNet&nbsp;3)",
"In-call soundboard with your own clips, synced across your devices",
"Incoming-call ring with Answer/Decline, join/leave sounds, draggable picture-in-picture window",
"Screenshare fullscreen and audio mute; per-user mic/screenshare bitrate and framerate caps",
"Server-enforced room limits and permissions (max participants, audio-only, no screenshare) for every Matrix client",
"AFK auto-mute after a configurable idle time"
]
},
{
"title": "Messaging",
"items": [
"Threads with a side panel, unread chips, &ldquo;Mark all read&rdquo; and per-thread notification overrides",
"Search operators: <code>from:</code>, <code>in:</code>, <code>before:</code>/<code>after:</code> (dates or <code>7d</code>), <code>has:link|image|video|file</code>, <code>is:pinned</code>",
"Voice messages with 0.75&times;&ndash;2&times; playback (MSC3245, E2EE)",
"Polls, message scheduling (MSC4140), forwarding, captions and location sharing",
"Pinned messages, saved messages/bookmarks and private notes on people, synced across devices",
"Who-reacted viewer, read receipt avatars, quick reactions on hover",
"GIF picker (Giphy), custom emoji &amp; sticker packs, math/LaTeX (KaTeX)",
"Pasted code is offered as a formatted code block, with the language detected",
"On-device message translation: text never leaves your machine, works in encrypted rooms"
]
},
{
"title": "Media &amp; Links",
"items": [
"Click-to-play inline embeds: YouTube, Vimeo, Twitch, TikTok, X, Instagram, Reddit, Bluesky, Spotify, SoundCloud, Apple Music, Tidal and more",
"Rich link cards for GitHub, Steam, Wikipedia, IMDb, npm and others; animated GIF previews",
"Full-screen viewer with pinch-zoom, swipe/arrow-key navigation and captions; media gallery for every file shared in a room",
"Optional image compression, folder drag-and-drop upload"
]
},
{
"title": "Privacy &amp; Security",
"items": [
"Private read receipts, hide typing, hide online status",
"Tracking-parameter stripping (utm_*, fbclid, YouTube si= and ~40 more), on your device",
"Opt-in on-device search index for encrypted rooms, wiped on logout",
"Device verification fixes (cross-client emoji SAS) and a per-member session panel"
]
},
{
"title": "Look &amp; Feel",
"items": [
"Lotus Terminal theme, night-light filter, glassmorphism sidebar",
"Animated chat backgrounds and 11 seasonal overlays (all respect reduced motion)",
"629 animated avatar decorations in 28 categories, visible to other Lotus users (MSC4133)",
"Custom status with emoji and auto-clear, Discord-style presence rings",
"Settings sync across devices (theme, layout, notification and privacy preferences)"
]
},
{
"title": "Desktop App",
"items": [
"Windows, macOS and Linux (Tauri)",
"Rich Windows notifications with inline reply; Focus Assist sync",
"Taskbar call controls, jump list and upload progress; tray with call status",
"Stays awake during calls, reconnects on network changes, updates itself"
]
},
{
"title": "Rooms &amp; Moderation",
"items": [
"Knock-to-join with an admin approve/deny panel and live pending-count badge",
"Invite links with QR codes, favourites, room filter, room history export (txt/json/html)",
"Room activity &amp; mod log, stats panel, server ACL editor, policy-list viewer",
"Room widgets, with a permission prompt before a widget may read or send in the room"
]
}
]
+112 -19
View File
File diff suppressed because one or more lines are too long
+520
View File
@@ -0,0 +1,520 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
color: #e0e0e0;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px 16px 40px;
}
body::before {
content: '';
position: fixed;
top: 50%;
left: 50%;
width: 900px;
height: 900px;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(152, 0, 0, 0.07) 0%, transparent 65%);
pointer-events: none;
z-index: 0;
}
.container {
position: relative;
z-index: 1;
text-align: center;
width: 100%;
max-width: 900px;
}
.logo {
width: 140px;
height: 140px;
margin: 0 auto 28px;
border-radius: 50%;
filter: drop-shadow(0 0 24px rgba(152, 0, 0, 0.35));
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
h1 {
font-size: 2rem;
font-weight: 300;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #fff;
margin-bottom: 4px;
}
h1 span { color: #980000; font-weight: 600; }
.subtitle {
font-size: 0.85rem;
color: #555;
letter-spacing: 0.3em;
text-transform: uppercase;
margin-bottom: 36px;
}
/* ─── Cards / Panels ─── */
.card {
background: linear-gradient(145deg, rgba(22,22,22,0.95), rgba(14,14,14,0.98));
border: 1px solid rgba(152,0,0,0.2);
border-radius: 16px;
padding: 32px;
max-width: 560px;
margin: 0 auto;
}
.card h2 {
font-size: 0.9rem;
font-weight: 500;
color: #980000;
text-transform: uppercase;
letter-spacing: 0.15em;
margin-bottom: 20px;
}
/* ─── Steps ─── */
.steps { list-style: none; text-align: left; margin-bottom: 28px; }
.steps li {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.steps li:last-child { border-bottom: none; }
.step-num {
flex-shrink: 0;
width: 28px; height: 28px;
background: rgba(152,0,0,0.12);
border: 1px solid rgba(152,0,0,0.35);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.8rem; font-weight: 600; color: #c44;
}
.step-text { padding-top: 3px; font-size: 0.95rem; line-height: 1.5; color: #bbb; }
.step-text strong { color: #e0e0e0; }
.homeserver {
display: inline-block;
background: rgba(152,0,0,0.1);
border: 1px solid rgba(152,0,0,0.25);
color: #e88;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.85rem;
padding: 3px 10px;
border-radius: 6px;
}
a { color: #c44; text-decoration: none; }
.step-text a, .option-block a {
border-bottom: 1px solid rgba(204,68,68,0.3);
transition: border-color 0.2s;
}
.step-text a:hover, .option-block a:hover { border-bottom-color: #c44; }
/* ─── Or divider ─── */
.or-divider {
display: flex; align-items: center; gap: 10px;
margin: 4px 0 10px 42px;
color: #444; font-size: 0.78rem; letter-spacing: 0.1em; text-transform: uppercase;
}
.or-divider::before, .or-divider::after {
content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.06);
}
.option-block {
margin-left: 42px;
padding: 12px 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px;
text-align: left; font-size: 0.88rem; color: #888; line-height: 1.5;
}
.divider { height: 1px; background: rgba(152,0,0,0.15); margin: 24px 0; }
/* ─── Tags ─── */
.tag {
font-size: 0.65rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.1);
padding: 2px 8px; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.05em; color: #bbb;
white-space: nowrap;
}
.tag.voice { background: rgba(0,180,120,0.2); border-color: rgba(0,180,120,0.4); color: #5effc4; }
.tag.beta { background: rgba(255,180,0,0.15); border-color: rgba(255,180,0,0.3); color: #ffcc55; }
.tag.dev { background: rgba(160,80,255,0.15); border-color: rgba(160,80,255,0.3); color: #cc88ff; }
.tag.rust { background: rgba(80,140,255,0.15); border-color: rgba(80,140,255,0.3); color: #88aaff; }
.tag.warn { background: rgba(255,140,0,0.15); border-color: rgba(255,140,0,0.3); color: #ffaa44; }
.tag.dim { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.08); color: #666; }
/* ─── Featured client ─── */
.client-featured { margin-bottom: 16px; }
.client-featured a {
display: flex; flex-direction: column; align-items: center; gap: 6px;
background: linear-gradient(135deg, rgba(152,0,0,0.25), rgba(120,0,0,0.15));
border: 1px solid rgba(152,0,0,0.55);
color: #fff; text-decoration: none;
padding: 18px 24px; border-radius: 12px;
transition: all 0.25s ease;
box-shadow: 0 0 20px rgba(152,0,0,0.1);
}
.client-featured a:hover {
background: linear-gradient(135deg, rgba(152,0,0,0.38), rgba(120,0,0,0.25));
border-color: rgba(152,0,0,0.8);
box-shadow: 0 0 32px rgba(152,0,0,0.25);
transform: translateY(-2px);
}
.client-name { font-size: 1.15rem; font-weight: 600; letter-spacing: 0.05em; }
.client-desc { font-size: 0.82rem; color: #ccc; }
.tag-row { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; margin-top: 2px; }
/* Also-available note */
.also-available {
font-size: 0.78rem; color: #555; margin-top: 8px; line-height: 1.6;
}
.also-available a { color: #666; border-bottom: 1px solid rgba(102,102,102,0.3); transition: color 0.2s; }
.also-available a:hover { color: #999; }
/* ─── Space join ─── */
.space-join {
margin-top: 20px; padding: 16px 20px;
background: rgba(152,0,0,0.06);
border: 1px solid rgba(152,0,0,0.2); border-radius: 10px;
}
.space-join p { font-size: 0.82rem; color: #666; margin-bottom: 10px; }
.space-join a {
display: inline-block;
background: rgba(152,0,0,0.15);
border: 1px solid rgba(152,0,0,0.35);
color: #c44; text-decoration: none;
padding: 8px 20px; border-radius: 8px; font-size: 0.88rem;
transition: all 0.25s ease;
}
.space-join a:hover { background: rgba(152,0,0,0.25); color: #fff; }
/* ─── Secondary client cards ─── */
.clients-section h3 {
font-size: 0.8rem; font-weight: 500; color: #666;
text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 14px;
}
.client-group { margin-bottom: 18px; }
.client-group-label {
font-size: 0.75rem; color: #555;
text-transform: uppercase; letter-spacing: 0.1em;
margin-bottom: 8px;
}
.client-cards { display: flex; flex-direction: column; gap: 8px; }
.client-card {
display: flex; flex-direction: column; gap: 6px;
background: rgba(255,255,255,0.025);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px; padding: 12px 14px; text-align: left;
transition: border-color 0.2s;
}
.client-card:hover { border-color: rgba(152,0,0,0.3); }
.client-card-top {
display: flex; align-items: center;
justify-content: space-between; gap: 10px; flex-wrap: wrap;
}
.client-card-name {
font-size: 0.95rem; font-weight: 600; color: #e0e0e0;
text-decoration: none;
border-bottom: 1px solid rgba(204,68,68,0.2);
transition: color 0.2s, border-color 0.2s;
}
.client-card-name:hover { color: #c44; border-bottom-color: #c44; }
.client-card-tags { display: flex; gap: 5px; flex-wrap: wrap; }
.client-card-desc { font-size: 0.8rem; color: #666; line-height: 1.45; }
/* ─── Comparison table ─── */
.comparison-section {
margin-top: 32px;
background: linear-gradient(145deg, rgba(22,22,22,0.95), rgba(14,14,14,0.98));
border: 1px solid rgba(152,0,0,0.2);
border-radius: 16px;
overflow: hidden;
}
.comparison-title {
font-size: 0.78rem; font-weight: 500; color: #980000;
text-transform: uppercase; letter-spacing: 0.15em;
padding: 16px 20px 12px;
border-bottom: 1px solid rgba(152,0,0,0.1);
}
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table {
width: 100%; border-collapse: collapse;
font-size: 0.78rem; min-width: 760px;
}
thead tr { border-bottom: 1px solid rgba(152,0,0,0.15); }
th {
padding: 10px 8px; text-align: center;
font-size: 0.7rem; font-weight: 600;
color: #888; text-transform: uppercase; letter-spacing: 0.08em;
background: rgba(255,255,255,0.02);
}
th:first-child { text-align: left; padding-left: 16px; min-width: 140px; }
th.ours { color: #c66; }
th small { display: block; font-size: 0.6rem; font-weight: 400; color: #555; margin-top: 2px; text-transform: none; letter-spacing: 0; }
tr { border-bottom: 1px solid rgba(255,255,255,0.03); }
tr:last-child { border-bottom: none; }
tr.section-header td {
background: rgba(152,0,0,0.06);
color: #770000; font-weight: 600;
font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.12em;
padding: 6px 8px 5px 16px; text-align: left;
}
td {
padding: 8px 8px; text-align: center; vertical-align: middle;
color: #888; line-height: 1.3;
}
td:first-child {
text-align: left; padding-left: 16px;
color: #aaa; font-size: 0.78rem;
}
td small { display: block; font-size: 0.68rem; color: #555; margin-top: 1px; }
.yes { color: #5effc4; font-size: 1rem; }
.part { color: #ffcc55; font-size: 0.9rem; }
.no { color: #444; font-size: 1rem; }
/* highlight our hosted client column */
th.ours, td.ours { background: rgba(152,0,0,0.04); }
/* ─── Legend ─── */
.legend {
display: flex; gap: 18px; justify-content: center; flex-wrap: wrap;
padding: 10px 16px 14px;
border-top: 1px solid rgba(255,255,255,0.04);
font-size: 0.72rem; color: #555;
}
.legend span { display: flex; align-items: center; gap: 5px; }
/* ─── Security note ─── */
.security-note {
margin: 0 20px 16px;
padding: 10px 14px;
background: rgba(80,140,255,0.04);
border: 1px solid rgba(80,140,255,0.12);
border-radius: 8px;
font-size: 0.76rem; color: #556; line-height: 1.55; text-align: left;
}
.security-note strong { color: #88aaff; }
.all-clients { margin-top: 14px; }
.all-clients a {
font-size: 0.78rem; color: #555;
border-bottom: 1px solid rgba(85,85,85,0.3);
transition: color 0.2s, border-color 0.2s;
}
.all-clients a:hover { color: #888; border-bottom-color: #888; }
/* ─── Server info ─── */
.server-info {
margin-top: 24px;
background: linear-gradient(145deg, rgba(22,22,22,0.95), rgba(14,14,14,0.98));
border: 1px solid rgba(152,0,0,0.2);
border-radius: 12px; overflow: hidden;
}
.server-info-title {
font-size: 0.78rem; font-weight: 500; color: #980000;
text-transform: uppercase; letter-spacing: 0.15em;
padding: 14px 20px 10px;
border-bottom: 1px solid rgba(152,0,0,0.1);
}
.info-grid { display: grid; grid-template-columns: 1fr 1fr; }
.info-item {
padding: 12px 18px;
border-bottom: 1px solid rgba(255,255,255,0.03);
border-right: 1px solid rgba(255,255,255,0.03);
text-align: left;
}
.info-item:nth-child(even) { border-right: none; }
.info-item:nth-last-child(-n+2) { border-bottom: none; }
.info-label { font-size: 0.7rem; color: #555; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 3px; }
.info-value { font-size: 0.88rem; color: #ccc; }
.privacy-strip {
padding: 10px 18px;
border-top: 1px solid rgba(152,0,0,0.1);
display: flex; gap: 18px; justify-content: center; flex-wrap: wrap;
}
.privacy-badge { font-size: 0.75rem; color: #5effc4; display: flex; align-items: center; gap: 5px; }
.privacy-badge::before { content: '✓'; font-weight: 700; }
/* ─── Legal / contact / footer ─── */
.legal-note {
margin-top: 24px; padding: 12px 18px;
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 8px; font-size: 0.75rem; color: #444;
line-height: 1.6; text-align: left;
}
.legal-note a { color: #555; border-bottom: 1px solid rgba(85,85,85,0.3); }
.legal-note a:hover { color: #888; }
.contact {
margin-top: 24px; padding: 16px;
background: linear-gradient(145deg, rgba(22,22,22,0.95), rgba(14,14,14,0.98));
border: 1px solid rgba(152,0,0,0.2);
border-radius: 12px; text-align: center;
}
.footer {
margin-top: 20px; font-size: 0.72rem; color: #383838;
letter-spacing: 0.04em;
display: flex; justify-content: center; gap: 14px; flex-wrap: wrap;
}
.footer a { color: #444; transition: color 0.2s; }
.footer a:hover { color: #777; }
/* ─── Sticky first table column (all screen sizes) ─── */
td:first-child {
position: sticky;
left: 0;
z-index: 1;
background: #0d0d0d;
}
th:first-child {
position: sticky;
left: 0;
z-index: 2;
background: #111;
}
tr.section-header td {
/* section headers span full width — override sticky bg */
position: static;
background: rgba(152,0,0,0.06);
}
/* Scroll hint — visible only on mobile via JS class */
.scroll-hint {
display: none;
font-size: 0.72rem;
color: #3a3a3a;
padding: 0 16px 10px;
letter-spacing: 0.05em;
}
/* ─── Tablet (≤ 700px) ─── */
@media (max-width: 700px) {
body { align-items: flex-start; }
}
/* ─── Mobile (≤ 540px) ─── */
@media (max-width: 540px) {
body { padding: 20px 12px 40px; }
.logo { width: 100px; height: 100px; margin-bottom: 20px; }
h1 { font-size: 1.5rem; letter-spacing: 0.1em; }
.subtitle { font-size: 0.78rem; letter-spacing: 0.18em; margin-bottom: 26px; }
.card { padding: 22px 16px; }
.step-text { font-size: 0.88rem; }
.homeserver { font-size: 0.75rem; word-break: break-all; }
.or-divider, .option-block { margin-left: 0; }
.client-card-top { flex-direction: column; align-items: flex-start; gap: 6px; }
.client-card-desc { font-size: 0.79rem; }
.client-card-tags { gap: 4px; }
.also-available { font-size: 0.75rem; }
/* Table */
.scroll-hint { display: block; }
table { font-size: 0.71rem; min-width: 620px; }
th { padding: 8px 5px; font-size: 0.6rem; }
th:first-child { min-width: 100px; padding-left: 10px; }
td { padding: 7px 5px; }
td:first-child { font-size: 0.71rem; padding-left: 10px; }
td small, th small { font-size: 0.58rem; }
.yes { font-size: 0.88rem; }
.part { font-size: 0.82rem; }
.no { font-size: 0.88rem; }
.comparison-title { font-size: 0.7rem; padding: 12px 14px 8px; letter-spacing: 0.1em; }
.security-note { margin: 10px 12px 4px; font-size: 0.71rem; }
.legend { padding: 8px 12px 12px; gap: 12px; font-size: 0.68rem; }
/* Server info */
.server-info-title { font-size: 0.7rem; padding: 12px 14px 8px; }
.info-item { padding: 10px 14px; }
.info-label { font-size: 0.65rem; }
.info-value { font-size: 0.82rem; }
.privacy-strip { flex-direction: column; align-items: center; gap: 8px; padding: 10px 14px; }
.privacy-badge { font-size: 0.71rem; }
/* Legal / footer */
.legal-note { font-size: 0.72rem; padding: 11px 14px; }
.contact { padding: 14px; }
.footer { gap: 8px; font-size: 0.68rem; }
}
/* ─── Very small (≤ 380px) ─── */
@media (max-width: 380px) {
h1 { font-size: 1.3rem; }
.logo { width: 84px; height: 84px; }
.card { padding: 18px 12px; }
table { font-size: 0.66rem; min-width: 580px; }
th { font-size: 0.55rem; padding: 7px 4px; }
td { padding: 6px 4px; }
td:first-child { font-size: 0.66rem; min-width: 90px; }
}
/* ── Fork feature groups (matrix #11) ── */
.fork-intro { font-size: 0.82rem; color: #888; margin: 14px 0 8px; line-height: 1.5; }
.fork-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
margin: 0 0 12px;
}
.fork-group {
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px;
padding: 10px 12px;
}
.fork-group { text-align: left; }
.fork-group h4 { text-align: center; margin: 0 0 6px; font-size: 0.8rem; color: #c66; letter-spacing: 0.02em; }
.fork-group ul { margin: 0; padding-left: 16px; }
.fork-group li { font-size: 0.74rem; color: #999; line-height: 1.45; margin: 3px 0; }
.fork-more { font-size: 0.76rem; color: #777; margin: 0 0 6px; }
.fork-more a { color: #888; border-bottom: 1px solid rgba(136,136,136,0.3); }
.hs-inline { font-size: 0.8em; color: #e88; }
.reviewed { font-size: 0.7rem; color: #666; text-align: center; margin: -4px 0 10px; }