Render the site as a channel of the flamenet.io portal #201
@@ -1 +1 @@
|
||||
cbdadd9f82c2b64f2c0eb1b08b8ff2e88e48a5a7
|
||||
d01cd0bbb267c591fd2ba7de7794f4bf461019cb
|
||||
@@ -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
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -222,7 +222,6 @@
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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
@@ -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 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 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">♥</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/landing.js?v=#(assetVersion)"></script>
|
||||
</body>
|
||||
|
||||
@@ -212,7 +212,6 @@
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
+157
-1
@@ -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>{"fnc":1,"kind":"otr","ctl":1,"otr":1}
|
||||
{"fnc":1,"kind":"otr","ctl":1,"otr":1,"echo":1}
|
||||
{"fnc":1,"kind":"otr","ctl":1,"otr":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>{"fnc":1,"kind":"poll","ctl":1,"pid":"<uuid>","q":"<question>","opts":["<a>","<b>"],"mset":"<member set>","gid":"<group>"}
|
||||
{"fnc":1,"kind":"pshare","ctl":1,"pid":"<uuid>","v":[1732584193,-1732584192],"gid":"<group>"}
|
||||
{"fnc":1,"kind":"psum","ctl":1,"pid":"<uuid>","v":[-604832771,604832772],"gid":"<group>"}</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>{"fnc":1,"kind":"call-handoff","call":{"id":"<uuid>","peer":4211,"video":false}}
|
||||
{"fnc":1,"kind":"call-handoff-taken","call":{"id":"<uuid>"}}</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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user