Manual: full participation guide (media/accounts/decisions/safety) + Enter-to-door + provisioner env passthrough

This commit is contained in:
glass beadian 2026-10-12 15:49:25 +00:00
parent 07d2df8c53
commit 6161ac4740
5 changed files with 251 additions and 42 deletions

View file

@ -7,6 +7,7 @@ import {
MANUAL_INTRO,
MANUAL_PATHS_HEADING,
MANUAL_TITLE,
MANUAL_TOC_HEADING,
PARTICIPATION_MANUAL,
PARTICIPATION_PATHS,
isManualLanguage,
@ -83,6 +84,17 @@ function ManualLanding(): React.ReactElement {
useEffect(() => setLang(readStoredLanguage()), []);
// Enter jumps to the door only when body has focus; interactive elements keep their own Enter.
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Enter" && e.target === document.body && !e.shiftKey) {
router.push(status === "authenticated" ? "/streams" : "/onboarding");
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [status, router]);
function switchLanguage(next: ManualLanguage): void {
setLang(next);
try {
@ -101,7 +113,7 @@ function ManualLanding(): React.ReactElement {
return (
<div className="min-h-dvh bg-bg text-text">
<header className="border-b border-border-subtle bg-surface">
<header className="sticky top-0 z-10 border-b border-border-subtle bg-surface">
<div className="mx-auto flex max-w-2xl items-center gap-3 px-4 py-4">
<Logo size={26} />
<span className="flex-1 truncate text-xs font-bold tracking-tight text-accent">Tactical Media Broadcast Hub</span>
@ -163,10 +175,31 @@ function ManualLanding(): React.ReactElement {
</ul>
</section>
{/* Compact table of contents, one line per section, anchor links. */}
<nav aria-labelledby="toc-heading" className="mb-10 rounded-lg border border-border-subtle bg-surface p-4">
<h2 id="toc-heading" className="mb-2 text-xs font-bold uppercase tracking-wider text-text-faint">
{t(MANUAL_TOC_HEADING)}
</h2>
<ol className="grid gap-1.5">
{PARTICIPATION_MANUAL.map((section, index) => (
<li key={section.id} className="flex items-baseline gap-2.5 text-sm">
<span className="font-mono text-xs font-bold text-text-faint">{String(index + 1).padStart(2, "0")}</span>
<a
href={`#section-${section.id}`}
className="text-accent underline-offset-2 hover:underline"
onClick={jumpToSection}
>
{t(section.heading)}
</a>
</li>
))}
</ol>
</nav>
{/* The manual itself. */}
<div className="flex flex-col gap-8">
{PARTICIPATION_MANUAL.map((section, index) => (
<section key={section.id} aria-labelledby={`section-${section.id}`}>
<section key={section.id} aria-labelledby={`section-${section.id}`} className="scroll-mt-20">
<h2 id={`section-${section.id}`} className="mb-2 flex items-baseline gap-2.5 text-base font-bold">
<span className="font-mono text-xs font-bold text-accent">{String(index + 1).padStart(2, "0")}</span>
{t(section.heading)}
@ -188,3 +221,19 @@ function ManualLanding(): React.ReactElement {
</div>
);
}
/**
* Smooth-scroll to the target section. The anchor itself works without
* JavaScript; this only makes the jump gentle and keeps the sticky header from
* covering the heading (paired with scroll-mt-20 above).
*/
function jumpToSection(event: React.MouseEvent<HTMLAnchorElement>): void {
const href = event.currentTarget.getAttribute("href");
if (href === null || !href.startsWith("#")) return;
const target = document.getElementById(href.slice(1));
if (target === null) return;
event.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
// Reflect the jump in the URL so the link is shareable.
window.history.replaceState(null, "", href);
}

View file

@ -5,6 +5,7 @@ import {
MANUAL_TITLE,
MANUAL_INTRO,
MANUAL_PATHS_HEADING,
MANUAL_TOC_HEADING,
PARTICIPATION_MANUAL,
PARTICIPATION_PATHS,
DEFAULT_MANUAL_LANGUAGE,
@ -13,6 +14,7 @@ import {
onboardingHeader,
onboardingMessages,
onboardingLanguageOrder,
manualToc,
type ManualLanguage,
} from "@tactical-media-broadcast-hub/core";
@ -22,10 +24,24 @@ import {
* Matrix onboarding bot, so these assertions protect BOTH surfaces at once.
*/
test("the manual covers the four sections the design mandates", () => {
/** The manual outline, in reading order: what a collective gets → how it decides → solo → safety → help. */
const SECTION_IDS = [
"what-this-is",
"library",
"rooms",
"accounts",
"live",
"decisions",
"diffusion",
"solo",
"safety",
"help",
] as const;
test("the manual covers the sections the design mandates, in reading order", () => {
assert.deepEqual(
PARTICIPATION_MANUAL.map((section) => section.id),
["what-this-is", "rooms", "safety", "help"],
[...SECTION_IDS],
);
for (const section of PARTICIPATION_MANUAL) {
for (const lang of MANUAL_LANGUAGES) {
@ -50,23 +66,82 @@ test("the three participation paths are exactly join, start and solo", () => {
test("the safety section states the non-negotiables in both languages", () => {
const safety = PARTICIPATION_MANUAL.find((section) => section.id === "safety")!;
for (const [lang, needles] of [
["es", ["cifrado de extremo a extremo", "borradones", "invitación", "cifrado"]],
["en", ["end-to-end encryption", "redaction", "invitation", "ciphertext"]],
["es", ["cifrado de extremo a extremo", "borradones", "invitación", "cifrado", "contribuciones"]],
["en", ["end-to-end encryption", "redaction", "invitation", "ciphertext", "contributions"]],
] as const) {
for (const needle of needles) assert.ok(safety.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
}
// The default contribution policy is stated plainly, not buried.
assert.ok(safety.body.es.includes("borrar tu cuenta no borra tus contribuciones"));
assert.ok(safety.body.en.includes("deleting your account does not delete your contributions"));
});
test("the rooms section names all three room types and their privacy stance", () => {
const rooms = PARTICIPATION_MANUAL.find((section) => section.id === "rooms")!;
for (const [lang, needles] of [
["es", ["abierta", "cerrada", "acogida", "nunca está cifrada", "nace cifrada"]],
["en", ["open room", "closed room", "onboarding room", "never end-to-end encrypted", "born encrypted"]],
["es", ["abierta", "cerrada", "acogida", "nunca está cifrada", "nace cifrada", "telegram"]],
["en", ["open room", "closed room", "onboarding room", "never end-to-end encrypted", "born encrypted", "telegram"]],
] as const) {
for (const needle of needles) assert.ok(rooms.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
}
});
test("the library section explains folders, auto-archiving and reorganising without dead links", () => {
const library = PARTICIPATION_MANUAL.find((section) => section.id === "library")!;
for (const [lang, needles] of [
["es", ["carpeta", "archivados", "renombrar", "sin romper enlaces"]],
["en", ["folder", "archived", "renamed", "without breaking links"]],
] as const) {
for (const needle of needles) assert.ok(library.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
}
});
test("the accounts section names the networks and the approval-before-publish path", () => {
const accounts = PARTICIPATION_MANUAL.find((section) => section.id === "accounts")!;
for (const [lang, needles] of [
["es", ["Mastodon", "Bluesky", "Telegram", "PeerTube", "Facebook", "Instagram", "Twitch", "Discord", "aprobación"]],
["en", ["Mastodon", "Bluesky", "Telegram", "PeerTube", "Facebook", "Instagram", "Twitch", "Discord", "approval"]],
] as const) {
for (const needle of needles) assert.ok(accounts.body[lang].includes(needle), `${lang}: ${needle}`);
}
});
test("the live section covers switching, overlays, auto-director and distributed transcoding", () => {
const live = PARTICIPATION_MANUAL.find((section) => section.id === "live")!;
for (const [lang, needles] of [
["es", ["fuentes", "rótulos", "prioridades", "nodos de trabajo"]],
["en", ["sources", "overlays", "priorities", "render workers"]],
] as const) {
for (const needle of needles) assert.ok(live.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
}
});
test("the decisions section states the gate, the reset-on-edit rule and the verifiable record", () => {
const decisions = PARTICIPATION_MANUAL.find((section) => section.id === "decisions")!;
for (const [lang, needles] of [
["es", ["aprobación", "votación", "encadenado"]],
["en", ["approvals", "vote", "chained"]],
] as const) {
for (const needle of needles) assert.ok(decisions.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
}
});
test("the diffusion section says subcollectives push UP and the parent releases", () => {
const diffusion = PARTICIPATION_MANUAL.find((section) => section.id === "diffusion")!;
assert.ok(diffusion.body.es.toLowerCase().includes("hacia arriba"));
assert.ok(diffusion.body.en.toLowerCase().includes("upward"));
});
test("the solo section covers personal storage, video access and joining later by invite", () => {
const solo = PARTICIPATION_MANUAL.find((section) => section.id === "solo")!;
for (const [lang, needles] of [
["es", ["archivos", "vídeo", "invitación"]],
["en", ["files", "video", "invite"]],
] as const) {
for (const needle of needles) assert.ok(solo.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
}
});
test("Spanish is the default and first language everywhere", () => {
assert.equal(DEFAULT_MANUAL_LANGUAGE, "es");
assert.deepEqual(MANUAL_LANGUAGE_ORDER, ["es", "en"]);
@ -76,6 +151,7 @@ test("Spanish is the default and first language everywhere", () => {
assert.ok(MANUAL_TITLE.es && MANUAL_TITLE.en);
assert.ok(MANUAL_INTRO.es && MANUAL_INTRO.en);
assert.ok(MANUAL_PATHS_HEADING.es && MANUAL_PATHS_HEADING.en);
assert.ok(MANUAL_TOC_HEADING.es && MANUAL_TOC_HEADING.en);
});
test("isManualLanguage accepts exactly es and en", () => {
@ -86,11 +162,26 @@ test("isManualLanguage accepts exactly es and en", () => {
}
});
test("onboardingMessages is one header plus one message per section, in section order", () => {
test("manualToc is one numbered line per section, in section order", () => {
for (const lang of MANUAL_LANGUAGES) {
const lines = manualToc(lang).split("\n");
assert.equal(lines.length, PARTICIPATION_MANUAL.length);
for (const [index, section] of PARTICIPATION_MANUAL.entries()) {
assert.ok(lines[index]!.startsWith(`${index + 1}. `), `${lang} line ${index} numbered`);
assert.ok(lines[index]!.includes(section.heading[lang]), `${lang} line ${index} carries the heading`);
}
}
});
test("onboardingMessages is header+contents first, then one message per section, in section order", () => {
for (const lang of MANUAL_LANGUAGES) {
const messages = onboardingMessages(lang);
assert.equal(messages.length, PARTICIPATION_MANUAL.length + 1);
assert.ok(messages[0]!.startsWith(onboardingHeader(lang)));
assert.ok(messages[0]!.startsWith(onboardingHeader(lang)), `${lang} header first`);
// The contents list rides in the first message so a long manual stays navigable.
for (const section of PARTICIPATION_MANUAL) {
assert.ok(messages[0]!.includes(section.heading[lang]), `${lang} contents lists ${section.id}`);
}
for (const [index, section] of PARTICIPATION_MANUAL.entries()) {
const message = messages[index + 1];
assert.ok(message !== undefined, `${lang} section ${index} exists`);
@ -126,7 +217,7 @@ test("language type is closed: assigning an unknown value is a compile-time erro
});
test("manual copy stays plain-language: no dev jargon in user-facing sections", () => {
const jargon = ["api", "http", "rls", "endpoint", "json", "sdk", "federate=true", "megolm"];
const jargon = ["api", "http", "rls", "endpoint", "json", "sdk", "federate=true", "megolm", "ocis", "webdav", "r2"];
for (const section of PARTICIPATION_MANUAL) {
for (const lang of MANUAL_LANGUAGES) {
const body = section.body[lang].toLowerCase();