landing: structured feature groups, single-source comparison table (#11)
Lint / Shell (shellcheck) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 11s
Lint / Landing page is rendered (matrix (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 7s
Lint / Python deps (pip-audit) (push) Successful in 1m5s
Lint / Secret scan (gitleaks) (push) Successful in 10s

- The ~1,800-word "our fork adds" paragraph becomes seven titled groups
  (Calls & Voice, Messaging, Media & Links, Privacy & Security, Look &
  Feel, Desktop App, Rooms & Moderation), one short line per feature,
  with a link to LOTUS_FEATURES.md instead of duplicating the changelog.
- The comparison table lives in landing/data/comparison.json (converted
  losslessly: all 71 rows render with identical cell text) and the
  feature groups in landing/data/features.json. landing/build.py renders
  them between <!-- BEGIN/END --> markers; everything else stays
  hand-edited.
- CSS moved to landing/style.css and inlined by build.py (the LXC 139
  deploy copies index.html only, so a linked stylesheet wouldn't ship).
  The fork paragraph's 16 repeated inline styles are gone.
- Visible "Client comparison last reviewed <date>" stamp from
  comparison.json, replacing the stale "June 2026 (latest)" title.
- CI: `landing-fresh` fails if index.html doesn't match its sources.

Page is ~1,850 px shorter on desktop and ~2,500 px on phones; no
horizontal overflow at 1280 px or Pixel 7.

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-23 22:15:27 -04:00
co-authored by Claude Opus 5.5
parent a16cc85420
commit 090a394782
6 changed files with 2692 additions and 19 deletions
+12
View File
@@ -31,6 +31,18 @@ jobs:
- name: Run ESLint
run: npx eslint --ext .js hookshot/
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:
name: Python (ruff)
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
+72
View File
@@ -0,0 +1,72 @@
[
{
"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)",
"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 and per-thread notification overrides",
"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)",
"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",
"Media gallery for every image, video and 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)",
"99 animated avatar decorations, 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"
]
}
]
+108 -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; }