Manual: full participation guide (media/accounts/decisions/safety) + Enter-to-door + provisioner env passthrough
This commit is contained in:
parent
07d2df8c53
commit
6161ac4740
5 changed files with 251 additions and 42 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue