Render the site as a channel of the flamenet.io portal #201

Merged
paulhitt merged 7 commits from feat/messenger-as-a-flamenet-portal-channel into main 2026-09-12 17:41:09 +00:00
13 changed files with 1293 additions and 1314 deletions
+1 -1
View File
@@ -1 +1 @@
cbdadd9f82c2b64f2c0eb1b08b8ff2e88e48a5a7
d01cd0bbb267c591fd2ba7de7794f4bf461019cb
-946
View File
@@ -1,946 +0,0 @@
/* Flamenet Messenger — site theme.
*
* Modern software-product styling: a dark hero, gradient accents, eyebrow
* labels, elevated cards. Everything is self-hosted because the relay serves
* this page under `default-src 'self'` — a CDN font or an analytics tag would
* simply not load, which is the intended failure mode rather than an
* inconvenience. Hence the system font stack: it is the only way to get a
* modern typeface without asking a third party for it.
*/
:root {
/* Light by commitment, not by default. The page does not follow the
system scheme: a single deliberate look beats two half-tuned ones, and
the gradient work below is designed against white. */
--bg: #ffffff;
--bg-2: #f7f8fc;
--bg-3: #f1f3f9;
--panel: #ffffff;
--ink: #212529;
--ink-2: #5b6472;
--ink-3: #838b99;
--border: #eaedf5;
--border-2: #dde2ee;
/* Measured from the reference design rather than guessed: violet primary
with an amber secondary, on white. */
--brand: #5842bc;
--brand-2: #7b63e8;
--brand-ink: #ffffff;
--accent: #fdb900;
--grad: linear-gradient(115deg, var(--brand) 0%, var(--brand-2) 100%);
--grad-soft: linear-gradient(115deg, rgba(88,66,188,.10), rgba(123,99,232,.08));
--radius: 16px;
--radius-lg: 22px;
--wrap: 1180px;
/* ONE measure, IN PIXELS, for the places that still centre a column.
There used to be four — 30em, 34em, 47em and "none" — and because they
were `em` on elements with four different font sizes, even the ones meant
to match resolved to different widths: 30em at 19.5px is 585px, 47em at
18.5px is 869px. That is why every row looked like a different width.
A measure only holds a column steady if it is stated in the same unit
everywhere it is used.
Left-aligned bands do NOT use it — they fill the wrap, so every row on
those pages shares one left edge and one width. `.wrap-narrow` is derived
from it so the document pages land on the same column. */
--measure: 730px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--shadow: 0 1px 2px rgba(13,18,32,.04), 0 12px 32px rgba(13,18,32,.07);
--shadow-lg: 0 2px 4px rgba(13,18,32,.05), 0 28px 60px rgba(13,18,32,.13);
}
html { color-scheme: light; }
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--font);
font-size: 17px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.wrap { width: 100%; max-width: var(--wrap); margin: 0 auto; padding: 0 24px; }
/* The measure plus the wrap's own 24px of gutter on each side, so a band that
narrows its container and a band that caps its paragraphs produce the same
column rather than two that are nearly-but-not-quite aligned. */
.wrap-narrow { max-width: calc(var(--measure) + 48px); }
a { color: inherit; }
.skip { position: absolute; left: -9999px; background: var(--ink); color: var(--bg); padding: 10px 16px; border-radius: 8px; }
.skip:focus { left: 16px; top: 16px; z-index: 100; }
/* ---------- nav ---------- */
.nav {
position: sticky; top: 0; z-index: 50;
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: saturate(180%) blur(14px);
border-bottom: 1px solid var(--border);
}
.nav-inner { display: flex; align-items: center; gap: 16px; height: 74px; }
.brand {
display: flex; align-items: center; gap: 11px;
font-weight: 700; letter-spacing: -.02em; font-size: 17px;
text-decoration: none; flex: none;
}
/* The product's mark, not a coloured square. The gradient block that used to
sit here was a placeholder that outlived its excuse. */
.mark {
width: 30px; height: 30px; border-radius: 9px; flex: none;
/* The artwork is painted on the panel navy, so the radius has to clip it —
without this the square corners show against the page. */
object-fit: cover; display: block;
box-shadow: 0 4px 12px rgba(18,26,46,.28);
}
.nav-links { display: flex; align-items: center; justify-content: center; gap: 34px; flex: 1; }
.nav-links a { text-decoration: none; color: var(--ink-2); font-size: 15px; font-weight: 550; }
.nav-links a:hover { color: var(--ink); }
/* The Donate entry that only exists inside the collapsed menu.
Below 640px `.nav-donate` in the header is `display: none`, and this menu did
not carry Donate at all — so on a phone there was NO route to the donation
page from anywhere in the navigation. You had to know the URL. Most people
arriving from a shared link are on a phone, which made it invisible to
exactly the audience most likely to have just been handed the link.
Hidden here so it does not duplicate the header button on desktop. */
.nav-donate-menu { display: none; }
/* `.nav-links a` outranks `.btn-nav` on specificity, so without this the CTA
inherits the muted link colour and renders grey on a saturated fill. */
.nav-links a.btn-nav, .nav-links a.btn-nav:hover { color: var(--brand-ink); }
/* `flex: none` matters: in the nav's flex row the button would otherwise
shrink to its padding and render three zero-width bars. */
.nav-toggle { display: none; flex: none; }
/* ---------- buttons ---------- */
.btn {
display: inline-block; text-decoration: none; font-weight: 650; font-size: 15.5px;
padding: 14px 28px; border-radius: 999px; border: 1px solid transparent;
transition: transform .16s ease, box-shadow .16s ease, background .16s ease;
}
.btn-primary {
background: var(--grad); color: var(--brand-ink);
box-shadow: 0 8px 22px rgba(88,66,188,.32);
}
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 14px 32px rgba(88,66,188,.42); }
.btn-ghost { border-color: var(--border-2); color: var(--ink); background: var(--panel); }
.btn-ghost:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
.nav-cta-placeholder{}
.nav-cta { flex: none; }
.btn-nav {
background: var(--grad); color: var(--brand-ink);
padding: 10px 22px; font-size: 15px;
box-shadow: 0 6px 16px rgba(88,66,188,.3);
}
.btn-nav:hover { transform: translateY(-1px); }
/* ---------- hero ---------- */
.hero {
position: relative; overflow: hidden;
padding: 104px 0 96px;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
/* Two blurred gradient fields. Pure CSS so nothing is fetched. */
.hero::before, .hero::after {
content: ""; position: absolute; border-radius: 50%;
filter: blur(90px); opacity: .5; pointer-events: none;
}
.hero::before {
/* The signature shape of this layout: one big soft curve occupying the
right half, with the product art sitting on top of it. */
width: 47%; height: 132%; top: -16%; right: 0;
border-radius: 44% 0 0 44% / 50% 0 0 50%;
background: var(--grad);
opacity: 1; filter: none;
}
.hero::after {
width: 340px; height: 340px; top: 4%; right: 10%;
background: radial-gradient(circle, rgba(255,255,255,.30), transparent 70%);
filter: blur(34px);
}
.hero-copy { max-width: 34em; }
.hero-inner {
position: relative; z-index: 1;
display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 56px; align-items: center;
}
.hero h1 {
margin: 0 0 22px;
font-size: clamp(38px, 4.6vw, 54px);
line-height: 1.14; letter-spacing: -.02em; font-weight: 750;
}
/* Fills its band. A left-aligned band's rows all start at the same edge and
end at the same one — which is the whole point of not capping them. */
.lede { margin: 0 0 32px; font-size: 19.5px; color: var(--ink-2); }
.cta-row { display: flex; flex-wrap: wrap; gap: 14px; }
.hero-note { margin: 22px 0 0; font-size: 14px; color: var(--ink-3); }
.hero-art { display: flex; justify-content: center; }
/* No height: the screenshot sets it, so the frame cannot crop the picture or
leave a band of bezel under it. */
.phone {
width: 296px; border-radius: 44px; padding: 13px;
background: linear-gradient(165deg, #2b3346, #0e131f);
box-shadow: var(--shadow-lg);
transform: rotate(-2deg);
}
.phone-screen {
display: block; width: 100%; height: auto;
border-radius: 33px; background: var(--bg-3);
}
/* ---------- sections ---------- */
.band { padding: 96px 0; }
.band-alt { background: var(--bg-2); }
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
padding: 7px 16px; margin-bottom: 18px;
border-radius: 999px; border: 1px solid var(--border-2);
background: var(--grad-soft); color: var(--brand);
font-size: 13px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
}
.eyebrow::before {
content: ""; width: 6px; height: 6px; border-radius: 50%;
background: var(--accent);
}
/* One word of a heading carried in the accent — the two-tone treatment this
layout uses to stop a large heading reading as a wall. */
.hl {
background: var(--grad);
-webkit-background-clip: text; background-clip: text;
color: transparent;
}
.band-center { text-align: center; }
/* The measure applies HERE and only here. Centred text needs a column or
the eye cannot find the start of the next line; left-aligned text does not,
and capping it is what left one row narrower than the one above it.
Everything a band can hold is centred as a set — listing only some of these
was the other half of the misalignment. */
.band-center .band-lede,
.band-center .lede,
.band-center .fineprint {
max-width: var(--measure); margin-left: auto; margin-right: auto;
}
.band-center .cta-row { justify-content: center; }
.band h2 {
margin: 0 0 18px; font-size: clamp(28px, 3.2vw, 40px);
letter-spacing: -.02em; line-height: 1.2; font-weight: 750;
}
.band-lede { margin: 0 0 52px; color: var(--ink-2); font-size: 18.5px; }
/* ---------- cards ---------- */
/* Fixed four columns rather than auto-fit: with eight features, auto-fit
resolves to five-and-three at wide viewports and leaves a ragged row. */
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 42px 30px; }
.cards-3 { grid-template-columns: repeat(3, 1fr); gap: 24px; }
.card {
background: transparent; border: 0;
border-radius: var(--radius-lg); padding: 22px 14px;
transition: transform .18s ease;
}
.card:hover { transform: translateY(-5px); }
/* The developer trio keeps its panels; only the feature row goes card-less. */
.cards-3 .card {
background: var(--panel); border: 1px solid var(--border);
padding: 32px 28px; text-align: left;
}
.cards-3 .card:hover { box-shadow: var(--shadow-lg); }
.cards-3 .card-icon { margin-left: 0; }
.card h3 { margin: 0 0 10px; font-size: 17.5px; letter-spacing: -.015em; font-weight: 700; }
/* A card inside a group: the group owns the h3, so the card's title is an h4
and has to keep the same look. Styled together so the two cannot drift. */
.card h4 { margin: 0 0 10px; font-size: 17.5px; letter-spacing: -.015em; font-weight: 700; }
/* Groups exist so the page can gain features without gaining top-level
sections (relay#44). Quieter than an h2 band head, louder than a card. */
.group-head {
margin: 46px 0 22px;
font-size: 15px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--ink-2);
text-align: center;
}
.group-head:first-of-type { margin-top: 30px; }
.card p { margin: 0; color: var(--ink-2); font-size: 15px; line-height: 1.6; }
.card-icon {
display: flex; align-items: center; justify-content: center;
width: 62px; height: 62px; margin: 0 auto 20px;
border-radius: 50%; background: var(--grad);
color: #fff;
box-shadow: 0 10px 24px rgba(88,66,188,.28);
}
.card-icon svg { width: 27px; height: 27px; display: block; }
/* Each tile takes its own hue, as the demo does — the set reads as a family
rather than five copies of one badge. */
.card:nth-child(5n+2) .card-icon { background: linear-gradient(115deg,#fdb900,#ffd257); box-shadow: 0 10px 24px rgba(253,185,0,.30); }
.card:nth-child(5n+3) .card-icon { background: linear-gradient(115deg,#2e99f5,#69bcff); box-shadow: 0 10px 24px rgba(46,153,245,.28); }
.card:nth-child(5n+4) .card-icon { background: linear-gradient(115deg,#198754,#31c37e); box-shadow: 0 10px 24px rgba(25,135,84,.26); }
.card:nth-child(5n+5) .card-icon { background: linear-gradient(115deg,#e0457b,#f57fa8); box-shadow: 0 10px 24px rgba(224,69,123,.26); }
.cards .card { text-align: center; }
.cards .card p { margin-left: auto; margin-right: auto; max-width: 34em; }
.cards .card-link { margin-top: 16px; }
.card-link {
display: inline-block; margin-top: 15px; font-size: 14.5px;
font-weight: 650; color: var(--brand); text-decoration: none;
}
.card-link:hover { text-decoration: underline; }
/* ---------- social-proof band ---------- */
.proof-band { padding: 44px 0 40px; background: var(--bg); border-bottom: 1px solid var(--border); }
.proof-lead {
margin: 0 0 22px; text-align: center;
color: var(--ink-3); font-size: 15px; font-weight: 600;
}
.proof-lead strong { color: var(--ink); font-weight: 700; }
.proof {
display: flex; flex-wrap: wrap; justify-content: center;
align-items: center; gap: 18px 54px;
}
.proof span { color: var(--ink-3); font-size: 15px; font-weight: 600; white-space: nowrap; }
.proof strong { color: var(--ink); font-weight: 750; }
/* ---------- steps ---------- */
.steps {
list-style: none; margin: 0; padding: 0;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 26px;
}
.step {
position: relative;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 32px 28px;
transition: transform .18s ease, box-shadow .18s ease;
}
.step:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.step-n {
display: inline-flex; align-items: center; justify-content: center;
width: 42px; height: 42px; border-radius: 12px;
background: var(--grad); color: var(--brand-ink);
font-weight: 750; font-size: 16px; margin-bottom: 16px;
box-shadow: 0 6px 16px rgba(88,66,188,.3);
}
.step h3 { margin: 0 0 9px; font-size: 18.5px; letter-spacing: -.015em; }
.step p { margin: 0; color: var(--ink-2); font-size: 15.5px; }
.step .card-icon { display: none; }
/* ---------- self-hosting split ---------- */
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: center; }
.code {
margin: 0; padding: 26px; overflow-x: auto;
background: #0c111c; color: #e7ebf5;
border: 1px solid var(--border-2); border-radius: var(--radius);
font-family: var(--mono); font-size: 14px; line-height: 1.75;
box-shadow: var(--shadow-lg);
}
.code code { white-space: pre; }
.fineprint {
margin: 34px 0 0; font-size: 14.5px; color: var(--ink-3);
padding-top: 22px; border-top: 1px solid var(--border);
}
.fineprint code { font-family: var(--mono); font-size: 13.5px; }
.fineprint-flush { border-top: 0; padding-top: 0; margin-top: 20px; }
/* ---------- platforms ---------- */
.platforms { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
.platform {
display: flex; flex-direction: column; gap: 5px;
padding: 26px; border-radius: var(--radius);
border: 1px solid var(--border); background: var(--panel);
text-decoration: none;
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
}
.platform:hover { transform: translateY(-4px); border-color: var(--brand); box-shadow: var(--shadow); }
.platform-name { font-weight: 700; font-size: 16.5px; }
.platform-state { font-size: 14px; color: var(--ink-3); }
.platform-soon { opacity: .6; }
/* ---------- faq ---------- */
.faq details {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 22px 26px; margin-bottom: 14px;
transition: border-color .18s ease;
}
.faq details[open] { border-color: var(--border-2); box-shadow: var(--shadow); }
.faq summary {
cursor: pointer; font-weight: 660; font-size: 17.5px;
list-style: none; display: flex; justify-content: space-between; align-items: center; gap: 16px;
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after { content: "+"; font-size: 24px; color: var(--brand); font-weight: 400; line-height: 1; }
.faq details[open] summary::after { content: "\2212"; }
.faq details p { margin: 14px 0 0; color: var(--ink-2); max-width: var(--measure); }
/* ---------- footer ---------- */
.footer { padding: 76px 0 52px; background: var(--bg-2); border-top: 1px solid var(--border); }
.footer-cols { display: grid; grid-template-columns: 1.5fr repeat(4, 1fr); gap: 34px; margin-bottom: 44px; }
.footer-brand { display: flex; align-items: center; gap: 11px; font-weight: 700; letter-spacing: -.02em; }
.footer-col { display: flex; flex-direction: column; gap: 11px; }
.footer-col h4 {
margin: 0 0 5px; font-size: 12.5px; text-transform: uppercase;
letter-spacing: .1em; color: var(--ink-3); font-weight: 700;
}
.footer-col a { text-decoration: none; color: var(--ink-2); font-size: 15px; }
.footer-col a:hover { color: var(--brand); }
.footer-legal {
margin: 0; padding-top: 30px; border-top: 1px solid var(--border);
font-size: 13.5px; color: var(--ink-3);
}
/* ---------- document pages ---------- */
.doc { padding-top: 72px; }
.doc h1 { margin: 0 0 20px; font-size: clamp(36px, 4.8vw, 50px); letter-spacing: -.032em; line-height: 1.08; font-weight: 800; }
.doc h2 { margin: 48px 0 14px; font-size: 23px; letter-spacing: -.02em; font-weight: 700; }
.doc p { margin: 0 0 17px; color: var(--ink-2); }
.doc .lede { font-size: 19.5px; margin-bottom: 10px; }
.doc .meta { font-size: 14.5px; color: var(--ink-3); margin-bottom: 10px; }
.doc ul, .doc ol { margin: 0 0 17px; padding-left: 22px; color: var(--ink-2); }
.doc li { margin-bottom: 9px; }
.doc a { color: var(--brand); text-decoration: none; }
.doc a:hover { text-decoration: underline; }
.doc strong { color: var(--ink); }
.doc table { width: 100%; border-collapse: collapse; margin: 0 0 22px; font-size: 15px; display: block; overflow-x: auto; }
.doc th, .doc td { border: 1px solid var(--border); padding: 10px 14px; text-align: left; vertical-align: top; }
.doc th { background: var(--bg-2); font-weight: 680; white-space: nowrap; }
.doc td { color: var(--ink-2); }
.code-light { font-size: 12.5px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
/* ---------- generated documentation ---------- */
.doc-generated h3 { margin: 30px 0 11px; font-size: 18.5px; font-weight: 680; letter-spacing: -.015em; }
.doc-generated h4 { margin: 24px 0 9px; font-size: 16.5px; font-weight: 680; }
.doc-generated hr { border: 0; border-top: 1px solid var(--border); margin: 44px 0; }
.doc-generated code {
background: var(--bg-3); border: 1px solid var(--border);
border-radius: 6px; padding: 1.5px 6px;
font-family: var(--mono); font-size: .87em; color: var(--ink);
}
.doc-generated pre {
background: #0c111c; color: #e7ebf5; border-radius: var(--radius);
padding: 22px; overflow-x: auto; margin: 0 0 20px; border: 1px solid var(--border-2);
}
.doc-generated pre code { background: none; border: 0; padding: 0; color: inherit; font-size: 13px; line-height: 1.7; white-space: pre; }
.doc-generated table { width: 100%; border-collapse: collapse; margin: 0 0 22px; font-size: 14.5px; display: block; overflow-x: auto; }
.doc-generated th, .doc-generated td { border: 1px solid var(--border); padding: 9px 13px; text-align: left; vertical-align: top; }
.doc-generated th { background: var(--bg-2); font-weight: 680; white-space: nowrap; }
.doc-generated td { color: var(--ink-2); }
.doc-generated blockquote {
margin: 0 0 20px; padding: 14px 20px;
border-left: 3px solid var(--brand); background: var(--bg-2);
border-radius: 0 10px 10px 0; color: var(--ink-2);
}
.doc-generated ol, .doc-generated ul { margin: 0 0 17px; padding-left: 22px; color: var(--ink-2); }
/* ---------- responsive ---------- */
@media (max-width: 980px) {
.hero { padding: 68px 0 60px; }
.hero-inner { grid-template-columns: 1fr; gap: 44px; }
/* Stacked, as the demo stacks it: copy first on plain ground, then the
artwork sitting on the gradient panel. */
.hero { padding-bottom: 0; }
/* The gradient panel is attached to the artwork rather than sized as a
percentage of the hero: the hero's height depends on how far the copy
wraps, and a percentage panel climbs up over the last lines of it. */
.hero-art {
display: flex; margin: 0 -24px -1px; padding: 44px 0 0;
background: var(--grad);
border-radius: 34% 34% 0 0 / 12% 12% 0 0;
}
.hero::before { display: none; }
.hero::after { display: none; }
.phone { transform: none; width: 268px; }
.cards, .cards-3, .steps { grid-template-columns: 1fr 1fr; }
.platforms { grid-template-columns: 1fr 1fr; }
.split { grid-template-columns: 1fr; gap: 30px; }
.footer-cols { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 640px) {
body { font-size: 16px; }
.band { padding: 64px 0; }
.cards, .cards-3, .steps, .platforms, .footer-cols { grid-template-columns: 1fr; }
/* Brand, menu button and CTA have to share 375px. Left at desktop sizes the
row overflows and the CTA is clipped by the viewport edge. */
.nav-inner { gap: 8px; }
.brand { font-size: 15.5px; gap: 9px; }
.brand .mark { width: 26px; height: 26px; border-radius: 8px; }
.btn-nav { padding: 9px 18px; font-size: 14.5px; }
.nav-toggle {
display: flex; flex-direction: column; justify-content: center; gap: 5px;
width: 38px; height: 42px; padding: 0 6px; background: none; border: 0; cursor: pointer;
}
.nav-toggle span { display: block; height: 2px; background: var(--ink); border-radius: 2px; }
.nav-links {
display: none; position: absolute; left: 0; right: 0; top: 74px;
flex-direction: column; align-items: flex-start; gap: 4px;
background: var(--bg); border-bottom: 1px solid var(--border); padding: 18px 24px 24px;
}
.nav-links.open { display: flex; }
.nav-links a { padding: 11px 0; font-size: 16px; }
.btn-nav { margin-top: 10px; }
}
/* ---------- announcement bar ---------- */
.topbar {
background: var(--ink); color: #fff;
font-size: 13px; line-height: 1;
}
.topbar .wrap {
display: flex; align-items: center; justify-content: center;
gap: 10px; padding-top: 11px; padding-bottom: 11px; flex-wrap: wrap;
}
.topbar span { opacity: .8; }
.topbar-tag {
background: var(--accent); color: #241a00;
font-weight: 800; font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase;
padding: 4px 9px; border-radius: 5px; opacity: 1;
}
.topbar a { color: #fff; text-decoration: underline; text-underline-offset: 3px; }
/* ---------- hero chips ---------- */
.hero-chips { display: flex; flex-wrap: wrap; gap: 12px 30px; margin-top: 40px; }
.chip {
display: inline-flex; align-items: center; gap: 10px;
font-size: 13.5px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase;
color: var(--ink-2);
}
.chip-ic {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 50%;
background: var(--bg-3); color: var(--brand); flex: none;
}
.chip-ic svg { width: 15px; height: 15px; }
/* ---------- community cards ---------- */
.commun { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
.commun-card {
display: flex; align-items: center; gap: 18px; text-align: left;
padding: 24px 26px; border-radius: var(--radius);
background: var(--panel); border: 1px solid var(--border);
text-decoration: none;
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
}
.commun-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); border-color: var(--brand); }
.commun-ic {
display: inline-flex; align-items: center; justify-content: center;
width: 52px; height: 52px; border-radius: 50%; flex: none;
background: var(--grad); color: #fff;
box-shadow: 0 8px 20px rgba(88,66,188,.28);
}
.commun-ic svg { width: 23px; height: 23px; }
.commun-inf { display: flex; flex-direction: column; gap: 3px; }
.commun-inf strong { font-size: 17px; font-weight: 700; color: var(--ink); }
.commun-inf small { font-size: 14px; color: var(--ink-3); }
/* ---------- footer call to action ---------- */
.footer-cta { text-align: center; padding-bottom: 56px; }
.footer-cta h2 {
margin: 0 0 12px; font-size: clamp(28px, 3.2vw, 40px);
letter-spacing: -.02em; line-height: 1.2; font-weight: 750;
}
.footer-cta p { margin: 0 auto 30px; color: var(--ink-2); max-width: var(--measure); }
.footer-cta-row { justify-content: center; }
@media (max-width: 980px) {
.commun { grid-template-columns: 1fr; }
}
/* ---------- donate ---------- */
.nav-cta { display: flex; align-items: center; gap: 10px; flex: none; }
.btn-outline {
background: transparent; border-color: var(--brand); color: var(--brand);
padding: 10px 22px; font-size: 15px;
}
.btn-outline:hover { background: var(--grad-soft); transform: translateY(-1px); }
/* `.nav-links a` does not reach these — they live outside that container — but
be explicit so a later markup change cannot silently mute them. */
.nav-cta a.btn-outline { color: var(--brand); }
.nav-cta a.btn-nav { color: var(--brand-ink); }
.donate-thanks {
margin: 0 0 26px; padding: 18px 22px; border-radius: var(--radius);
background: var(--grad-soft); border: 1px solid var(--brand);
}
.donate-thanks p { margin: 6px 0 0; color: var(--ink-2); font-size: 15px; }
.donate-pending {
padding: 18px 22px; border-radius: var(--radius);
background: var(--bg-2); border: 1px dashed var(--border-2);
color: var(--ink-2);
}
@media (max-width: 640px) {
/* The header has room for the logo, the toggle and one CTA. Donate moves
into the menu rather than off the site — see `.nav-donate-menu`. */
.nav-donate { display: none; }
.nav-donate-menu { display: block; }
}
/* ---------- donation form ---------- */
.donate-form {
margin: 0 0 24px; padding: 28px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--panel);
}
.donate-form fieldset { border: 0; padding: 0; margin: 0 0 22px; }
.donate-form legend {
padding: 0; margin-bottom: 12px;
font-size: 13px; font-weight: 700; letter-spacing: .07em;
text-transform: uppercase; color: var(--ink-3);
}
.donate-freq { display: flex; gap: 10px; }
.donate-amounts { display: flex; flex-wrap: wrap; gap: 10px; }
.donate-form label {
display: inline-flex; align-items: center; gap: 8px;
padding: 12px 20px; border-radius: 999px;
border: 1px solid var(--border-2); background: var(--bg);
font-weight: 650; font-size: 15.5px; cursor: pointer;
transition: border-color .15s ease, background .15s ease, color .15s ease;
}
.donate-form label:hover { border-color: var(--brand); }
/* The radio itself is kept in the accessibility tree and focusable — hiding it
with display:none would take it out of both. */
.donate-form input[type="radio"] { position: absolute; opacity: 0; width: 0; height: 0; }
.donate-form label:has(input[type="radio"]:checked) {
border-color: var(--brand); background: var(--grad-soft); color: var(--brand);
}
.donate-form label:has(input[type="radio"]:focus-visible) {
outline: 2px solid var(--brand); outline-offset: 2px;
}
.donate-custom input[type="number"] {
width: 90px; padding: 6px 10px; font: inherit; font-size: 15px;
border: 1px solid var(--border-2); border-radius: 8px;
background: var(--bg); color: var(--ink);
}
.donate-form .btn { border: 0; cursor: pointer; font-family: inherit; }
/* The email field is a plain text input, not a pill choice — undo the label
chrome the radio pills wear. */
.donate-email { margin: 18px 0 4px; }
.donate-email label {
display: block; padding: 0; border: 0; background: none; cursor: default;
font-size: 13px; font-weight: 700; letter-spacing: .07em;
text-transform: uppercase; color: var(--ink-3); margin-bottom: 8px;
}
.donate-email label:hover { border-color: transparent; }
.donate-email input[type="email"] {
width: 100%; max-width: 340px; padding: 10px 12px; font: inherit;
font-size: 15px; border: 1px solid var(--border-2); border-radius: 8px;
background: var(--bg); color: var(--ink);
}
/* Stripe's own fields land here. Only spacing and a rule above them: the frame
styles itself, and fighting it produces a form that looks half-ours. */
.donate-payment {
margin: 4px 0 22px; padding-top: 22px;
border-top: 1px solid var(--border);
}
.donate-error {
margin: 0 0 16px; padding: 12px 16px; border-radius: 10px;
background: rgba(200, 30, 30, .07); border: 1px solid rgba(200, 30, 30, .25);
color: #9b1c1c; font-size: 15px;
}
@media (max-width: 640px) {
.donate-freq, .donate-amounts { flex-direction: column; align-items: stretch; }
.donate-form label { justify-content: center; }
}
/* ---------- straight-talk band ---------- */
/* A centred heading over a left-aligned body: the caveats read as prose you
are meant to actually read, not as three more marketing tiles. */
.band-head { text-align: center; }
.band-head .band-lede { margin-left: auto; margin-right: auto; }
.honest { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
.honest-card {
padding: 30px 28px; border-radius: var(--radius);
background: var(--panel); border: 1px solid var(--border);
border-top: 3px solid var(--accent);
}
.honest-card h3 { margin: 0 0 10px; font-size: 18px; letter-spacing: -.015em; font-weight: 700; }
.honest-card p { margin: 0; color: var(--ink-2); font-size: 15.5px; }
/* ---------- developer note ---------- */
/* Deliberately no strip of protocol acronyms here. The names live in the
specification, where someone looking for them has context; on the landing
page they read as a wall to everybody else. */
.stack-note {
max-width: var(--measure); margin: 40px auto 0; padding: 22px 26px;
border-radius: var(--radius); background: var(--bg-2);
border: 1px solid var(--border);
color: var(--ink-2); font-size: 15.5px;
}
/* The FAQ band is centred at the head and left-aligned in the body; without
this the flex summary would centre its text and the answers would too. */
.faq .band-center { margin-bottom: 44px; }
.faq details, .faq details p { text-align: left; }
@media (max-width: 980px) {
.honest { grid-template-columns: 1fr; }
}
/* ---------- comparison table ---------- */
/* The page names three other products, so the table has to be readable enough
that someone can actually check it rather than skim the ticks. */
.compare-scroll { overflow-x: auto; margin: 0 0 30px; -webkit-overflow-scrolling: touch; }
.compare {
width: 100%; min-width: 780px;
border-collapse: separate; border-spacing: 0;
font-size: 15px; display: table;
}
.compare th, .compare td {
border-bottom: 1px solid var(--border);
padding: 16px 18px; text-align: left; vertical-align: top;
}
.compare thead th {
position: sticky; top: 0; z-index: 1;
background: var(--bg); border-bottom: 2px solid var(--border-2);
font-size: 15px; font-weight: 700; white-space: nowrap;
}
.compare tbody th {
font-weight: 620; color: var(--ink); width: 24%;
background: var(--bg);
}
.compare td { color: var(--ink-2); width: 19%; }
/* Our own column, marked rather than disguised — a comparison written by one
of the parties should say which one it is. */
.compare .mine { background: var(--grad-soft); }
.compare thead th.mine {
background: var(--grad); color: var(--brand-ink);
border-radius: 10px 10px 0 0;
}
/* The verdict sits on its own line above its note, and the break is a <br> in
the markup rather than `display: block` here. Styling the separation looks
right on screen and copies as "YesEvery conversation" run together — the
text has to carry the break itself. */
.compare td strong { font-weight: 680; color: var(--ink); }
.compare td small {
display: inline-block; margin-top: 5px;
font-size: 13.5px; line-height: 1.55; color: var(--ink-3); font-weight: 400;
}
.compare .yes { color: #198754; }
.compare .no { color: #b4453f; }
.compare .part { color: #b07d00; }
.callout {
/* Full width like the rows around it, and always left-aligned inside — it
is a paragraph with a rule down its edge, and centred text against a left
border reads as a mistake. */
margin: 0 0 34px; text-align: left;
padding: 22px 26px;
border-radius: var(--radius); background: var(--bg-2);
border-left: 3px solid var(--accent);
}
.callout p { margin: 0; }
/* ---------- footer hosting credit ---------- */
/* The hosting credit runs on from the legal note as one more sentence. The
only outbound link on the site besides the repository. */
.footer-legal .host-link { color: var(--brand); text-decoration: none; font-weight: 650; }
.footer-legal .host-link:hover { text-decoration: underline; }
.footer-legal .heart { color: #e0457b; }
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
@media (max-width: 640px) {
.compare-scroll { margin-inline: -24px; padding-inline: 24px; }
}
/* The comparison page runs to the full wrap width because of the table, so its
opening paragraph should too — `.lede` caps at 30em, which leaves it sitting
in the left half of a page that is twice that wide. */
/* Kept for the hero of a page whose `.hero-copy` column would otherwise cap
it. Everywhere else `.lede` already fills its band. */
.lede-wide { max-width: none; }
/* A class, not a `style=` attribute. This page is served under
`default-src 'self'` with no `unsafe-inline`, so an inline style is BLOCKED
and logs a CSP violation — the spacing silently did not apply, and a
security-focused homepage threw a console error for exactly the audience
most likely to open devtools. Any new spacing goes here. */
.band-lede-spaced { margin-top: 1.25rem; }
/* ---------- the "hop to your phone" QR ---------- */
/* **Desktop only, deliberately.** On a phone this code points at the page
the reader is already looking at — a puzzle, not a shortcut. Its entire job
is to move somebody from a laptop to the phone the app has to end up on, so
it is hidden at the width where that is no longer a problem. */
.qr-hop {
display: flex; align-items: center; gap: 18px;
/* Full width, on the same edges as every row around it — a panel that
centred itself in a left-aligned page was one of the rows that looked
like a different width. */
margin: 22px 0 0; padding: 16px 18px;
text-align: left;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-2);
}
.qr-hop img {
flex: none; width: 132px; height: 132px; display: block;
/* No opacity, no filters, no blend modes on a QR code. The file now
carries its own white ground so it scans on any background; anything that
washes the modules towards the page is spending the contrast a camera
needs, to no visible benefit. The small radius clips the white quiet zone
only, never the modules. */
border-radius: 6px;
}
.qr-hop p { margin: 0; font-size: 14px; line-height: 1.5; color: var(--ink-2); }
.qr-hop strong { color: var(--ink); }
@media (max-width: 640px) {
.qr-hop { display: none; }
}
/* ---------- what Android will ask ---------- */
/* Deliberately a panel, not fineprint. People were stopping at the first
Android prompt — the one that says "might be harmful" — because nothing had
told them it was coming. Somebody forewarned reads it as a step; somebody
surprised reads it as a reason not to continue. Its prominence is the whole
point of it. */
.install-steps {
margin: 26px 0 0; padding: 22px 24px;
text-align: left;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-2);
}
.install-steps h3 { margin: 0 0 8px; font-size: 17px; }
.install-lede { margin: 0 0 14px; font-size: 14px; line-height: 1.6; color: var(--ink-2); }
.install-steps ol { margin: 0; padding-left: 22px; }
.install-steps ol li {
margin: 0 0 12px; font-size: 14px; line-height: 1.6; color: var(--ink-2);
}
.install-steps ol li strong { color: var(--ink); }
.install-verify { margin: 16px 0 8px; font-size: 14px; color: var(--ink-2); }
.install-hashes { margin: 0; padding-left: 20px; }
.install-hashes li {
margin: 0 0 10px; font-size: 13px; line-height: 1.6; color: var(--ink-2);
}
/* A 64-character hash has no spaces in it, so it will not wrap on its own
and pushes the whole panel sideways on a narrow screen. */
.install-hashes code { word-break: break-all; }
.install-cmd { font-size: 12px; color: var(--ink-2); opacity: .85; }
.install-foot {
margin: 14px 0 0; padding-top: 12px; border-top: 1px solid var(--border);
font-size: 13px; line-height: 1.6; color: var(--ink-2);
}
@media (max-width: 640px) {
.install-steps { padding: 18px 16px; }
}
/* ---------- the F-Droid route ---------- */
/* Offered beside the direct download, not instead of it: F-Droid handles
updates and moves Android's warnings to a single one-off, but it costs a
person an extra app first. Both routes stay. */
.fdroid-block {
margin: 22px 0 0; padding: 22px 24px;
text-align: left;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-2);
}
.fdroid-block h3 { margin: 0 0 8px; font-size: 17px; }
.fdroid-block p { margin: 0 0 12px; font-size: 14px; line-height: 1.6; color: var(--ink-2); }
.fdroid-steps { margin-bottom: 10px !important; font-weight: 550; color: var(--ink) !important; }
.fdroid-url { margin-bottom: 14px !important; }
.fdroid-manual { font-size: 13px !important; }
/* A 64-character fingerprint has no spaces, so it will not wrap on its own
and shoves the panel off a narrow screen. */
.fdroid-manual code, .fdroid-fp code { word-break: break-all; }
.fdroid-fp { display: block; margin-top: 6px; font-size: 12px; opacity: .9; }
.fdroid-note {
margin: 0 !important; padding-top: 12px; border-top: 1px solid var(--border);
font-size: 13px !important;
}
/* The link from the landing CTA to the full install page. */
.get-more { margin: 18px auto 0; max-width: 620px; font-size: 14px; }
.get-more a { color: var(--brand); font-weight: 550; text-decoration: none; }
.get-more a:hover { text-decoration: underline; }
/* Verification fingerprints on /get. */
.verify-line { font-size: 13px; line-height: 1.8; }
.verify-line code, .verify-code { word-break: break-all; }
.verify-code {
display: block; padding: 14px 16px; margin: 0 0 14px;
font-size: 12.5px; line-height: 1.6; white-space: pre-wrap;
background: var(--bg-2); border: 1px solid var(--border);
border-radius: var(--radius); overflow-x: auto;
}
/* Releases (/releases) --------------------------------------------------
The self-hosting block is visually separate on purpose. It is the one
part of the page written for a different reader, and an operator note that
looks like a user note gets read as one — "delete this from your .env"
means nothing to somebody holding a phone. */
.release { margin: 0 0 2.5rem; }
.release h2 { margin-bottom: .25rem; }
.release h2 small { font-weight: 400; opacity: .65; }
.release-platforms { margin: .25rem 0 .75rem; }
.release-platforms .tag {
display: inline-block; font-size: .75rem; letter-spacing: .04em;
text-transform: uppercase; padding: .15rem .5rem; margin-right: .35rem;
border: 1px solid currentColor; border-radius: 999px; opacity: .7;
}
.release .selfhost {
margin-top: 1rem; padding: .85rem 1rem;
border-left: 3px solid currentColor; opacity: .8;
}
.release .selfhost h3 { margin: 0 0 .5rem; font-size: .95rem; }
.pager { display: flex; gap: 1rem; align-items: center; margin-top: 2rem; }
.pager-off { opacity: .35; }
.pager-at { opacity: .7; font-size: .9rem; }
+18 -17
View File
@@ -1,20 +1,3 @@
// The nav toggle, and nothing else.
(function () {
var toggle = document.querySelector('.nav-toggle');
var links = document.getElementById('nav-links');
if (!toggle || !links) return;
toggle.addEventListener('click', function () {
var open = links.classList.toggle('open');
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
links.addEventListener('click', function (e) {
if (e.target.tagName === 'A') {
links.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
}
});
})();
// The published Android version, read from the same manifest the app reads.
(function () {
var slot = document.querySelector('[data-apk-version]');
@@ -42,3 +25,21 @@
})
.catch(function () {});
})();
// The rail sections fold on a phone.
//
// They are open in the markup, so a desktop and a browser without JS get the
// full rail. Once the columns stack, four open groups put a screenful of
// navigation above the page, so everything below the product nav closes. The
// same 860px the stylesheet stacks at.
(function () {
var groups = document.querySelectorAll('.lcol-sec');
if (!groups.length || !window.matchMedia) return;
var stacked = window.matchMedia('(max-width: 860px)');
function apply() {
for (var i = 0; i < groups.length; i++) groups[i].open = !stacked.matches;
}
apply();
if (stacked.addEventListener) stacked.addEventListener('change', apply);
else if (stacked.addListener) stacked.addListener(apply);
})();
+827
View File
@@ -0,0 +1,827 @@
/* Flamenet Messenger, styled as a channel of the flamenet.io portal.
Ported from the flamenet-2001 WordPress theme (wp-content/themes/flamenet-2001/
style.css) so the messenger reads as part of the same site. The two stacks are
separate on purpose, so this is a copy, not a shared file: changes to the
portal chrome have to be made in both places.
Channel colour is burgundy, which is what the theme's own colour map already
assigns to messages. */
:root {
--fn-navy: #004E82;
--fn-navy-dark: #00314F;
--fn-mid: #026AAB;
--fn-chan: #963455;
--fn-chan-dark: #6F2740;
--fn-chan-tint: #F3E9ED;
--fn-left: #C4D9EF;
--fn-right: #EDF5FD;
--fn-rule: #A9C4E0;
--fn-link: #0266A8;
--fn-visited: #635190;
--fn-gold: #FFCC00;
--fn-ink: #000000;
--fn-ink-2: #333333;
--fn-ink-3: #666666;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
background-color: var(--fn-navy);
background-image: linear-gradient(180deg, #0A5C92 0%, var(--fn-navy) 45%, var(--fn-navy-dark) 100%);
background-attachment: fixed;
font-family: Tahoma, Geneva, Arial, Helvetica, sans-serif;
font-size: 11px;
color: var(--fn-ink);
line-height: 1.35;
}
a { color: var(--fn-link); text-decoration: none; }
a:visited { color: var(--fn-visited); }
a:hover, a:visited:hover { color: #FF0000; text-decoration: underline; }
img { border: 0; max-width: 100%; }
h1, h2, h3, h4 { font-family: Arial, Helvetica, sans-serif; }
.sr-only, .screen-reader-text {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.skip {
position: absolute; left: -9999px;
background: var(--fn-gold); color: #000; padding: 6px 10px; z-index: 100;
}
.skip:focus { left: 6px; top: 6px; }
#page-wrapper { width: 100%; background: #FFFFFF; }
/* Top utility bar. Carries the date and the account links, same as the portal,
but the account it offers is the messenger's own: a flamenet.io sign-in is
not a Flamenet Messenger sign-in. */
#top-bar {
background: #003F6B linear-gradient(180deg, #0A5C92 0%, #003A62 100%);
border-bottom: 1px solid #00345A;
padding: 3px 12px;
display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 2px 10px;
font-size: 10px; color: #B4D2EE;
}
#top-bar .top-bar-left { color: #B4D2EE; }
#top-bar .top-bar-right a {
color: #FFFFFF; text-decoration: none; font-size: 10px; margin-left: 12px;
}
#top-bar .top-bar-right a:visited { color: #FFFFFF; }
#top-bar .top-bar-right a:hover { color: var(--fn-gold); text-decoration: underline; }
#top-bar .top-bar-tag { color: #FFFFFF; font-weight: bold; }
/* Channel masthead. The parent brand sits above the channel name, which is the
whole cue that this is a section of flamenet.io rather than a separate site. */
#channel-masthead {
background-color: var(--fn-chan);
background-image: linear-gradient(180deg, rgba(255,255,255,0.20) 0%, rgba(255,255,255,0) 46%, rgba(0,0,0,0.28) 100%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.28);
display: flex; justify-content: space-between; align-items: flex-end;
padding: 12px 14px 13px 14px;
color: #FFFFFF;
}
.channel-title { font-family: Arial, Helvetica, sans-serif; line-height: 1; }
.ch-brand, a.ch-brand, a.ch-brand:visited {
display: inline-block;
font-size: 15px; font-weight: bold;
color: #FFFFFF; opacity: 0.8;
text-decoration: none;
text-shadow: 0 1px 1px rgba(0,0,0,0.35);
margin-bottom: 3px;
}
a.ch-brand:hover { opacity: 1; color: #FFFFFF; text-decoration: underline; }
.ch-name {
display: block;
font-size: 27px; font-weight: bold; letter-spacing: -0.5px;
color: #FFFFFF;
text-shadow: 0 1px 2px rgba(0,0,0,0.45);
}
/* The search box in the masthead searches flamenet.io, because the messenger
has nothing to search and the portal does. */
.channel-search {
display: flex; align-items: center; gap: 4px;
font-size: 11px; color: #FFFFFF; padding-bottom: 2px;
}
.channel-search label { color: #FFFFFF; white-space: nowrap; }
.channel-search input[type="search"] {
width: 150px; height: 20px;
border: 1px solid #5E7A96; padding: 1px 4px;
font-size: 11px; font-family: Tahoma, Arial, sans-serif; background: #FFFFFF;
box-shadow: inset 1px 1px 2px rgba(0,0,0,0.2);
}
/* Portal buttons: beveled, 2px radius, the era's grey gradient. */
.fs-btn, .channel-search input[type="submit"] {
height: 20px; padding: 0 9px;
background: linear-gradient(180deg, #FCFBF6 0%, #E7E3D3 52%, #D4CEB9 100%);
border: 1px solid #87826E; border-radius: 2px;
font-size: 11px; font-weight: bold; color: #000000;
font-family: Tahoma, Arial, sans-serif; cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.85);
}
.fs-btn:hover, .channel-search input[type="submit"]:hover {
background: linear-gradient(180deg, #FFFFFF 0%, #F1EEE3 52%, #DED8C5 100%);
}
/* Channel nav. These are the portal's channels, not the messenger's sections:
the tab strip is the parent site's, and Messenger is the tab you are on.
The messenger's own sections live in the left rail. */
#channel-nav {
overflow: hidden;
background-color: var(--fn-chan-dark);
background-image: linear-gradient(180deg, rgba(255,255,255,0.18) 0%, rgba(0,0,0,0.26) 100%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.20), 0 1px 3px rgba(0,0,0,0.25);
}
#channel-nav ul { list-style: none; display: flex; flex-wrap: wrap; margin: 0; padding: 0; }
#channel-nav ul li a {
display: block; padding: 4px 11px;
color: #FFFFFF; text-decoration: none;
font-size: 11px; font-weight: bold; white-space: nowrap;
border-right: 1px solid rgba(255,255,255,0.16);
text-shadow: 0 1px 1px rgba(0,0,0,0.3);
}
#channel-nav ul li a:visited { color: #FFFFFF; }
#channel-nav ul li a:hover,
#channel-nav ul li.active-tab a {
background: rgba(255,255,255,0.25); color: #FFFFFF; text-decoration: none;
}
/* Three columns, as a table so the centre column can take whatever height the
rails do not. Stacked below 860px, see the bottom of this file. */
#content-area {
display: table; width: 100%;
border-collapse: collapse; background: #FFFFFF;
}
#directory-column {
display: table-cell; width: 150px; vertical-align: top;
background: var(--fn-left) linear-gradient(180deg, #D3E2F3 0%, var(--fn-left) 28%, #B9D0EB 100%);
box-shadow: inset -9px 0 11px -9px rgba(0,24,56,0.42);
padding: 8px 8px 22px 8px;
}
#main-column {
display: table-cell; vertical-align: top;
background: #FFFFFF; padding: 8px 10px 22px 10px;
}
#sidebar-column {
display: table-cell; width: 300px; vertical-align: top;
background: var(--fn-right) linear-gradient(180deg, #F5F9FE 0%, var(--fn-right) 28%, #E1EDFA 100%);
box-shadow: inset 9px 0 11px -9px rgba(0,24,56,0.42);
padding: 8px 10px 22px 10px;
}
/* Left rail: the messenger's own sections. This is the product nav. */
#directory-column ul, .lcol-list { list-style: none; margin: 0 0 8px 0; padding: 0; }
#directory-column li { margin: 0; padding: 0; line-height: 1.4; }
#directory-column li a {
display: block; color: var(--fn-navy); text-decoration: none;
font-size: 11px; padding: 1px 2px;
}
#directory-column li a:visited { color: var(--fn-navy); }
#directory-column li a:hover { color: #FF0000; text-decoration: underline; }
#directory-column li.active-tab a { font-weight: bold; color: #CC0000; }
.lcol-hd {
color: #004277; font-weight: bold; font-size: 11px;
font-family: Arial, Helvetica, sans-serif;
padding: 3px 2px; margin-top: 4px;
border-top: 1px solid var(--fn-rule);
}
summary.lcol-hd { display: block; cursor: default; list-style: none; }
summary.lcol-hd::-webkit-details-marker { display: none; }
.lcol-note { margin: 2px 2px 8px 2px; color: #555555; font-size: 10px; line-height: 1.35; }
/* Module boxes. Coloured header, hairline body, one per topic. */
.content-box { margin-bottom: 13px; width: 100%; box-shadow: 0 1px 3px rgba(0,22,52,0.22); }
.content-box .box-header, .sidebar-widget .widget-title {
background-color: #1835A0;
background-image: linear-gradient(180deg, rgba(255,255,255,0.28) 0%, rgba(255,255,255,0) 50%, rgba(0,0,0,0.22) 100%);
color: #FFFFFF;
font-size: 11px; font-weight: bold;
font-family: Arial, Helvetica, sans-serif;
padding: 3px 6px; margin: 0;
display: flex; justify-content: space-between; align-items: center; gap: 8px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.32);
text-shadow: 0 1px 1px rgba(0,0,0,0.32);
}
.content-box .box-header a, .content-box .box-header a:visited,
.sidebar-widget .widget-title a, .sidebar-widget .widget-title a:visited { color: #FFFFFF; }
.content-box .box-header a:hover, .sidebar-widget .widget-title a:hover {
color: var(--fn-gold); text-decoration: underline;
}
.content-box .box-header > a:last-child { font-size: 10px; font-weight: normal; }
.content-box .box-body, .sidebar-widget .widget-body {
padding: 6px 8px; background: #FFFFFF;
border: 1px solid var(--fn-rule); border-top: none;
}
.cm-orange > .box-header, .cm-orange > .widget-title { background-color: #CC6600; }
.cm-green > .box-header, .cm-green > .widget-title { background-color: #2B804C; }
.cm-burgundy > .box-header, .cm-burgundy > .widget-title { background-color: #963455; }
.cm-blue > .box-header, .cm-blue > .widget-title { background-color: #1835A0; }
.cm-purple > .box-header, .cm-purple > .widget-title { background-color: #635190; }
.cm-red > .box-header, .cm-red > .widget-title { background-color: #9E4040; }
.cm-sky > .box-header, .cm-sky > .widget-title { background-color: #446CC0; }
.cm-orange > .box-body { background: #FBEEE0; }
.cm-green > .box-body { background: #EAF3ED; }
.cm-burgundy > .box-body { background: var(--fn-chan-tint); }
.cm-blue > .box-body { background: #E8ECFB; }
.cm-purple > .box-body { background: #EDE9F5; }
.cm-red > .box-body { background: #F3E9E9; }
.cm-sky > .box-body { background: #E9EEF9; }
.sidebar-widget { margin-bottom: 13px; box-shadow: 0 1px 3px rgba(0,22,52,0.22); }
#sidebar-column .sidebar-widget:nth-of-type(7n+1) .widget-title { background-color: #CC6600; }
#sidebar-column .sidebar-widget:nth-of-type(7n+2) .widget-title { background-color: #2B804C; }
#sidebar-column .sidebar-widget:nth-of-type(7n+3) .widget-title { background-color: #963455; }
#sidebar-column .sidebar-widget:nth-of-type(7n+4) .widget-title { background-color: #635190; }
#sidebar-column .sidebar-widget:nth-of-type(7n+5) .widget-title { background-color: #9E4040; }
#sidebar-column .sidebar-widget:nth-of-type(7n+6) .widget-title { background-color: #446CC0; }
#sidebar-column .sidebar-widget:nth-of-type(7n+7) .widget-title { background-color: #1835A0; }
/* A stack of links in a widget, one per line, the portal's densest pattern. */
.widget-links { list-style: none; margin: 0; padding: 0; }
.widget-links li { line-height: 1.5; border-bottom: 1px dotted #C9D9EC; }
.widget-links li:last-child { border-bottom: 0; }
.widget-links li a { display: block; padding: 2px 1px; }
/* Download callout in the right rail, the one thing on the page with a job. */
.dl-box .box-body, .dl-box .widget-body { text-align: center; }
.dl-lede { margin: 0 0 6px; font-size: 11px; color: var(--fn-ink-2); }
.dl-meta { margin: 6px 0 0; font-size: 10px; color: var(--fn-ink-3); }
/* The big icon band the portal uses for its four headline destinations. */
.big4 {
display: flex;
background: var(--fn-mid) linear-gradient(180deg, #1A86C4 0%, var(--fn-mid) 52%, #014F80 100%);
margin-bottom: 13px; padding: 6px 3px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.28), 0 1px 3px rgba(0,22,52,0.3);
}
.big4-cell, .big4-cell:visited {
flex: 1 1 0; text-align: center; color: #FFFFFF;
font-size: 10px; font-weight: bold; text-decoration: none;
padding: 2px 1px; text-shadow: 0 1px 1px rgba(0,0,0,0.35);
}
.big4-cell:hover { color: var(--fn-gold); text-decoration: underline; }
.big4-ic { display: block; font-size: 15px; margin-bottom: 2px; }
.section-divider { height: 1px; background: #DCE4F4; margin: 6px 0; }
/* Page bodies.
Twenty of the pages, including every page generated from docs, are an
<article class="band doc"> holding a <div class="wrap wrap-narrow">. Those
classes are restyled here rather than in the templates, so the generated
pages keep building from their own sources and still land inside the portal
frame. */
.band { display: block; }
.wrap { width: 100%; margin: 0; padding: 0; }
.wrap-narrow { max-width: 640px; }
.doc h1, .band h1 {
font-size: 17px; color: #003399; margin: 0 0 6px;
padding-bottom: 3px; border-bottom: 1px solid var(--fn-rule);
}
.doc h2, .band h2 {
font-size: 13px; color: #003399; margin: 14px 0 4px;
}
.doc h3, .band h3 { font-size: 12px; color: var(--fn-chan); margin: 10px 0 3px; }
.doc h4, .band h4 { font-size: 11px; color: var(--fn-ink-2); margin: 8px 0 3px; }
.doc p, .band p { margin: 0 0 7px; font-size: 11px; line-height: 1.5; }
.doc ul, .doc ol, .band ul, .band ol { margin: 0 0 8px 18px; }
.doc li, .band li { margin: 0 0 3px; font-size: 11px; line-height: 1.5; }
.doc table, .band table { border-collapse: collapse; margin: 0 0 10px; font-size: 11px; }
.doc th, .doc td, .band th, .band td {
border: 1px solid var(--fn-rule); padding: 3px 6px; text-align: left; vertical-align: top;
}
.doc th, .band th { background: #E8EFF8; font-weight: bold; }
/* A table in a generated page has no wrapper to scroll inside, and those
templates are built from docs so they cannot be given one. Making the table
a block gives it its own scroll container, and its rows still lay out as a
table inside. The comparison table already sits in .compare-scroll. */
.doc table:not(.compare), .band table:not(.compare) {
display: block;
width: max-content;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.doc hr { border: 0; border-top: 1px solid var(--fn-rule); margin: 12px 0; }
.doc blockquote {
margin: 0 0 8px; padding: 5px 8px;
background: #F5F8FC; border-left: 3px solid var(--fn-chan);
}
.eyebrow {
display: block; font-size: 10px; font-weight: bold;
letter-spacing: .06em; text-transform: uppercase; color: var(--fn-chan);
margin-bottom: 3px;
}
.lede { margin: 0 0 9px; font-size: 12px; line-height: 1.5; color: var(--fn-ink-2); }
.lede-wide, .band-lede { max-width: none; }
.band-lede-spaced { margin-top: 9px; }
.meta, .fineprint { font-size: 10px; color: var(--fn-ink-3); margin: 0 0 8px; }
.hl { color: var(--fn-chan); }
.callout {
margin: 0 0 10px; padding: 6px 8px;
background: #FFFBE6; border: 1px solid #E0C878;
}
.callout p { margin: 0; }
/* Code. Monospace on a tinted panel, and long unbroken strings wrap: a
sixty-four character hash has no spaces and would push the column sideways. */
code, kbd, samp {
font-family: "Courier New", Courier, monospace; font-size: 11px;
background: #F1F4F9; border: 1px solid #DCE4F0; padding: 0 3px;
}
pre {
margin: 0 0 10px; padding: 6px 8px;
background: #F1F4F9; border: 1px solid var(--fn-rule);
overflow-x: auto; font-size: 11px; line-height: 1.45;
}
pre code { background: none; border: 0; padding: 0; }
.code, .json, .bash, .js { display: block; }
.code-light { background: #FAFCFF; }
.btn, a.btn {
display: inline-block;
padding: 3px 12px; margin: 0 3px 3px 0;
background: linear-gradient(180deg, #FCFBF6 0%, #E7E3D3 52%, #D4CEB9 100%);
border: 1px solid #87826E; border-radius: 2px;
font-size: 11px; font-weight: bold; color: #000000;
font-family: Tahoma, Arial, sans-serif; cursor: pointer; text-decoration: none;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.85);
}
a.btn:visited { color: #000000; }
.btn:hover, a.btn:hover {
background: linear-gradient(180deg, #FFFFFF 0%, #F1EEE3 52%, #DED8C5 100%);
color: #000000; text-decoration: none;
}
/* The one button per page that is the thing to do. Gold on navy, which is the
portal's own accent pair. */
.btn-primary, a.btn-primary {
background: linear-gradient(180deg, #596EBB 0%, #1835A0 50%, #13297D 100%);
border-color: #122678; color: #FFFFFF;
}
.btn-primary:visited, a.btn-primary:visited { color: #FFFFFF; }
.btn-primary:hover, a.btn-primary:hover {
background: linear-gradient(180deg, #6B80CC 0%, #2445B5 50%, #18328F 100%);
color: var(--fn-gold);
}
.btn-nav, a.btn-nav { background: var(--fn-gold); border-color: #D1A700; color: #004E82; }
.btn-nav:visited, a.btn-nav:visited { color: #004E82; }
.cta-row { margin: 8px 0 4px; }
/* Feature grids become module rows. Cards keep their class names so the
product pages need no rewrite beyond their own layout. */
.cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin: 0 0 10px; }
.cards-3 { grid-template-columns: repeat(3, 1fr); }
.cards-4 { grid-template-columns: repeat(4, 1fr); }
.card {
padding: 6px 8px; background: #F7FAFD;
border: 1px solid var(--fn-rule); box-shadow: 0 1px 2px rgba(0,22,52,0.12);
}
.card h3 { margin: 0 0 3px; font-size: 11px; color: #003399; }
.card p { margin: 0 0 4px; font-size: 11px; }
.card-icon { display: none; }
.card-link { font-size: 10px; font-weight: bold; }
.honest { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 0 0 10px; }
.honest-card {
padding: 6px 8px; background: #FFFFFF;
border: 1px solid var(--fn-rule); border-top: 3px solid var(--fn-chan);
}
.honest-card h3 { margin: 0 0 3px; font-size: 11px; }
.honest-card p { margin: 0; font-size: 11px; }
.stack-note {
margin: 10px 0 0; padding: 6px 8px;
background: #F5F8FC; border: 1px solid var(--fn-rule); font-size: 11px;
}
.proof { display: flex; flex-wrap: wrap; gap: 3px 14px; font-size: 11px; margin: 4px 0 0; }
.proof-lead { margin: 0; font-size: 11px; }
.chip { display: inline-block; margin-right: 10px; font-size: 11px; }
.chip-ic { display: none; }
.hero-chips { margin: 6px 0 0; }
/* Donation form. Stripe styles its own fields, so this covers the frame,
the amount choices and the errors only. */
.donate-form {
margin: 0 0 12px; padding: 8px 10px;
background: #F7FAFD; border: 1px solid var(--fn-rule);
}
.donate-form fieldset { border: 0; padding: 0; margin: 0 0 10px; }
.donate-form legend {
padding: 0; margin-bottom: 5px;
font-size: 10px; font-weight: bold; letter-spacing: .06em;
text-transform: uppercase; color: var(--fn-ink-3);
}
.donate-freq, .donate-amounts { display: flex; flex-wrap: wrap; gap: 4px; }
.donate-form label {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px;
background: linear-gradient(180deg, #FCFBF6 0%, #E7E3D3 52%, #D4CEB9 100%);
border: 1px solid #87826E; border-radius: 2px;
font-size: 11px; font-weight: bold; cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.85);
}
.donate-form label:hover { background: linear-gradient(180deg, #FFFFFF 0%, #F1EEE3 52%, #DED8C5 100%); }
/* The radio stays focusable and in the accessibility tree, so it is moved
rather than hidden. */
.donate-form input[type="radio"] { position: absolute; opacity: 0; width: 0; height: 0; }
.donate-form label:has(input[type="radio"]:checked) {
background: linear-gradient(180deg, #596EBB 0%, #1835A0 50%, #13297D 100%);
border-color: #122678; color: #FFFFFF;
}
.donate-form label:has(input[type="radio"]:focus-visible) {
outline: 2px solid var(--fn-gold); outline-offset: 1px;
}
.donate-custom input[type="number"],
.donate-email input[type="email"] {
padding: 2px 5px; font-family: Tahoma, Arial, sans-serif; font-size: 11px;
border: 1px solid #6E8AA6; background: #FFFFFF;
box-shadow: inset 1px 1px 2px rgba(0,0,0,0.18);
}
.donate-custom input[type="number"] { width: 70px; }
.donate-email { margin: 8px 0 4px; }
.donate-email label {
display: block; padding: 0; border: 0; background: none;
box-shadow: none; cursor: default;
font-size: 10px; font-weight: bold; letter-spacing: .06em;
text-transform: uppercase; color: var(--fn-ink-3); margin-bottom: 4px;
}
.donate-email label:hover { background: none; }
.donate-email input[type="email"] { width: 100%; max-width: 280px; }
.donate-payment { margin: 4px 0 10px; padding-top: 10px; border-top: 1px solid var(--fn-rule); }
.donate-error {
margin: 0 0 8px; padding: 5px 8px;
background: #FDECEC; border: 1px solid #D69A9A; color: #9B1C1C; font-size: 11px;
}
.donate-thanks {
margin: 0 0 12px; padding: 6px 8px;
background: #EAF3ED; border: 1px solid #2B804C;
}
.donate-thanks p { margin: 3px 0 0; font-size: 11px; }
.donate-pending {
padding: 6px 8px; background: #F5F8FC;
border: 1px dashed var(--fn-rule); color: var(--fn-ink-3);
}
/* Comparison table. Wide, so it scrolls inside its own box rather than
widening the column. */
.compare-scroll { overflow-x: auto; margin: 0 0 12px; -webkit-overflow-scrolling: touch; }
.compare { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 11px; }
.compare th, .compare td {
border: 1px solid var(--fn-rule); padding: 4px 6px;
text-align: left; vertical-align: top;
}
.compare thead th { background: #E8EFF8; font-weight: bold; white-space: nowrap; }
.compare tbody th { background: #F5F8FC; font-weight: bold; width: 24%; }
.compare td { width: 19%; }
/* Our own column is marked, since the comparison is written by one of the
products in it. */
.compare .mine { background: var(--fn-chan-tint); }
.compare thead th.mine { background: var(--fn-chan); color: #FFFFFF; }
/* Install page panels. */
.install-steps, .fdroid-block, .qr-hop, .verify-code {
margin: 10px 0 0; padding: 6px 8px;
background: #F7FAFD; border: 1px solid var(--fn-rule);
}
.install-steps h3, .fdroid-block h3 { margin: 0 0 4px; font-size: 12px; color: #003399; }
.install-lede { margin: 0 0 6px; font-size: 11px; }
.install-steps ol { margin: 0 0 0 18px; }
.install-steps ol li, .install-hashes li { margin: 0 0 5px; font-size: 11px; line-height: 1.5; }
.install-verify { margin: 8px 0 4px; font-size: 11px; }
.install-hashes { margin: 0 0 0 18px; }
.install-cmd { font-size: 10px; color: var(--fn-ink-3); }
.install-foot, .fdroid-note {
margin: 8px 0 0; padding-top: 6px; border-top: 1px solid var(--fn-rule); font-size: 10px;
}
.fdroid-block p { margin: 0 0 6px; font-size: 11px; }
.fdroid-steps { font-weight: bold; }
.fdroid-fp { display: block; margin-top: 3px; font-size: 10px; }
/* Hashes and fingerprints have no spaces to break at. */
.install-hashes code, .fdroid-manual code, .fdroid-fp code, .verify-line code, .verify-code {
word-break: break-all;
}
.verify-code { display: block; white-space: pre-wrap; font-family: "Courier New", Courier, monospace; }
.verify-line { font-size: 11px; line-height: 1.6; }
.get-more { margin: 8px 0 0; font-size: 11px; }
/* The QR is for moving from a desktop to the phone the app ends up on, so it
is hidden at phone widths where it would point at the page already open. */
.qr-hop { display: flex; align-items: center; gap: 10px; text-align: left; }
.qr-hop img { flex: none; width: 110px; height: 110px; display: block; }
.qr-hop p { margin: 0; font-size: 11px; }
/* Releases. The self-hosting note in each entry is written for an operator,
not for somebody holding a phone, so it is set apart. */
.release { margin: 0 0 16px; padding-bottom: 12px; border-bottom: 1px solid #DCE4F4; }
.release h2 { margin-bottom: 2px; }
.release h2 small { font-weight: normal; color: var(--fn-ink-3); }
.release-platforms { margin: 2px 0 5px; }
.release-platforms .tag {
display: inline-block; font-size: 10px; letter-spacing: .04em; text-transform: uppercase;
padding: 0 5px; margin-right: 3px;
background: #E8EFF8; border: 1px solid var(--fn-rule); color: var(--fn-ink-2);
}
.release .selfhost {
margin-top: 8px; padding: 5px 8px;
background: #F5F8FC; border-left: 3px solid var(--fn-chan);
}
.release .selfhost h3 { margin: 0 0 3px; font-size: 11px; }
.pager { display: flex; gap: 10px; align-items: center; margin: 12px 0 0; font-size: 11px; }
.pager-off { color: #999999; }
.pager-at { color: var(--fn-ink-3); }
/* Footer, the portal's terms bar. */
#site-footer {
background: var(--fn-navy); padding: 8px 12px 12px 12px;
text-align: center; color: #FFFFFF;
}
#footer-links { margin-bottom: 6px; padding-bottom: 6px; border-bottom: 1px solid #6695B4; }
#footer-links a, #footer-links a:visited {
color: #FFFFFF; text-decoration: none; font-size: 10px; margin: 0 6px;
display: inline-block; line-height: 1.8;
}
#footer-links a:hover { color: var(--fn-gold); text-decoration: underline; }
/* The separate-app statement, in the same cream notice panel flamenet.io uses
for its counterpart (#footer-messenger there). Somebody who thinks one
account covers both sites has to read this, so it is a panel on the navy
rather than another line of fine print. */
#footer-separate {
max-width: 780px; margin: 0 auto 8px; padding: 8px 12px;
font-size: 11px; line-height: 1.6; text-align: center;
color: #333333;
background: #FFF9E6;
border: 1px solid #E0C97F;
}
#footer-separate strong { color: #000000; }
#footer-separate a,
#footer-separate a:visited { color: #0000CC; text-decoration: underline; }
#footer-separate a:hover { color: #CC0000; }
#footer-copyright { font-size: 9px; color: #B4D2EE; margin-top: 2px; line-height: 1.5; }
#footer-copyright a, #footer-copyright a:visited { color: var(--fn-gold); }
#footer-copyright strong { color: #FFFFFF; }
#footer-copyright .heart { color: #FF8FB0; }
/* Stats strip above the footer, the portal's site weather bar. Numbers here
are the ones a relay can state without counting people. */
#site-stats-bar {
background: #003399; color: #FFFFFF;
font-family: Arial, Helvetica, sans-serif; font-size: 11px;
padding: 5px 12px; border-top: 1px solid var(--fn-gold); text-align: center;
}
#site-stats-bar strong { color: var(--fn-gold); }
#site-stats-bar a, #site-stats-bar a:visited { color: #FFFFFF; text-decoration: underline; }
/* Stacked layout.
The three columns are table-cells, and table cells never wrap. At 375px the
fixed rails leave the centre column about 100px wide and the row still
demands more than the viewport, so the right rail becomes unreachable rather
than narrow. Stacking is the only thing a table layout can do at this width.
The look is kept: same gradients, same module chrome, the columns become
rows.
An overflow check will call the page clean either way, because the overflow
is clipped rather than scrolled. Measure scrollWidth against clientWidth. */
@media (max-width: 860px) {
#content-area { display: block; width: 100%; }
#directory-column, #main-column, #sidebar-column {
display: block; width: auto;
box-shadow: none;
}
/* The rail stays first, where navigation is looked for. Its links flow and
wrap instead of stacking into a vertical wall. */
#directory-column ul, #directory-column .lcol-list {
display: flex; flex-wrap: wrap; gap: 2px 6px; margin: 0 0 6px 0;
}
/* 11px text with 1px of padding is a 15px tap target, which is fine for a
mouse and not for a thumb. */
#directory-column li a { display: inline-block; font-size: 13px; padding: 6px 8px; }
#main-column, #sidebar-column { border-top: 1px solid #9BB6D4; }
/* Title left, search right, with a fixed 150px box that refuses to give way.
They stay on one line whenever they fit. */
#channel-masthead { flex-wrap: wrap; gap: 6px 10px; }
.channel-search { flex-wrap: wrap; }
.channel-search input[type="search"] { width: auto; flex: 1 1 130px; min-width: 0; }
summary.lcol-hd {
cursor: pointer; position: relative;
padding: 8px 24px 8px 2px; font-size: 13px;
}
summary.lcol-hd::after {
content: '+'; position: absolute; right: 6px; top: 50%;
margin-top: -0.6em; font-size: 15px; line-height: 1;
}
.lcol-sec[open] > summary.lcol-hd::after { content: '\2212'; }
.lcol-sec[open] > .lcol-list, .lcol-sec[open] > ul { margin-top: 4px; }
.cards, .cards-3, .cards-4, .honest { grid-template-columns: 1fr; }
.qr-hop { display: none; }
}
/* Bands as module boxes.
Every page body is a stack of <section class="band"> or
<article class="band doc">, each holding a .wrap. Styling that stack as the
portal's module boxes is what makes all of the pages portal pages without
editing a template, which matters for the nine pages generated from docs.
The heading becomes the coloured header bar and the eyebrow becomes the pale
tag strip above it, which is the same pair the theme's own page shell uses.
Both are pulled full bleed with negative margins so the .wrap keeps its
padding for everything else. */
.band {
margin: 0 0 13px;
background: #FFFFFF;
border: 1px solid var(--fn-rule);
box-shadow: 0 1px 3px rgba(0,22,52,0.22);
}
.band > .wrap { padding: 0 8px 7px; }
.band > .wrap > .eyebrow,
.band > .wrap > .band-head > .eyebrow {
display: block; margin: 0 -8px 0 -8px; padding: 3px 8px;
background: #EEF2FF; color: #003399;
font-size: 10px; font-weight: bold; letter-spacing: .06em; text-transform: uppercase;
border-bottom: 1px solid var(--fn-rule);
}
.band > .wrap > h1,
.band > .wrap > h2,
.band > .wrap > .band-head > h2 {
margin: 0 -8px 7px -8px; padding: 3px 8px;
background-color: var(--fn-chan);
background-image: linear-gradient(180deg, rgba(255,255,255,0.28) 0%, rgba(255,255,255,0) 50%, rgba(0,0,0,0.22) 100%);
color: #FFFFFF;
font-family: Arial, Helvetica, sans-serif;
font-size: 12px; font-weight: bold;
border: 0;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.32);
text-shadow: 0 1px 1px rgba(0,0,0,0.32);
}
.band > .wrap > h1 .hl,
.band > .wrap > h2 .hl,
.band > .wrap > .band-head > h2 .hl { color: var(--fn-gold); }
/* The portal gives consecutive modules different header colours. Element types
in the stack are mixed, so the cycle is uneven, which is what these pages
looked like anyway. */
.band:nth-of-type(7n+2) > .wrap > h2, .band:nth-of-type(7n+2) > .wrap > .band-head > h2 { background-color: #CC6600; }
.band:nth-of-type(7n+3) > .wrap > h2, .band:nth-of-type(7n+3) > .wrap > .band-head > h2 { background-color: #2B804C; }
.band:nth-of-type(7n+4) > .wrap > h2, .band:nth-of-type(7n+4) > .wrap > .band-head > h2 { background-color: #1835A0; }
.band:nth-of-type(7n+5) > .wrap > h2, .band:nth-of-type(7n+5) > .wrap > .band-head > h2 { background-color: #635190; }
.band:nth-of-type(7n+6) > .wrap > h2, .band:nth-of-type(7n+6) > .wrap > .band-head > h2 { background-color: #446CC0; }
.band:nth-of-type(7n+7) > .wrap > h2, .band:nth-of-type(7n+7) > .wrap > .band-head > h2 { background-color: #9E4040; }
.band-alt { background: #FAFCFF; }
.band-center, .band-head { text-align: left; }
.fineprint-flush { margin-bottom: 0; }
.doc-generated { }
/* The landing splash. A channel-coloured promo box with the copy on the left
and the app on the right, which is where a product section of a portal put
the thing it wanted you to download. */
.hero {
margin: 0 0 13px;
background-color: var(--fn-chan);
background-image: linear-gradient(180deg, #A94062 0%, var(--fn-chan) 52%, #7A2A45 100%);
border: 1px solid var(--fn-chan-dark);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.25), 0 1px 3px rgba(0,22,52,0.3);
}
.hero-inner { display: flex; align-items: center; gap: 14px; padding: 12px 12px 14px; }
.hero-copy { flex: 1 1 auto; min-width: 0; }
.hero .eyebrow {
display: block; background: none; border: 0; padding: 0; margin: 0 0 3px;
color: rgba(255,255,255,0.85);
}
.hero h1 {
margin: 0 0 6px; padding: 0; border: 0; background: none;
font-size: 22px; line-height: 1.15; color: #FFFFFF;
text-shadow: 0 1px 2px rgba(0,0,0,0.4);
}
.hero h1 .hl { color: var(--fn-gold); }
.hero .lede { color: #FFFFFF; font-size: 12px; max-width: 42em; }
.hero .chip { color: #FFFFFF; font-weight: bold; }
.hero .chip::before { content: '\2713\00a0'; color: var(--fn-gold); }
.hero-art { flex: none; }
.phone { display: block; }
.phone-screen {
display: block; width: 132px; height: auto;
border: 2px solid rgba(0,0,0,0.35); background: #FFFFFF;
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
}
/* The facts strip under the splash. */
.proof-band {
margin: 0 0 13px; padding: 5px 8px;
background: #E8EFF8; border: 1px solid var(--fn-rule);
}
.proof-band .wrap { padding: 0; }
/* Numbered steps. The number is a small square counter, not a circle. */
.steps { list-style: none; margin: 0; padding: 0; }
.step { position: relative; padding: 0 0 8px 22px; }
.step-n {
position: absolute; left: 0; top: 1px;
width: 16px; height: 16px; line-height: 16px; text-align: center;
background: var(--fn-chan); color: #FFFFFF;
font-weight: bold; font-size: 10px;
font-family: Arial, Helvetica, sans-serif;
}
.step h3 { margin: 0 0 2px; font-size: 11px; color: #003399; }
.step p { margin: 0; }
/* Download choices. A row of beveled tiles, one per platform. */
.platforms { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 0 0 8px; }
.platform, a.platform, a.platform:visited {
display: block; padding: 5px 8px; text-decoration: none; color: #000000;
background: linear-gradient(180deg, #FCFBF6 0%, #EFECE0 52%, #DED8C5 100%);
border: 1px solid #87826E;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.85);
}
a.platform:hover { background: linear-gradient(180deg, #FFFFFF 0%, #F6F3E9 52%, #E7E1CF 100%); color: #000000; }
.platform-name { display: block; font-weight: bold; font-size: 11px; color: #003399; }
.platform-state { display: block; font-size: 10px; color: var(--fn-ink-3); }
/* Two columns inside a module, copy beside a command block. */
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: start; }
/* Comparison verdicts. A word, then its note on the next line. */
.yes { color: #1E6B3A; font-weight: bold; }
.no { color: #9E2020; font-weight: bold; }
/* Community page cards. */
.commun { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 0 0 10px; }
.commun-card {
padding: 6px 8px; background: #F7FAFD;
border: 1px solid var(--fn-rule); border-top: 3px solid var(--fn-chan);
}
.commun-card h3 { margin: 0 0 3px; font-size: 11px; color: #003399; }
.commun-ic { display: none; }
.commun-inf { font-size: 10px; color: var(--fn-ink-3); }
/* Grouped headings inside a document, and the spec's numbered parts. */
.group-head { margin: 12px 0 4px; font-size: 12px; color: #003399; font-weight: bold; }
.part { margin: 0 0 10px; }
/* Frequently asked questions. Closed by default, opened one at a time. */
.faq details {
margin: 0 0 4px; background: #F7FAFD; border: 1px solid var(--fn-rule);
}
.faq summary {
padding: 3px 6px; cursor: pointer;
font-weight: bold; font-size: 11px; color: #003399;
}
.faq details p { margin: 0; padding: 0 6px 6px; }
.btn-ghost, a.btn-ghost { background: linear-gradient(180deg, #FCFBF6 0%, #E7E3D3 52%, #D4CEB9 100%); }
.btn-outline, a.btn-outline { background: #FFFFFF; }
@media (max-width: 860px) {
.hero-inner { flex-wrap: wrap; }
.hero-art { order: -1; }
.platforms { grid-template-columns: repeat(2, 1fr); }
.split, .commun { grid-template-columns: 1fr; }
}
-115
View File
@@ -10,7 +10,6 @@
</p>
<div class="cta-row">
<a class="btn btn-primary" href="#("#")get">Descarga la aplicación</a>
<a class="btn btn-ghost" href="#("#")how">Mira cómo funciona</a>
</div>
<div class="hero-chips">
<span class="chip"><span class="chip-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>Sin número de teléfono</span>
@@ -86,120 +85,6 @@
</div>
</section>
<section class="band band-alt">
<div class="wrap">
<span class="eyebrow">Nuevo en la app</span>
<h2>Cinco cosas nuevas en <span class="hl">el mensajero</span>.</h2>
<p class="band-lede">
Cada una funciona como funciona el resto de la app.
</p>
<p class="band-lede band-lede-spaced">
<strong>Una encuesta que nadie puede atribuir.</strong> Pregúntale algo a
un grupo y el voto de nadie se le envía a nadie, tampoco a ti que
preguntas. Cada voto se parte en trozos aleatorios, uno por miembro, y
cada cual publica solo la suma de los trozos que le tocaron. Sumando esas
sumas salen los recuentos. Todos los demás tendrían que ponerse de acuerdo
para saber lo que votó una persona.
</p>
<p class="band-lede band-lede-spaced">
<strong>Pasa una llamada a otro dispositivo.</strong> Estás en una llamada
en el teléfono, la coges en la tableta y sigues hablando. La otra persona
oye un hueco corto, se queda donde está, y le queda una llamada en su
registro y no dos.
</p>
<p class="band-lede band-lede-spaced">
<strong>Una reacción que solo ve esa persona.</strong> En un grupo,
reacciona a quien lo escribió en vez de a toda la sala. A nadie más se le
envía.
</p>
<p class="band-lede band-lede-spaced">
<strong>Reacciona durante una llamada.</strong> Di que sí sin interrumpir.
Se ve un momento y no se guarda nada.
</p>
<p class="band-lede band-lede-spaced">
<strong>Fuera de registro.</strong> Los dos teléfonos dejan de escribir la
conversación. Cierra la app y ya no está. Lo acuerdan los dos teléfonos,
así que no puede estar activo para uno y no para el otro.
</p>
<p class="fineprint">
En Android desde la versión 0.18. En iPhone, en la próxima compilación de
TestFlight.
</p>
</div>
</section>
<section class="band band-alt" id="honest">
<div class="wrap">
<div class="band-head">
<span class="eyebrow">Conviene saberlo</span>
<h2>Unas cuantas cosas <span class="hl">que conviene saber</span>.</h2>
<p class="band-lede">
Toda aplicación de mensajería tiene límites. Estos son los nuestros, dichos con claridad, para que nada de cómo funciona esto te sorprenda más adelante.
</p>
</div>
<div class="honest">
<article class="honest-card">
<h3>Que los servidores hablen entre ellos significa que más servidores ven algo</h3>
<p>
Cuando dos servidores se intercambian un mensaje, cada uno se entera de que el otro tenía tráfico para él. Nadie lee el mensaje, pero el hecho de que exista queda repartido entre más operadores de los que habría con un solo servidor. Ese es el precio de poder marcharte, y por eso un operador tiene que activarlo a propósito en lugar de encontrárselo ya puesto.
</p>
</article>
<article class="honest-card">
<h3>El cifrado cubre el mensaje, no el hecho de que lo enviaste</h3>
<p>
Nuestro servidor tiene que saber a quién entregar un mensaje, así que puede ver que le escribiste a alguien y más o menos cuándo. No puede leer ni una palabra de lo que dijiste. Eso vale para cualquier aplicación de mensajería que entregue a gente que está desconectada.
</p>
<a class="card-link" href="/metadata">Mira exactamente qué se guarda</a>
</article>
<article class="honest-card">
<h3>Todavía nos queda pendiente una auditoría externa</h3>
<p>
El código es abierto y está muy probado, y el diseño está publicado para que cualquiera pueda revisarlo. Una revisión pagada a una empresa de seguridad es otro tipo de garantía, y es una que nos gustaría poder financiar.
</p>
<a class="card-link" href="/audit">Qué cubriría una auditoría</a>
</article>
</div>
</div>
</section>
<section class="band band-center" id="how">
<div class="wrap">
<span class="eyebrow">Cómo funciona</span>
<h2>Cómo tus mensajes <span class="hl">siguen siendo privados</span>.</h2>
<p class="band-lede">
No necesitas nada de esto para usar la aplicación. Pero si alguna vez te has preguntado qué quiere decir de verdad "cifrado de extremo a extremo", se reduce a tres ideas.
</p>
<ol class="steps">
<li class="step">
<span class="step-n">1</span>
<h3>Tu teléfono fabrica una cerradura y una llave</h3>
<p>
Reparte copias de la cerradura a quien quiera escribirte. La llave que las abre se queda en tu teléfono, y no se comparte ni con nosotros ni con nadie.
</p>
</li>
<li class="step">
<span class="step-n">2</span>
<h3>Tu amigo cierra el mensaje antes de enviarlo</h3>
<p>
Para cuando llega a nuestro servidor ya está cerrado, y nosotros no tenemos la llave. Ninguno de los dos tiene que estar conectado a la vez para que esto funcione.
</p>
</li>
<li class="step">
<span class="step-n">3</span>
<h3>Cada mensaje lleva una cerradura nueva</h3>
<p>
Las cerraduras van cambiando conforme avanza la conversación, así que aunque alguna llegara a abrirse, no serviría de nada con los mensajes anteriores.
</p>
</li>
</ol>
<p class="fineprint">
Las tres ideas tienen nombres técnicos propios, y si quieres la versión completa con los detalles, está todo en
<a href="/spec">la especificación</a>.
</p>
</div>
</section>
<!-- **Short on purpose. The detail lives at /get.**
This section had grown to hold the platform tiles, a QR code, a
three-prompt install walkthrough, the F-Droid repository, verification
-1
View File
@@ -222,7 +222,6 @@
</p>
</article>
</div>
</div>
</div>
</section>
-127
View File
@@ -12,7 +12,6 @@
</p>
<div class="cta-row">
<a class="btn btn-primary" href="#("#")get">Get the app</a>
<a class="btn btn-ghost" href="#("#")how">See how it works</a>
</div>
<div class="hero-chips">
<span class="chip"><span class="chip-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>No phone number</span>
@@ -98,132 +97,6 @@
</div>
</section>
<section class="band band-alt">
<div class="wrap">
<span class="eyebrow">New in the app</span>
<h2>Five new things in <span class="hl">the messenger</span>.</h2>
<p class="band-lede">
Each one works the way the rest of the app does.
</p>
<p class="band-lede band-lede-spaced">
<strong>A poll nobody can attribute.</strong> Ask a group something and no
vote is sent to anybody, including you as the person asking. Each vote is
split into random pieces, one per member, and everybody publishes only the
sum of the pieces they were handed. Adding those sums gives the counts.
Everyone other than the voter would have to work together to know how one
person voted.
</p>
<p class="band-lede band-lede-spaced">
<strong>Carry a call to another device.</strong> On a call on your phone,
pick it up on the tablet and keep talking. The other person hears a short
gap, stays where they are, and gets one call in their log rather than two.
</p>
<p class="band-lede band-lede-spaced">
<strong>A reaction only they see.</strong> In a group, react to the person
who wrote it rather than to the room. Nobody else is sent it.
</p>
<p class="band-lede band-lede-spaced">
<strong>React during a call.</strong> Say yes without interrupting. It
shows for a moment and nothing is saved.
</p>
<p class="band-lede band-lede-spaced">
<strong>Off the record.</strong> Both phones stop writing the conversation
down. Close the app and it is gone. It is agreed between the two phones, so
it cannot be on for one of you and not the other.
</p>
<p class="fineprint">
On Android from version 0.18. On iPhone in the next TestFlight build.
</p>
</div>
</section>
<section class="band band-alt" id="honest">
<div class="wrap">
<div class="band-head">
<span class="eyebrow">Good to know</span>
<h2>A few things <span class="hl">worth knowing</span>.</h2>
<p class="band-lede">
Every messenger has limits. These are ours, in plain terms, so that nothing
about how this works comes as a surprise later on.
</p>
</div>
<div class="honest">
<article class="honest-card">
<h3>Relays talking to each other means more servers see something</h3>
<p>
When two relays exchange a message, each one learns that the other had
traffic for it. Nobody reads the message, but the fact of it is spread
across more operators than a single server would be. That is the trade
for being able to leave, and it is why an operator has to switch it on
deliberately rather than finding it already on.
</p>
</article>
<article class="honest-card">
<h3>Encryption covers the message, not the fact you sent one</h3>
<p>
Our server has to know who to hand a message to, so it can see that you wrote
to someone and roughly when. It cannot read a word of what you said. That is
true of any messenger that delivers to people who are offline.
</p>
<a class="card-link" href="/metadata">See exactly what's kept</a>
</article>
<article class="honest-card">
<h3>An outside audit is still ahead of us</h3>
<p>
The code is open and heavily tested, and the design is published so anyone can
review it. A paid review by a security firm is a different kind of assurance,
and it is one we would like to fund.
</p>
<a class="card-link" href="/audit">What an audit would cover</a>
</article>
</div>
</div>
</section>
<section class="band band-center" id="how">
<div class="wrap">
<span class="eyebrow">How it works</span>
<h2>How your messages <span class="hl">stay private</span>.</h2>
<p class="band-lede">
You don't need any of this to use the app. But if you have ever wondered what
"end-to-end encrypted" actually means, it comes down to three ideas.
</p>
<ol class="steps">
<li class="step">
<span class="step-n">1</span>
<h3>Your phone makes a lock and a key</h3>
<p>
It hands out copies of the lock to anyone who wants to write to you. The key
that opens them stays on your phone, and isn't shared with us or anyone
else.
</p>
</li>
<li class="step">
<span class="step-n">2</span>
<h3>Your friend locks the message before sending</h3>
<p>
By the time it reaches our server it's already shut, and we don't have the key.
Neither of you has to be online at the same time for this to work.
</p>
</li>
<li class="step">
<span class="step-n">3</span>
<h3>Every message gets a fresh lock</h3>
<p>
The locks change as the conversation goes along, so even if one were ever
opened, it wouldn't help with the messages that came before it.
</p>
</li>
</ol>
<p class="fineprint">
All three ideas have proper technical names, and if you would like the full
version with the details filled in, it is all in
<a href="/spec">the specification</a>.
</p>
</div>
</section>
<!-- **Short on purpose. The detail lives at /get.**
This section had grown to hold the platform tiles, a QR code, a
three-prompt install walkthrough, the F-Droid repository, verification
+194 -102
View File
@@ -23,7 +23,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png?v=#(assetVersion)">
<link rel="icon" type="image/png" sizes="16x16" href="/brand/favicon-16.png?v=#(assetVersion)">
<link rel="apple-touch-icon" href="/brand/apple-touch-icon.png?v=#(assetVersion)">
<link rel="stylesheet" href="/landing.css?v=#(assetVersion)">
<link rel="stylesheet" href="/portal.css?v=#(assetVersion)">
<script type="application/ld+json">
{
"@context": "https://schema.org",
@@ -49,116 +49,208 @@
<a class="skip" href="#("#")main">#(chrome.skip)</a>
<div class="topbar">
<div class="wrap">
<span class="topbar-tag">#(chrome.openTag)</span>
<span>#(chrome.freeOpen)</span>
<a href="/source">#(chrome.readCode)</a>
</div>
</div>
<div id="page-wrapper">
<nav class="nav">
<div class="wrap nav-inner">
<a class="brand" href="/">
<img class="mark" src="/brand/mark-128.png?v=#(assetVersion)" alt="" aria-hidden="true"
width="30" height="30">
<span>Flamenet&nbsp;Messenger</span>
</a>
<button class="nav-toggle" aria-expanded="false" aria-controls="nav-links" aria-label="#(chrome.menu)">
<span></span><span></span><span></span>
</button>
<div class="nav-links" id="nav-links">
<div id="top-bar">
<span class="top-bar-left">
<span class="top-bar-tag">#(chrome.openTag)</span>
#(chrome.freeOpen)<a href="/source">#(chrome.readCode)</a>
</span>
<span class="top-bar-right">
<a href="/get">#(chrome.getIt)</a>
<a href="/donate">#(chrome.donate)</a>
<a href="/support">#(chrome.footSupport)</a>
<a href="/contact">#(chrome.footContact)</a>
</span>
</div>
<!-- Channel masthead. The parent brand sits above the channel name, which is
how a section of the portal is headed. -->
<div id="channel-masthead">
<div class="channel-title">
<a class="ch-brand" href="https://flamenet.io/" title="#(chrome.portalHome)">FlameNet</a>
<span class="ch-name">#(chrome.channel)</span>
</div>
<!-- Searches flamenet.io. The messenger has nothing to search and the
portal does. -->
<form class="channel-search" role="search" method="get" action="https://flamenet.io/">
<label for="portal-search">#(chrome.webSearch)</label>
<input type="search" id="portal-search" name="s" value="">
<input type="submit" value="#(chrome.searchGo)">
</form>
</div>
<!-- The portal's channels, not the messenger's sections. These are the names
of flamenet.io sections and are not translated, because the pages they
lead to are in English. The messenger's own sections are in the rail. -->
<nav id="channel-nav">
<ul>
<li><a href="https://flamenet.io/">Web</a></li>
<li><a href="https://flamenet.io/category/news/">News</a></li>
<li><a href="https://flamenet.io/category/technology/">Tech</a></li>
<li><a href="https://flamenet.io/category/entertainment/">Entertainment</a></li>
<li><a href="https://flamenet.io/category/sports/">Sports</a></li>
<li><a href="https://flamenet.io/forum/">Forums</a></li>
<li><a href="https://flamenet.io/directory/">Directory</a></li>
<li><a href="https://flamenet.io/files/">Files</a></li>
<li class="active-tab"><a href="/">#(chrome.channel)</a></li>
</ul>
</nav>
<div id="content-area">
<div id="directory-column">
<div class="lcol-hd">#(chrome.railProduct)</div>
<ul class="lcol-list">
<li#if(section == "get"): class="active-tab"#endif><a href="/get">#(chrome.footGetIt)</a></li>
<li#if(section == "messages"): class="active-tab"#endif><a href="/messages">#(chrome.messages)</a></li>
<li#if(section == "files"): class="active-tab"#endif><a href="/files">#(chrome.files)</a></li>
<li#if(section == "wallet"): class="active-tab"#endif><a href="/wallet">#(chrome.wallet)</a></li>
<li#if(section == "comparison"): class="active-tab"#endif><a href="/comparison">#(chrome.compare)</a></li>
<li#if(section == "about"): class="active-tab"#endif><a href="/about">#(chrome.about)</a></li>
<li#if(section == "donate"): class="active-tab"#endif><a href="/donate">#(chrome.donate)</a></li>
</ul>
<details class="lcol-sec" open>
<summary class="lcol-hd">#(chrome.footDevelopers)</summary>
<ul class="lcol-list">
<li#if(section == "source"): class="active-tab"#endif><a href="/source">#(chrome.footSource)</a></li>
<li#if(section == "spec"): class="active-tab"#endif><a href="/spec">#(chrome.footSpec)</a></li>
<li#if(section == "security"): class="active-tab"#endif><a href="/security">#(chrome.footSecurity)</a></li>
<li#if(section == "audit"): class="active-tab"#endif><a href="/audit">#(chrome.footAudit)</a></li>
<li#if(section == "self-hosting"): class="active-tab"#endif><a href="/self-hosting">#(chrome.footSelfHost)</a></li>
</ul>
</details>
<details class="lcol-sec" open>
<summary class="lcol-hd">#(chrome.footProject)</summary>
<ul class="lcol-list">
<li#if(section == "community"): class="active-tab"#endif><a href="/community">#(chrome.footCommunity)</a></li>
<li#if(section == "support"): class="active-tab"#endif><a href="/support">#(chrome.footSupport)</a></li>
<li#if(section == "safety"): class="active-tab"#endif><a href="/safety">#(chrome.footHelp)</a></li>
<li#if(section == "contact"): class="active-tab"#endif><a href="/contact">#(chrome.footContact)</a></li>
<li#if(section == "roadmap"): class="active-tab"#endif><a href="/roadmap">#(chrome.footRoadmap)</a></li>
<li><a href="/releases">#(chrome.footNews)</a></li>
<li><a href="/status">#(chrome.footStatus)</a></li>
</ul>
</details>
<details class="lcol-sec" open>
<summary class="lcol-hd">#(chrome.footLegal)</summary>
<ul class="lcol-list">
<li#if(section == "terms"): class="active-tab"#endif><a href="/terms">#(chrome.footTerms)</a></li>
<li#if(section == "privacy"): class="active-tab"#endif><a href="/privacy">#(chrome.footPrivacy)</a></li>
<li#if(section == "metadata"): class="active-tab"#endif><a href="/metadata">#(chrome.footKeep)</a></li>
<li#if(section == "licenses"): class="active-tab"#endif><a href="/licenses">#(chrome.footLicences)</a></li>
</ul>
</details>
<p class="lcol-note">#(chrome.railNote)</p>
</div>
<div id="main-column">
<main id="main">
#import("content")
</main>
</div>
<div id="sidebar-column">
<div class="sidebar-widget dl-box">
<div class="widget-title">#(chrome.railGetTitle)</div>
<div class="widget-body">
<p class="dl-lede">#(chrome.railGetBody)</p>
<a class="btn btn-primary" href="/get">#(chrome.footGetIt)</a>
<p class="dl-meta">#(chrome.railGetMeta)</p>
</div>
</div>
<div class="sidebar-widget">
<div class="widget-title">#(chrome.railReadTitle)</div>
<div class="widget-body">
<ul class="widget-links">
<li><a href="/comparison">#(chrome.footCompare)</a></li>
<li><a href="/metadata">#(chrome.footKeep)</a></li>
<li><a href="/security">#(chrome.footSecurity)</a></li>
<li><a href="/spec">#(chrome.footSpec)</a></li>
<li><a href="/status">#(chrome.footStatus)</a></li>
</ul>
</div>
</div>
<!-- The cross link back to the portal, which is the other half of the
relationship: flamenet.io carries this site in its nav. -->
<div class="sidebar-widget">
<div class="widget-title">#(chrome.railPortalTitle)</div>
<div class="widget-body">
<ul class="widget-links">
<li><a href="https://flamenet.io/">FlameNet</a></li>
<li><a href="https://flamenet.io/forum/">Forums</a></li>
<li><a href="https://flamenet.io/directory/">Directory</a></li>
<li><a href="https://flamenet.io/chatrooms/">Chatrooms</a></li>
<li><a href="https://flamenetfiles.com/">Flamenet Files</a></li>
</ul>
<p class="dl-meta">#(chrome.railNote)</p>
</div>
</div>
<div class="sidebar-widget">
<div class="widget-title">#(chrome.ctaButton)</div>
<div class="widget-body">
<p class="dl-lede">#(chrome.ctaBody)</p>
<a class="btn" href="/self-hosting">#(chrome.footSelfHost)</a>
<a class="btn" href="/spec">#(chrome.ctaLink)</a>
</div>
</div>
<div class="sidebar-widget">
<div class="widget-title">#(chrome.donate)</div>
<div class="widget-body">
<p class="dl-lede">#(chrome.railDonateBody)</p>
<a class="btn btn-primary" href="/donate">#(chrome.donate)</a>
</div>
</div>
</div>
</div>
<div id="site-stats-bar">#(chrome.statsLine)</div>
<div id="site-footer">
<div id="footer-links">
<a href="/">#(chrome.channel)</a>
<a href="/get">#(chrome.footGetIt)</a>
<a href="/messages">#(chrome.messages)</a>
<a href="/files">#(chrome.files)</a>
<a href="/wallet">#(chrome.wallet)</a>
<a href="/comparison">#(chrome.compare)</a>
<a href="/#("#")developers">#(chrome.developers)</a>
<a href="/comparison">#(chrome.footCompare)</a>
<a href="/about">#(chrome.about)</a>
<a class="nav-donate-menu" href="/donate">#(chrome.donate)</a>
<a href="/support">#(chrome.footSupport)</a>
<a href="/safety">#(chrome.footHelp)</a>
<a href="/contact">#(chrome.footContact)</a>
<a href="/releases">#(chrome.footNews)</a>
<a href="/terms">#(chrome.footTerms)</a>
<a href="/privacy">#(chrome.footPrivacy)</a>
<a href="/licenses">#(chrome.footLicences)</a>
<a href="/.well-known/security.txt">security.txt</a>
<a href="https://flamenet.io/">FlameNet</a>
</div>
<div class="nav-cta">
<a class="btn btn-outline nav-donate" href="/donate">#(chrome.donate)</a>
<a class="btn btn-nav" href="/get">#(chrome.getIt)</a>
<div id="footer-separate">
<strong>#(chrome.footSeparateLead)
<a href="https://flamenet.io/" rel="noopener">FlameNet.io</a>.</strong>
#(chrome.footSeparateBody)
</div>
</div>
</nav>
<main id="main">
#import("content")
</main>
<footer class="footer">
<div class="wrap">
<div class="footer-cta">
<h2>#(chrome.ctaLead) <span class="hl">#(chrome.ctaLeadHl)</span>.</h2>
<p>#(chrome.ctaBody)</p>
<div class="cta-row footer-cta-row">
<a class="btn btn-primary" href="/self-hosting">#(chrome.ctaButton)</a>
<a class="btn btn-ghost" href="/spec">#(chrome.ctaLink)</a>
</div>
</div>
<div class="footer-cols">
<div class="footer-brand">
<img class="mark" src="/brand/mark-128.png?v=#(assetVersion)" alt="" aria-hidden="true"
width="30" height="30">
<span>Flamenet&nbsp;Messenger</span>
</div>
<nav class="footer-col" aria-label="#(chrome.footProduct)">
<h4>#(chrome.footProduct)</h4>
<a href="/get">#(chrome.footGetIt)</a>
<a href="/messages">#(chrome.messages)</a>
<a href="/files">#(chrome.files)</a>
<a href="/wallet">#(chrome.wallet)</a>
<a href="/comparison">#(chrome.footCompare)</a>
<a href="/#("#")selfhost">#(chrome.footSelfHost)</a>
</nav>
<nav class="footer-col" aria-label="Developers">
<h4>#(chrome.footDevelopers)</h4>
<a href="/source">#(chrome.footSource)</a>
<a href="/spec">#(chrome.footSpec)</a>
<a href="/security">#(chrome.footSecurity)</a>
<a href="/audit">#(chrome.footAudit)</a>
<a href="/.well-known/security.txt">security.txt</a>
</nav>
<nav class="footer-col" aria-label="#(chrome.footProject)">
<h4>#(chrome.footProject)</h4>
<a href="/about">#(chrome.about)</a>
<a href="/community">#(chrome.footCommunity)</a>
<a href="/support">#(chrome.footSupport)</a>
<a href="/safety">#(chrome.footHelp)</a>
<a href="/contact">#(chrome.footContact)</a>
<a href="/roadmap">#(chrome.footRoadmap)</a>
<!-- Beside Roadmap on purpose: one is what is coming, the other is
what actually landed, and the pair is more honest than either
alone. "What's new" rather than "Releases", the people this
footer is for want to know what changed, not to read a changelog. -->
<a href="/releases">#(chrome.footNews)</a>
<a href="/status">#(chrome.footStatus)</a>
<a href="/donate">#(chrome.donate)</a>
</nav>
<nav class="footer-col" aria-label="#(chrome.footLegal)">
<h4>#(chrome.footLegal)</h4>
<a href="/terms">#(chrome.footTerms)</a>
<a href="/privacy">#(chrome.footPrivacy)</a>
<a href="/metadata">#(chrome.footKeep)</a>
<a href="/licenses">#(chrome.footLicences)</a>
</nav>
</div>
<p class="footer-legal">
#(chrome.footLicence) Signal is a
trademark of Signal Messenger, LLC; this project is not affiliated with or endorsed
by them. Proudly hosted at
<a class="host-link" href="https://hitthosting.com">Hitt Hosting</a>
<div id="footer-copyright">
<strong>#(chrome.footLicence)</strong><br>
Signal is a trademark of Signal Messenger, LLC; this project is not affiliated
with or endorsed by them.<br>
Flamenet is a registered trade name of Extant 2000 LLC. Proudly hosted at
<a href="https://hitthosting.com">Hitt Hosting</a>
<span class="heart" aria-hidden="true">&#9829;</span>
</p>
</div>
</div>
</footer>
</div>
<script src="/landing.js?v=#(assetVersion)"></script>
</body>
-1
View File
@@ -212,7 +212,6 @@
</p>
</article>
</div>
</div>
</div>
</section>
+157 -1
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/docs/SPEC.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 7e3a8724ef27a6da -->
source-sha256: 9c3e570b61488cd7 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-messenger-end-to-end-encryption-spec">Flamenet
@@ -1405,6 +1405,162 @@ promise was made there is nothing to report against.</p>
<p>A client SHOULD send at most one notice per conversation per short
window. A screenshot often produces several platform events, and a
person who took one screenshot should not appear to have taken four.</p>
<h3 id="628-off-the-record">6.28 Off the record</h3>
<p>A conversation both ends agree not to write down. While it is on,
neither client persists the messages: they live in memory for as long as
the app is open and are gone when it closes. Nothing is deleted
afterwards, because nothing was ever written.</p>
<pre class="json"><code>{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;otr&quot;,&quot;ctl&quot;:1,&quot;otr&quot;:1}
{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;otr&quot;,&quot;ctl&quot;:1,&quot;otr&quot;:1,&quot;echo&quot;:1}
{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;otr&quot;,&quot;ctl&quot;:1,&quot;otr&quot;:0}</code></pre>
<p><code>ctl</code> is set (§6.10), so a build that does not know
<code>otr</code> ignores it rather than rendering it, and, critically,
never turns the mode on. That is why it is a <strong>proposal and an
echo</strong> rather than a single announcement. The proposing client
sets the mode locally only when the echo comes back. A client that
receives a proposal and supports the mode turns it on and replies with
the same envelope carrying <code>echo</code>, which MUST NOT itself be
echoed.</p>
<p>The handshake exists because the alternative is worse than the
feature. If one side believed the conversation was off the record and
the other was writing it to disk, the promise would be false in exactly
the case somebody relied on it. So the mode is never on for one end
alone: a proposal that is not echoed leaves both ends exactly as they
were, and the proposing client MUST say the other end did not agree
rather than showing the mode as on.</p>
<p>Turning it off (<code>"otr":0</code>) needs no echo. Either end may
stop at any time, and stopping is not something the other end can
refuse.</p>
<p><strong>What it does not do.</strong> It stops this app writing the
conversation to this device's storage. It does not stop a screenshot
(§6.27), a photograph of the screen, a modified build, or a platform
backup of anything that reached the screen. A client MUST describe it as
"neither phone writes this down" and MUST NOT describe it as messages
that cannot be kept.</p>
<p>Messages sent while it is on are ordinary envelopes. Nothing on the
wire marks them, and the relay learns nothing: the mode is a rule about
what the two clients do with plaintext they have already decrypted.</p>
<h3 id="629-polls-nobody-can-attribute">6.29 Polls nobody can
attribute</h3>
<p>A poll asked of a group where no individual vote is ever sent to
anybody, including the person who created it and including the
server.</p>
<p>Hiding a vote in the interface is not hiding it. In every messenger
that offers an "anonymous" poll today, the vote reaches somebody: a
server, or the poll's creator, or both. Group messages here are
encrypted to each member separately, so the recipient of any envelope
knows who sent it, and sending "I chose B" to anybody at all attributes
it.</p>
<p><strong>Secure aggregation.</strong> A vote is split into random
pieces that sum to it, one piece per member. Each member is handed one
piece of everybody's vote, publishes only the sum of the pieces they
hold, and the published sums add up to the counts.</p>
<pre class="json"><code>{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;poll&quot;,&quot;ctl&quot;:1,&quot;pid&quot;:&quot;&lt;uuid&gt;&quot;,&quot;q&quot;:&quot;&lt;question&gt;&quot;,&quot;opts&quot;:[&quot;&lt;a&gt;&quot;,&quot;&lt;b&gt;&quot;],&quot;mset&quot;:&quot;&lt;member set&gt;&quot;,&quot;gid&quot;:&quot;&lt;group&gt;&quot;}
{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;pshare&quot;,&quot;ctl&quot;:1,&quot;pid&quot;:&quot;&lt;uuid&gt;&quot;,&quot;v&quot;:[1732584193,-1732584192],&quot;gid&quot;:&quot;&lt;group&gt;&quot;}
{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;psum&quot;,&quot;ctl&quot;:1,&quot;pid&quot;:&quot;&lt;uuid&gt;&quot;,&quot;v&quot;:[-604832771,604832772],&quot;gid&quot;:&quot;&lt;group&gt;&quot;}</code></pre>
<p><code>ctl</code> is set on all three (§6.10), so a build that does
not know them ignores them rather than rendering a poll it cannot take
part in.</p>
<p><strong>The vector.</strong> <code>v</code> is one signed 32-bit
integer per option, in the order <code>opts</code> gives. A vote for
option <code>i</code> is the vector that is 1 at <code>i</code> and 0
elsewhere. Arithmetic wraps at 32 bits, and the wrapping is what does
the hiding: every piece but the last is a uniform random integer, the
last is whatever makes the set add up to the vote, and no single piece
says anything about the vote it came from. Implementations MUST use
wrapping addition over the full 32-bit range. A language that traps on
overflow MUST mask rather than throw.</p>
<p><strong><code>mset</code></strong> names the members the arithmetic
is agreed over, and every client MUST compute its pieces against that
list in that order. Two clients disagreeing about who is in the poll
produce sums that do not add up, and the result is a wrong tally rather
than an error, which is the failure this field exists to prevent.</p>
<p><strong>The guarantee, exactly.</strong> Every member other than the
voter would have to collude to recover one vote. That is what this
provides. It is not "nobody can ever know", and a client MUST state the
collusion bound rather than the shorter claim.</p>
<p>The bound is worth reading carefully at small sizes. Against a group
of two it is empty: the other member holds a piece of your vote and sees
the tally, and subtracting one from the other gives your vote with no
collusion at all, because there is nobody to collude with. The
arithmetic has no threshold to enforce this at, so a client MUST NOT
offer the word "anonymous" for a poll of two, and SHOULD set a floor
below which it does not offer one.</p>
<p><strong>Who voted is visible; what they voted is not.</strong>
Holding a piece of somebody's vote is how a member learns that person
has voted, and a client needs that to know when the poll can proceed.
The scheme hides the content of a vote, never the fact of one. A client
MUST NOT present participation as secret, and MAY show who has voted,
which is also the only honest way to say who a poll is still waiting
on.</p>
<p><strong>Everybody publishes, or there is no tally.</strong> A missing
sum does not give a smaller count. It leaves a uniform random number in
the total, so a partial result is not a partial answer but a meaningless
one. A client MUST wait for a sum from every member in <code>mset</code>
and MUST NOT show a result before then. It MUST say plainly that it is
waiting, and on whom.</p>
<p>There is deliberately no fallback to a poll that is merely hidden. A
fallback that kept the word "anonymous" while quietly sending votes to
somebody is the one outcome that must not ship, and offering it under
another name in the same interface invites exactly that confusion.</p>
<p><strong>Limits.</strong> Share traffic grows with the square of the
membership: every member sends a piece to every member. A client MUST
NOT start a poll in a group larger than it can serve and MUST say so
rather than starting one that will never complete. <code>opts</code>
carries at least 2 and at most 12 options.</p>
<h3 id="630-handing-a-call-to-another-of-your-devices">6.30 Handing a
call to another of your devices</h3>
<p>On a call on the phone, walk to the desk, take the call on the
tablet, and keep talking. The other person hears a short gap and nothing
else.</p>
<p>This account's own devices already have a private channel during a
call: the device that answers tells the others to stop ringing (§6.8).
Handoff is that channel carrying more.</p>
<pre class="json"><code>{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;call-handoff&quot;,&quot;call&quot;:{&quot;id&quot;:&quot;&lt;uuid&gt;&quot;,&quot;peer&quot;:4211,&quot;video&quot;:false}}
{&quot;fnc&quot;:1,&quot;kind&quot;:&quot;call-handoff-taken&quot;,&quot;call&quot;:{&quot;id&quot;:&quot;&lt;uuid&gt;&quot;}}</code></pre>
<p>Both go to this account's own devices and to nobody else, by the same
routing check the stop-ringing path already makes. Neither is ever sent
to the peer.</p>
<p><strong>The sequence.</strong></p>
<ol type="1">
<li>The device on the call offers it to this account's other devices,
naming the call and the peer. Only an idle device offers to take
it.</li>
<li>The taking device builds a fresh connection and sends the
<strong>peer</strong> a <code>call-offer</code> under the same
<code>call.id</code>.</li>
<li>The peer treats an offer for a call it is already on as a
renegotiation: it answers, stays on the call screen, and logs one call
rather than two.</li>
<li>The taking device sends <code>call-handoff-taken</code> to its own
devices. The original tears down its connection and sends the peer
nothing.</li>
</ol>
<p><strong>The peer is never hung up on.</strong> No
<code>call-end</code> is sent to the peer at any point during a handoff,
in either direction. An end would make the far end drop the call and
redial, which is the failure this whole sequence exists to avoid. The
original device MUST tear down locally and silently.</p>
<p><strong>A handoff that half happens leaves the original
working.</strong> If no device takes the call within a few seconds, the
offer expires on both sides and the call stays exactly where it is.
Expiry is a timeout at both ends rather than a withdrawal envelope, so a
lost withdrawal cannot leave a device believing an offer is still
live.</p>
<p><strong>Whether the peer can take a renegotiating offer is known
before the handoff starts.</strong> A client that accepts a
renegotiating <code>call-offer</code> sets <code>"rnv":1</code> on the
<code>call-offer</code> and <code>call-answer</code> it sends, so both
ends learn it while the call is being set up. A client MUST NOT offer a
handoff for a call whose peer did not set it. Discovering that the far
end cannot renegotiate by dropping the call is the wrong way to find
out.</p>
<p>A device that does not understand <code>call-handoff</code> ignores
it, which is correct: it does not offer to take the call, and the call
stays where it is.</p>
<p><strong>The relay learns nothing new.</strong> These ride the ratchet
like every other call signal, and the same envelopes to this account's
own devices already exist for stopping a ring.</p>
<h2 id="7-safety-numbers-verification">7. Safety numbers
(verification)</h2>
<p>For a local identity <code>IK_sig_local_pub</code> and remote
+64 -3
View File
@@ -417,6 +417,7 @@ func register(routes r: Routes, on app: Application) throws {
audience: r.config.audience,
report: await StatusPage.report(
config: r.config, db: req.db, store: r.attachmentStore))
page.section = "status"
page.canonical = publicOrigin(req) + req.url.path
page.origin = publicOrigin(req)
return try await req.view.render("status", page)
@@ -622,6 +623,7 @@ func register(routes r: Routes, on app: Application) throws {
&& r.config.donatePublishableKey != nil,
thanks: req.query[String.self, at: "thanks"] != nil,
processorName: (r.config.donateProvider ?? "the processor").capitalized)
page.section = "donate"
page.canonical = publicOrigin(req) + req.url.path
page.origin = publicOrigin(req)
return try await req.view.render("donate", page)
@@ -645,6 +647,7 @@ func register(routes r: Routes, on app: Application) throws {
releases: slice.items,
page: slice.page,
pages: slice.pages)
page.section = "releases"
page.canonical = publicOrigin(req) + req.url.path
page.origin = publicOrigin(req)
return try await req.view.render("releases", page)
@@ -830,6 +833,7 @@ func register(routes r: Routes, on app: Application) throws {
try await req.view.render(
slug,
LandingContext(
section: slug,
hasAlternate: translated.contains(slug),
altLang: translated.contains(slug) ? "es" : "",
altHref: translated.contains(slug)
@@ -888,6 +892,7 @@ func register(routes r: Routes, on app: Application) throws {
"es/" + slug,
LandingContext(
lang: "es",
section: slug,
hasAlternate: true,
chrome: .spanish,
altLang: "en",
@@ -1453,7 +1458,7 @@ enum AssetVersion {
static let value: String = {
// The brand images are in here too, and belong here.
let paths = [
"Public/landing.css", "Public/landing.js", "Public/donate.js",
"Public/portal.css", "Public/landing.js", "Public/donate.js",
"Public/brand/mark-128.png", "Public/brand/og.png",
"Public/brand/apple-touch-icon.png",
"Public/shots/conversation.png", "Public/shots/conversation@3x.png",
@@ -1470,6 +1475,9 @@ struct DonateContext: Encodable {
/// Renders the shared layout, so it carries the same chrome, see
/// `LandingContext.chrome`.
var lang: String = "en"
/// Which left-rail entry is the page being rendered, as a page slug.
/// Empty on pages the rail does not list.
var section: String = ""
var hasAlternate: Bool = false
var chrome: LandingContext.Chrome = LandingContext.Chrome()
/// Every context the shared layout renders needs these.
@@ -1491,6 +1499,9 @@ struct LandingContext: Encodable {
/// The language this render is in, `<html lang>`, and the hreflang of the
/// canonical.
var lang: String = "en"
/// Which left-rail entry is the page being rendered, as a page slug.
/// Empty on pages the rail does not list.
var section: String = ""
/// Whether this page has a translation at all, as a Bool rather than by
/// testing `altHref` for emptiness.
var hasAlternate: Bool = false
@@ -1513,7 +1524,6 @@ struct LandingContext: Encodable {
var files = "Files"
var wallet = "Wallet"
var compare = "Compare"
var developers = "Developers"
var about = "About"
var donate = "Donate"
var getIt = "Get\u{00A0}it"
@@ -1550,6 +1560,34 @@ struct LandingContext: Encodable {
var footKeep = "What we keep"
var footLicences = "Licenses"
var footLicence = "Flamenet Messenger is free software, published under the MIT licence."
// On every page, because people arrive here believing a flamenet.io
// sign-in works. Written from this site's side: the same statement on
// flamenet.io points the other way.
// Split so the lead can carry the bold and the link. Chrome strings are
// escaped by Leaf, so markup in one prints literally.
var footSeparateLead = "Flamenet Messenger is a separate app from"
var footSeparateBody =
"It lives on its own domain, is end-to-end encrypted, and has its own sign-in: "
+ "a FlameNet.io account does not work here, and a Flamenet Messenger account does "
+ "not work there."
// The portal chrome: the masthead, the left rail headings and the
// widgets in the right rail.
var portalHome = "FlameNet home"
var channel = "Messenger"
var webSearch = "Web Search:"
var searchGo = "Go"
var railProduct = "The app"
var railNote =
"A flamenet.io account is not a Flamenet Messenger account. The two sign-ins are separate."
var railGetTitle = "Get the app"
var railGetBody = "Android today. The iPhone build is a TestFlight beta."
var railGetMeta = "Free, and free of ads."
var railReadTitle = "Before you install"
var railPortalTitle = "Elsewhere on FlameNet"
var railDonateBody = "Nothing here is sold, so donations are what pay for the relay."
var statsLine =
"Free and open source · No ads · No phone number · Your own server if you want one"
/// Every string above, in Spanish.
static let spanish: Chrome = {
@@ -1563,7 +1601,6 @@ struct LandingContext: Encodable {
c.files = "Archivos"
c.wallet = "Cartera"
c.compare = "Comparar"
c.developers = "Desarrolladores"
c.about = "Acerca de"
c.donate = "Donar"
c.getIt = "Descargar"
@@ -1596,6 +1633,27 @@ struct LandingContext: Encodable {
c.footKeep = "Qué guardamos"
c.footLicences = "Licencias"
c.footLicence = "Flamenet Messenger es software libre, publicado bajo la licencia MIT."
c.footSeparateLead = "Flamenet Messenger es una aplicación aparte de"
c.footSeparateBody =
"Vive en su propio dominio, está cifrada de extremo a extremo y tiene su propio "
+ "acceso: una cuenta de FlameNet.io no sirve aquí, y una cuenta de Flamenet "
+ "Messenger no sirve allí."
c.portalHome = "Inicio de FlameNet"
c.channel = "Messenger"
c.webSearch = "Buscar en la web:"
c.searchGo = "Ir"
c.railProduct = "La aplicación"
c.railNote =
"Una cuenta de flamenet.io no es una cuenta de Flamenet Messenger. Son dos accesos distintos."
c.railGetTitle = "Descarga la aplicación"
c.railGetBody = "Hoy en Android. La versión para iPhone es una beta de TestFlight."
c.railGetMeta = "Gratis, y sin anuncios."
c.railReadTitle = "Antes de instalar"
c.railPortalTitle = "Más en FlameNet"
c.railDonateBody =
"Aquí no se vende nada, así que las donaciones son lo que paga el servidor."
c.statsLine =
"Libre y de código abierto · Sin anuncios · Sin número de teléfono · Tu propio servidor si lo quieres"
return c
}()
}
@@ -1631,6 +1689,9 @@ struct StatusContext: Encodable {
/// Renders the shared layout, so it carries the same chrome, see
/// `LandingContext.chrome`.
var lang: String = "en"
/// Which left-rail entry is the page being rendered, as a page slug.
/// Empty on pages the rail does not list.
var section: String = ""
var hasAlternate: Bool = false
var chrome: LandingContext.Chrome = LandingContext.Chrome()
/// Every context the shared layout renders needs these.
+3
View File
@@ -142,6 +142,9 @@ struct ReleasesContext: Encodable {
/// Renders the shared layout, so it carries the same chrome, see
/// `LandingContext.chrome`.
var lang: String = "en"
/// Which left-rail entry is the page being rendered, as a page slug.
/// Empty on pages the rail does not list.
var section: String = ""
var hasAlternate: Bool = false
var chrome: LandingContext.Chrome = LandingContext.Chrome()
/// Every context the shared layout renders needs these.
@@ -59,6 +59,35 @@ final class PublicPagesTests: XCTestCase {
}
}
/// Every template closes as many divs as it opens, in both languages.
///
/// The body columns are table-cells. A template with one closing div too
/// many ends the centre column early, and the sections after it become
/// siblings of the columns rather than children of one, which puts the
/// right rail in a new table row at the foot of the page. messages.leaf
/// carried a duplicate closing div that was invisible in a one-column
/// layout and broke both language versions in this one.
func testEveryTemplateClosesItsDivs() throws {
var checked = 0
for directory in [views, views.appendingPathComponent("es")] {
let names = try FileManager.default
.contentsOfDirectory(atPath: directory.path)
.filter { $0.hasSuffix(".leaf") }
for name in names {
let body = try String(
contentsOf: directory.appendingPathComponent(name), encoding: .utf8)
let opens = body.components(separatedBy: "<div").count - 1
let closes = body.components(separatedBy: "</div>").count - 1
checked += 1
XCTAssertEqual(
opens, closes,
"\(name): \(opens) <div against \(closes) </div>, which moves the "
+ "right rail to the foot of the page")
}
}
XCTAssertGreaterThan(checked, 30, "the div check found almost nothing to check")
}
/// Every internal link in the layout must go somewhere that exists.
func testTheSitemapIsBuiltFromThePageTable() throws {
let source = try String(