From 89ed78f40307674562bf1f2c1b12e28a7c69e127 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 12 Sep 2026 10:18:38 -0400 Subject: [PATCH 1/6] Render the site as a channel of the flamenet.io portal The messenger looked like a different company to its own parent. flamenet.io is a Y2K portal, and this site was a modern one-column landing page, so the two read as unrelated products that happen to share a name. The chrome is now the portal's: a utility bar, a channel masthead carrying FlameNet above the channel name, the portal's own tab strip with Messenger as the tab you are on, three columns, and the portal's footer. Burgundy is the colour the theme's colour map already assigns to messages. portal.css is a port of wp-content/themes/flamenet-2001/style.css, not a shared file. The two stacks are separate and a cross-stack stylesheet would make a Swift server's appearance depend on a WordPress box, so the chrome now has to be changed in both places. Page bodies are restyled by class rather than rewritten. Every page is a .band holding a .wrap, so the heading becomes the coloured header bar and the eyebrow becomes the tag strip above it. That leaves the nine pages generated from docs untouched, and no approved copy moved. The left rail carries the sections the top nav used to. LandingContext gains a section slug so the rail can mark the page you are on; the three contexts with their own inits set it after construction, beside canonical and origin. Also drops the "new in the app", "good to know" and "how it works" sections from both landing pages, and the hero button that pointed at the last of them. Co-Authored-By: Claude Opus 5 --- Public/landing.css | 946 ----------------------- Public/landing.js | 17 - Public/portal.css | 798 +++++++++++++++++++ Resources/Views/es/landing.leaf | 101 --- Resources/Views/landing.leaf | 114 --- Resources/Views/layout.leaf | 288 ++++--- Sources/FlamenetRelay/Entrypoint.swift | 50 +- Sources/FlamenetRelay/ReleaseNotes.swift | 3 + 8 files changed, 1035 insertions(+), 1282 deletions(-) delete mode 100644 Public/landing.css create mode 100644 Public/portal.css diff --git a/Public/landing.css b/Public/landing.css deleted file mode 100644 index 038eb47..0000000 --- a/Public/landing.css +++ /dev/null @@ -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
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; } diff --git a/Public/landing.js b/Public/landing.js index acbb443..08e96a0 100644 --- a/Public/landing.js +++ b/Public/landing.js @@ -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]'); diff --git a/Public/portal.css b/Public/portal.css new file mode 100644 index 0000000..5e2d86d --- /dev/null +++ b/Public/portal.css @@ -0,0 +1,798 @@ +/* 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); +} + +.ch-mark { vertical-align: -5px; margin-right: 5px; } + +/* 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 +
holding a
. 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; } + +.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; } +#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
or +
, 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; } +} diff --git a/Resources/Views/es/landing.leaf b/Resources/Views/es/landing.leaf index ffc9b9d..56aab01 100644 --- a/Resources/Views/es/landing.leaf +++ b/Resources/Views/es/landing.leaf @@ -10,7 +10,6 @@

Sin número de teléfono @@ -86,106 +85,6 @@
-
-
- Nuevo en la app -

Tres cosas nuevas en el mensajero.

-

- Cada una es pequeña, y cada una funciona como funciona el resto de la app. -

-

- Una reacción que solo ve esa persona. En un grupo, - reacciona a quien lo escribió en vez de a toda la sala. A nadie más se le - envía. -

-

- Reacciona durante una llamada. Di que sí sin interrumpir. - Se ve un momento y no se guarda nada. -

-

- Fuera de registro. 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. -

-

- En Android desde la versión 0.17. En iPhone, en la próxima compilación de - TestFlight. -

-
-
- -
-
-
- Conviene saberlo -

Unas cuantas cosas que conviene saber.

-

- 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. -

-
- -
-
-

Que los servidores hablen entre ellos significa que más servidores ven algo

-

- 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. -

-
-
-

El cifrado cubre el mensaje, no el hecho de que lo enviaste

-

- 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. -

- Mira exactamente qué se guarda -
-
-

Todavía nos queda pendiente una auditoría externa

-

- 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. -

- Qué cubriría una auditoría -
-
-
-
- -
-
- Cómo funciona -

Cómo tus mensajes siguen siendo privados.

-

- 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. -

-
    -
  1. - 1 -

    Tu teléfono fabrica una cerradura y una llave

    -

    - 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. -

    -
  2. -
  3. - 2 -

    Tu amigo cierra el mensaje antes de enviarlo

    -

    - 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. -

    -
  4. -
  5. - 3 -

    Cada mensaje lleva una cerradura nueva

    -

    - 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. -

    -
  6. -
-

- Las tres ideas tienen nombres técnicos propios, y si quieres la versión completa con los detalles, está todo en - la especificación. -

-
-
- +
+
+ FlameNet + + #(chrome.channel) +
+ + +
+ + + + +
+ + + +
+
+#import("content") +
+
+ + +
+ +
#(chrome.statsLine)
+ + - - -
-#import("content") -
- - + +
diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index 615251b..9e35c5d 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -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, ``, 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 @@ -1551,6 +1562,24 @@ struct LandingContext: Encodable { var footLicences = "Licenses" var footLicence = "Flamenet Messenger is free software, published under the MIT licence." + // 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 = { var c = Chrome() @@ -1596,6 +1625,22 @@ struct LandingContext: Encodable { c.footKeep = "Qué guardamos" c.footLicences = "Licencias" c.footLicence = "Flamenet Messenger es software libre, publicado bajo la licencia MIT." + 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 +1676,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. diff --git a/Sources/FlamenetRelay/ReleaseNotes.swift b/Sources/FlamenetRelay/ReleaseNotes.swift index 30091eb..b6fd3bc 100644 --- a/Sources/FlamenetRelay/ReleaseNotes.swift +++ b/Sources/FlamenetRelay/ReleaseNotes.swift @@ -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. -- 2.54.0 From fb2ba4dd127f337cee58d179144750100edff84e Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 12 Sep 2026 11:55:09 -0400 Subject: [PATCH 2/6] Close the div messages.leaf left open, and drop the masthead logo Both language versions of messages.leaf carried one closing div too many. In a one-column layout that was invisible. The body columns are table-cells, so the stray div ended the centre column early and the sections after it became siblings of the columns rather than children of one, which put the right rail in a new table row at the foot of the page. testEveryTemplateClosesItsDivs counts div pairs across both language directories. The existing balance test only counts Leaf directives, which is why this reached a browser. The mark is out of the channel masthead: FlameNet above the channel name carries the relationship on its own, and the icon beside 27px type read as a second logo competing with the portal's. Verified on a local instance: /messages lays out 150/830/300 on one row with no sideways overflow, and all 21 public page tests pass. Co-Authored-By: Claude Opus 5 --- Public/portal.css | 2 -- Resources/Views/es/messages.leaf | 1 - Resources/Views/layout.leaf | 4 +-- Resources/Views/messages.leaf | 1 - .../FlamenetRelayTests/PublicPagesTests.swift | 29 +++++++++++++++++++ 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/Public/portal.css b/Public/portal.css index 5e2d86d..57e806b 100644 --- a/Public/portal.css +++ b/Public/portal.css @@ -108,8 +108,6 @@ a.ch-brand:hover { opacity: 1; color: #FFFFFF; text-decoration: underline; } text-shadow: 0 1px 2px rgba(0,0,0,0.45); } -.ch-mark { vertical-align: -5px; margin-right: 5px; } - /* The search box in the masthead searches flamenet.io, because the messenger has nothing to search and the portal does. */ .channel-search { diff --git a/Resources/Views/es/messages.leaf b/Resources/Views/es/messages.leaf index 0b8f251..8d26465 100644 --- a/Resources/Views/es/messages.leaf +++ b/Resources/Views/es/messages.leaf @@ -196,7 +196,6 @@

- diff --git a/Resources/Views/layout.leaf b/Resources/Views/layout.leaf index 2752dd7..47b700c 100644 --- a/Resources/Views/layout.leaf +++ b/Resources/Views/layout.leaf @@ -69,9 +69,7 @@
FlameNet - - #(chrome.channel) + #(chrome.channel)
diff --git a/Resources/Views/messages.leaf b/Resources/Views/messages.leaf index f02df80..04b6cdd 100644 --- a/Resources/Views/messages.leaf +++ b/Resources/Views/messages.leaf @@ -186,7 +186,6 @@

- diff --git a/Tests/FlamenetRelayTests/PublicPagesTests.swift b/Tests/FlamenetRelayTests/PublicPagesTests.swift index 7cb7605..ce0de7e 100644 --- a/Tests/FlamenetRelayTests/PublicPagesTests.swift +++ b/Tests/FlamenetRelayTests/PublicPagesTests.swift @@ -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: "").count - 1 + checked += 1 + XCTAssertEqual( + opens, closes, + "\(name): \(opens)
, 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( -- 2.54.0 From 0835fe834b3d157803f04da9b7112918c1a77bbc Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 12 Sep 2026 12:00:47 -0400 Subject: [PATCH 3/6] Fix the mobile rail and contain tables in the generated pages Three things a phone showed. A table in a generated page had no wrapper to scroll inside, so /spec pushed the document to 418px in a 375px viewport. Those templates are built from docs and cannot be given a wrapper, so the table becomes a block with its own scroll container and its rows still lay out as a table inside. The comparison table keeps its .compare-scroll. The rail put four open groups above the page once the columns stacked, which is a screenful of navigation before any content. Developers, Project and Legal are now details elements, open in the markup so a desktop and a browser without JS get the full rail, closed by a matchMedia listener at the same 860px the stylesheet stacks at. The stylesheet already carried the summary.lcol-hd rules for this. Donate is back in the rail. It was in the top bar, the right rail and the footer, but not in the sections list. Measured at 375px across all 32 pages: none overflow. /spec was the only one that did. Desktop is unchanged at 150/830/300 on one row with every group open. Co-Authored-By: Claude Opus 5 --- Public/landing.js | 18 ++++++++++++++++++ Public/portal.css | 12 ++++++++++++ Resources/Views/layout.leaf | 19 +++++++++++++------ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/Public/landing.js b/Public/landing.js index 08e96a0..49df388 100644 --- a/Public/landing.js +++ b/Public/landing.js @@ -25,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); +})(); diff --git a/Public/portal.css b/Public/portal.css index 57e806b..26226b8 100644 --- a/Public/portal.css +++ b/Public/portal.css @@ -316,6 +316,18 @@ summary.lcol-hd::-webkit-details-marker { display: none; } } .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 { diff --git a/Resources/Views/layout.leaf b/Resources/Views/layout.leaf index 47b700c..1db0bb4 100644 --- a/Resources/Views/layout.leaf +++ b/Resources/Views/layout.leaf @@ -108,19 +108,23 @@ #(chrome.compare)
  • #(chrome.developers)
  • #(chrome.about) + #(chrome.donate) -
    #(chrome.footDevelopers)
    -
    -- 2.54.0 From c0a4cccae232b62d5b863d07c244315da6d8ac79 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 12 Sep 2026 12:08:17 -0400 Subject: [PATCH 4/6] Say on every page that Messenger is a separate app People arrive here believing a flamenet.io sign-in works, and nothing on the site said otherwise except a line in the rail. The statement now sits in the footer, so it is on every page in both languages. It is the same cream notice panel flamenet.io uses for its counterpart (#footer-messenger there, 780px, #FFF9E6 on #E0C97F), and the wording points the other way round: this side says a flamenet.io account does not work here. The string is split into a lead and a body so the lead can carry the bold and the link to flamenet.io. Chrome strings are escaped by Leaf, so markup inside one prints literally. Get it is back in the rail alongside Donate, marked active on its own page. The parent's sentence about that site no longer having a mailbox is left out: this site never had one, so it would not be true here. Co-Authored-By: Claude Opus 5 --- Public/portal.css | 19 +++++++++++++++++++ Resources/Views/layout.leaf | 6 ++++++ Sources/FlamenetRelay/Entrypoint.swift | 15 +++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/Public/portal.css b/Public/portal.css index 26226b8..fa52037 100644 --- a/Public/portal.css +++ b/Public/portal.css @@ -576,6 +576,25 @@ a.btn:visited { color: #000000; } 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; } diff --git a/Resources/Views/layout.leaf b/Resources/Views/layout.leaf index 1db0bb4..d9f8854 100644 --- a/Resources/Views/layout.leaf +++ b/Resources/Views/layout.leaf @@ -102,6 +102,7 @@ +