forked from Rosetta/landing
Refine documentation and support experience
This commit is contained in:
44
known-issues.json
Normal file
44
known-issues.json
Normal file
@@ -0,0 +1,44 @@
|
||||
[
|
||||
{
|
||||
"id": "lost-recovery-phrase",
|
||||
"title": "Lost recovery phrase",
|
||||
"summary": "The recovery phrase cannot be restored by Rosetta.",
|
||||
"solution": "Rosetta does not keep a copy of your recovery phrase or private key. If another device is still signed in, secure any data you need and create a new account before access is lost. Never send a recovery phrase to support.",
|
||||
"keywords": ["lost", "recovery", "phrase", "seed", "restore", "account", "login"]
|
||||
},
|
||||
{
|
||||
"id": "new-device-confirmation",
|
||||
"title": "New device requires confirmation",
|
||||
"summary": "A restored account may require approval before the new device is trusted.",
|
||||
"solution": "Keep the existing Rosetta device online and approve the new device when prompted. Check that the recovery phrase was entered in the original order. If no trusted device remains available, contact support with the client version and operating system, but never include private keys.",
|
||||
"keywords": ["new device", "confirmation", "approve", "trusted", "restore", "handshake", "login"]
|
||||
},
|
||||
{
|
||||
"id": "attachment-stuck",
|
||||
"title": "Attachment remains in sending state",
|
||||
"summary": "Large media can remain pending when the upload or connection is interrupted.",
|
||||
"solution": "Keep Rosetta open and connected until the transfer finishes. Confirm that the file is still available locally, retry the message, and verify that the current client version is installed. Include the attachment type and approximate size when contacting support.",
|
||||
"keywords": ["attachment", "file", "photo", "video", "upload", "sending", "stuck", "pending", "media"]
|
||||
},
|
||||
{
|
||||
"id": "missing-media-permission",
|
||||
"title": "Photos, files, or microphone are unavailable",
|
||||
"summary": "The operating system may have denied a required permission.",
|
||||
"solution": "Open the operating-system privacy settings for Rosetta and enable access to Photos, Files, or Microphone as needed. Restart Rosetta after changing permissions and select the attachment again.",
|
||||
"keywords": ["permission", "microphone", "photo", "camera", "file", "voice", "denied", "unavailable"]
|
||||
},
|
||||
{
|
||||
"id": "message-not-delivered",
|
||||
"title": "Message is not delivered",
|
||||
"summary": "Delivery can be delayed by connectivity, client state, or an unavailable recipient.",
|
||||
"solution": "Check the network connection and confirm that Rosetta reports a connected state. Leave the application open briefly and retry once. If the problem continues, provide support with the sender platform, recipient platform, approximate time, and client version. Do not include message content or encryption material.",
|
||||
"keywords": ["message", "deliver", "delivered", "waiting", "error", "offline", "connection", "send"]
|
||||
},
|
||||
{
|
||||
"id": "security-report",
|
||||
"title": "Report abuse or a security issue",
|
||||
"summary": "Security reports should contain reproducible technical details without secrets.",
|
||||
"solution": "Email support@rosetta.im with the affected platform, client version, expected behavior, actual behavior, and reproduction steps. Never include a recovery phrase, private key, decrypted private messages, or another person's personal information.",
|
||||
"keywords": ["security", "abuse", "report", "vulnerability", "privacy", "spam", "scam"]
|
||||
}
|
||||
]
|
||||
@@ -12,6 +12,12 @@
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://rosetta.im/proto/docs/</loc>
|
||||
<lastmod>2026-07-31</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://rosetta.im/encryption/</loc>
|
||||
<lastmod>2026-07-30</lastmod>
|
||||
|
||||
@@ -178,5 +178,5 @@ const totals = layers.reduce(
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Generated Proto docs from ${layers.length} layers: ${totals.packets} packets, ${totals.types} objects, ${totals.enums} enums.`,
|
||||
`Generated Proto docs from ${layers.length} layers: ${totals.packets} packets, ${totals.types} types, ${totals.enums} enums.`,
|
||||
);
|
||||
|
||||
@@ -15,6 +15,9 @@ const EncryptionPage = lazy(() =>
|
||||
const ProtoPage = lazy(() =>
|
||||
import('./pages/ProtoPage').then((module) => ({ default: module.ProtoPage })),
|
||||
);
|
||||
const ProtoDocsPage = lazy(() =>
|
||||
import('./pages/ProtoPage').then((module) => ({ default: module.ProtoDocsPage })),
|
||||
);
|
||||
const SupportPage = lazy(() =>
|
||||
import('./pages/SupportPage').then((module) => ({ default: module.SupportPage })),
|
||||
);
|
||||
@@ -51,6 +54,7 @@ export default function App() {
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/encryption" element={<EncryptionPage />} />
|
||||
<Route path="/proto" element={<ProtoPage />} />
|
||||
<Route path="/proto/docs" element={<ProtoDocsPage />} />
|
||||
<Route path="/support" element={<SupportPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
|
||||
@@ -15,21 +15,21 @@
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin-inline: auto;
|
||||
padding: clamp(32px, 6vw, 72px);
|
||||
padding: clamp(30px, 5vw, 60px);
|
||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.intro {
|
||||
max-width: 760px;
|
||||
padding-bottom: 48px;
|
||||
padding-bottom: 36px;
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
font-size: clamp(38px, 6vw, 56px);
|
||||
font-size: clamp(28px, 4vw, 36px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.08;
|
||||
@@ -39,8 +39,8 @@
|
||||
max-width: 780px;
|
||||
margin: 40px 0 0;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
font-size: 20px;
|
||||
line-height: 1.6;
|
||||
font-size: 17px;
|
||||
line-height: 1.72;
|
||||
}
|
||||
|
||||
.text,
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
.list li::marker {
|
||||
color: var(--mantine-color-blue-5);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -77,7 +77,7 @@
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
|
||||
font-size: clamp(21px, 3vw, 26px);
|
||||
font-size: clamp(19px, 3vw, 23px);
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
}
|
||||
@@ -95,8 +95,8 @@
|
||||
|
||||
.address {
|
||||
margin: var(--mantine-spacing-md) 0;
|
||||
border-left: 3px solid var(--mantine-color-blue-5);
|
||||
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.address p {
|
||||
|
||||
@@ -33,9 +33,9 @@ export function LegalPage({
|
||||
<Box className={classes.page}>
|
||||
<SiteHeader />
|
||||
<Container className={classes.shell}>
|
||||
<Paper component="main" withBorder radius="lg" className={classes.main}>
|
||||
<Paper component="main" withBorder radius="sm" className={classes.main}>
|
||||
<Box className={classes.intro}>
|
||||
<Text tt="uppercase" fw={700} fz="xs" c="blue" lts="0.08em">
|
||||
<Text tt="uppercase" fw={700} fz="xs" c="dimmed" lts="0.08em">
|
||||
{eyebrow}
|
||||
</Text>
|
||||
<Title order={1} className={classes.title}>
|
||||
@@ -93,7 +93,7 @@ export function LegalList({ children }: { children: ReactNode }) {
|
||||
|
||||
export function LegalAddress({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Paper radius="md" p="md" className={classes.address}>
|
||||
<Paper radius="sm" p="md" className={classes.address}>
|
||||
<Text component="p">{children}</Text>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,7 @@ export function SiteFooter() {
|
||||
Terms of Use
|
||||
</Anchor>
|
||||
<Anchor href="mailto:support@rosetta.im" c="dimmed" size="sm">
|
||||
Support@rosetta.im
|
||||
support@rosetta.im
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
|
||||
text-decoration: none;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.brand:hover {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
|
||||
@@ -26,7 +26,7 @@ export function SiteHeader() {
|
||||
|
||||
const links = NAVIGATION.map(({ label, to }) => {
|
||||
const active =
|
||||
(to === '/proto' && pathname === '/proto') ||
|
||||
(to === '/proto' && pathname.startsWith('/proto')) ||
|
||||
(to === '/encryption' && pathname === '/encryption') ||
|
||||
(to === '/support' && pathname === '/support');
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const protoLayers = [
|
||||
{
|
||||
"name": "HandshakeStage",
|
||||
"type": "int8",
|
||||
"description": "",
|
||||
"description": "Handshake Stage (need for set device confirm view after pass seed)",
|
||||
"yaml": "HandshakeStage:\n type: int8\n values:\n - id: 0\n name: COMPLETED\n - id: 1\n name: NEED_DEVICE_VERIFICATION",
|
||||
"values": [
|
||||
{
|
||||
|
||||
@@ -1,162 +1,30 @@
|
||||
.page {
|
||||
padding: 48px 0 112px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));
|
||||
}
|
||||
|
||||
.hero,
|
||||
.notice,
|
||||
.contents,
|
||||
.section {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 64px 0 48px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
font-size: clamp(48px, 7vw, 74px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.02;
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
max-width: 790px;
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
font-size: 19px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: var(--mantine-spacing-xl);
|
||||
border-left: 4px solid var(--mantine-color-blue-5);
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.contents {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.contentLink {
|
||||
padding: 7px 12px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
border-radius: 999px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contentLink:hover {
|
||||
border-color: var(--mantine-color-blue-4);
|
||||
color: var(--mantine-color-blue-6);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.section {
|
||||
scroll-margin-top: 104px;
|
||||
margin-top: 24px;
|
||||
padding: 64px clamp(24px, 5vw, 64px);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.section > h2 {
|
||||
max-width: 760px;
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
font-size: clamp(30px, 4vw, 43px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.sectionBody {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.sectionBody > h3 {
|
||||
margin-top: 42px;
|
||||
margin-bottom: 12px;
|
||||
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
|
||||
font-size: 21px;
|
||||
.subheading {
|
||||
margin: 28px 0 10px;
|
||||
color: light-dark(var(--mantine-color-dark-8), var(--mantine-color-dark-0));
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.codeBlock {
|
||||
margin: var(--mantine-spacing-lg) 0;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-9));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: light-dark(var(--mantine-color-blue-0), rgba(34, 139, 230, 0.1));
|
||||
color: light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-1));
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
.code {
|
||||
margin: 18px 0;
|
||||
padding: 16px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.list {
|
||||
.table {
|
||||
margin: 18px 0 24px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.references {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.list li::marker {
|
||||
color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: var(--mantine-spacing-md);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.boundary {
|
||||
background: light-dark(var(--mantine-color-orange-0), rgba(253, 126, 20, 0.08));
|
||||
}
|
||||
|
||||
.terms {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 32px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.term {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.term dd {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.page {
|
||||
padding: 32px 0 80px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 48px 0 40px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 42px 24px;
|
||||
}
|
||||
|
||||
.terms {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +1,25 @@
|
||||
import { Anchor, Code, List, Table, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Code,
|
||||
Container,
|
||||
Group,
|
||||
List,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBinaryTree,
|
||||
IconFileTextShield,
|
||||
IconKey,
|
||||
IconLock,
|
||||
IconRoute,
|
||||
IconShieldCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
LegalList,
|
||||
LegalPage,
|
||||
LegalSection,
|
||||
LegalText,
|
||||
} from '../components/LegalPage/LegalPage';
|
||||
import { SEO } from '../components/SEO/SEO';
|
||||
import { SiteFooter } from '../components/SiteFooter/SiteFooter';
|
||||
import { SiteHeader } from '../components/SiteHeader/SiteHeader';
|
||||
import classes from './EncryptionPage.module.css';
|
||||
|
||||
const MESSAGE_FLOW = [
|
||||
{
|
||||
number: '01',
|
||||
title: 'Create message material',
|
||||
description: 'Generate a fresh 32-byte content key and a fresh 24-byte nonce.',
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
title: 'Encrypt the content',
|
||||
description: 'Protect UTF-8 message content with XChaCha20-Poly1305.',
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
title: 'Wrap for the recipient',
|
||||
description: 'Encrypt the key and nonce for the recipient using ephemeral secp256k1 ECDH.',
|
||||
},
|
||||
{
|
||||
number: '04',
|
||||
title: 'Transmit ciphertext',
|
||||
description: 'Send the encrypted content, key envelope, routing data, and attachment metadata.',
|
||||
},
|
||||
];
|
||||
|
||||
const ATTACHMENT_LAYOUT = [
|
||||
['Magic', '4 bytes', 'ASCII RAC2 (52 41 43 32)'],
|
||||
['Salt', '16 bytes', 'Random per attachment encryption session'],
|
||||
['IV', '12 bytes', 'Random and unique for every encrypted chunk'],
|
||||
['Ciphertext + tag', 'Variable', 'AES-256-GCM output including its authentication tag'],
|
||||
['IV', '12 bytes', 'Random for every encrypted chunk'],
|
||||
['Ciphertext + tag', 'Variable', 'AES-256-GCM output and authentication tag'],
|
||||
];
|
||||
|
||||
function TechnicalSection({
|
||||
id,
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
function TechnicalCode({ children }: { children: string }) {
|
||||
return (
|
||||
<Box component="section" id={id} className={classes.section}>
|
||||
<Text c="blue" fw={700} size="xs" tt="uppercase" lts="0.08em">
|
||||
{eyebrow}
|
||||
</Text>
|
||||
<Title order={2}>{title}</Title>
|
||||
<Box className={classes.sectionBody}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Term({
|
||||
term,
|
||||
children,
|
||||
}: {
|
||||
term: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box className={classes.term}>
|
||||
<Text component="dt" fw={700}>
|
||||
{term}
|
||||
</Text>
|
||||
<Text component="dd" c="dimmed" size="sm" lh={1.65}>
|
||||
<Code block className={classes.code}>
|
||||
{children}
|
||||
</Text>
|
||||
</Box>
|
||||
</Code>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,181 +32,104 @@ export function EncryptionPage() {
|
||||
canonical="https://rosetta.im/encryption/"
|
||||
keywords="Rosetta encryption, XChaCha20-Poly1305, AES-256-GCM, secp256k1, encrypted attachments"
|
||||
/>
|
||||
<SiteHeader />
|
||||
<main className={classes.page}>
|
||||
<Container>
|
||||
<Anchor component={Link} to="/" size="sm">
|
||||
Rosetta
|
||||
</Anchor>
|
||||
<Text component="span" c="dimmed" size="sm">
|
||||
{' '}
|
||||
/ Encryption
|
||||
</Text>
|
||||
<LegalPage eyebrow="Technical documentation" title="Encryption">
|
||||
<LegalText lead>
|
||||
This document describes the current Rosetta desktop implementation. It covers
|
||||
direct messages, per-message keys, recipient key envelopes, group messages,
|
||||
local storage, and encrypted attachments. It is an implementation reference,
|
||||
not a claim of formal verification or an independent security audit.
|
||||
</LegalText>
|
||||
|
||||
<Box className={classes.hero}>
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={46} radius="md" variant="light">
|
||||
<IconShieldCheck size={24} />
|
||||
</ThemeIcon>
|
||||
<Badge variant="light">Implementation reference</Badge>
|
||||
</Group>
|
||||
<Title order={1}>Rosetta Encryption</Title>
|
||||
<Text c="dimmed" className={classes.heroDescription}>
|
||||
A technical description of how the current Rosetta desktop client
|
||||
derives identity keys, protects direct and group messages, encrypts
|
||||
attachments, and separates plaintext from server-visible data.
|
||||
</Text>
|
||||
</Box>
|
||||
<LegalSection title="1. Hybrid encryption model">
|
||||
<LegalText>
|
||||
Rosetta does not encrypt an entire message or file with public-key
|
||||
cryptography. Public-key operations are comparatively slow and are not
|
||||
designed for large payloads. They are used only to establish and protect
|
||||
the small secret required by the content cipher.
|
||||
</LegalText>
|
||||
<LegalText>
|
||||
The message text itself is encrypted with a symmetric AEAD cipher because
|
||||
symmetric cryptography is fast and appropriate for arbitrary amounts of
|
||||
data. In other words, the asymmetric part protects the key; the unique
|
||||
symmetric key protects the message.
|
||||
</LegalText>
|
||||
<LegalText>
|
||||
Every direct message receives its own fresh 32-byte content key and its own
|
||||
fresh 24-byte nonce. A key is not reused as the general content key for the
|
||||
next message.
|
||||
</LegalText>
|
||||
</LegalSection>
|
||||
|
||||
<Paper withBorder radius="lg" className={classes.notice}>
|
||||
<Text fw={700}>Scope of this document</Text>
|
||||
<Text c="dimmed" size="sm" mt={6} lh={1.65}>
|
||||
This page documents the current client implementation, including its
|
||||
compatibility formats. It is an implementation reference, not a claim
|
||||
of formal verification, an independent security audit, or a frozen
|
||||
protocol specification.
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Group component="nav" gap="xs" className={classes.contents}>
|
||||
{[
|
||||
['Direct messages', '#direct-messages'],
|
||||
['Attachments', '#attachments'],
|
||||
['Groups', '#groups'],
|
||||
['Key material', '#key-material'],
|
||||
['Security properties', '#security-properties'],
|
||||
['Terminology', '#terminology'],
|
||||
].map(([label, href]) => (
|
||||
<Anchor key={href} href={href} className={classes.contentLink}>
|
||||
{label}
|
||||
</Anchor>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<TechnicalSection
|
||||
id="direct-messages"
|
||||
eyebrow="Direct messages"
|
||||
title="A new authenticated cipher for every message."
|
||||
>
|
||||
<Text c="dimmed" lh={1.75}>
|
||||
Rosetta encrypts the text of each one-to-one message with a newly
|
||||
generated content key. The symmetric secret is never sent in plaintext:
|
||||
it is placed in a separate recipient-specific key envelope.
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mt="xl">
|
||||
{MESSAGE_FLOW.map(({ number, title, description }) => (
|
||||
<Paper key={number} withBorder radius="md" p="lg">
|
||||
<Text c="blue" fw={700} size="xs">
|
||||
{number}
|
||||
</Text>
|
||||
<Text fw={700} mt="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" mt={6} lh={1.6}>
|
||||
{description}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Title order={3}>Content cipher</Title>
|
||||
<Code block className={classes.codeBlock}>
|
||||
{`contentKey = randomBytes(32) # 256 bits
|
||||
<LegalSection title="2. Direct-message encryption">
|
||||
<Title order={3} className={classes.subheading}>
|
||||
Content cipher
|
||||
</Title>
|
||||
<LegalText>
|
||||
The client generates the content key and nonce with the platform
|
||||
cryptographic random number generator. XChaCha20-Poly1305 encrypts the
|
||||
UTF-8 message and produces an authentication tag. A modified ciphertext or
|
||||
an incorrect key causes decryption to fail.
|
||||
</LegalText>
|
||||
<TechnicalCode>{`contentKey = randomBytes(32) # 256 bits
|
||||
nonce = randomBytes(24) # 192 bits
|
||||
ciphertext = XChaCha20-Poly1305(
|
||||
key = contentKey,
|
||||
nonce = nonce,
|
||||
plaintext = UTF8(message)
|
||||
)`}
|
||||
</Code>
|
||||
<Text c="dimmed" lh={1.75}>
|
||||
XChaCha20-Poly1305 is an AEAD construction: it encrypts the message and
|
||||
produces an authentication tag. Decryption fails if the protected
|
||||
ciphertext has been changed or the wrong key is supplied.
|
||||
</Text>
|
||||
ciphertext = XChaCha20-Poly1305(contentKey, nonce, UTF8(message))`}</TechnicalCode>
|
||||
|
||||
<Title order={3}>Recipient key envelope</Title>
|
||||
<List spacing="sm" className={classes.list}>
|
||||
<Title order={3} className={classes.subheading}>
|
||||
Recipient key envelope
|
||||
</Title>
|
||||
<LegalList>
|
||||
<List.Item>
|
||||
The client generates an ephemeral secp256k1 key pair for the
|
||||
message.
|
||||
The client creates an ephemeral secp256k1 key pair for the message.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
ECDH combines the ephemeral private key with the recipient's
|
||||
long-term public key.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
The x-coordinate of the shared point becomes a 32-byte AES key.
|
||||
The x-coordinate of the shared point is used as a 32-byte AES wrapping
|
||||
key.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
The 56-byte message material — content key followed by nonce — is
|
||||
encrypted with AES-CBC and PKCS#7 padding using a random 16-byte IV.
|
||||
The 56-byte message secret — content key followed by nonce — is wrapped
|
||||
with AES-CBC and PKCS#7 padding using a random 16-byte IV.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
The envelope carries the IV, encrypted material, and uncompressed
|
||||
ephemeral public key. The recipient reconstructs the same shared
|
||||
secret with their private key.
|
||||
The envelope contains the IV, encrypted message secret, and ephemeral
|
||||
public key. The content key is never sent in plaintext.
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
<Code block className={classes.codeBlock}>
|
||||
{`messageSecret = contentKey || nonce
|
||||
</LegalList>
|
||||
<TechnicalCode>{`messageSecret = contentKey || nonce
|
||||
ephemeral = secp256k1.randomKeyPair()
|
||||
sharedPoint = ECDH(ephemeral.private, recipient.public)
|
||||
wrappingKey = sharedPoint.x[0..31]
|
||||
keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.public)`}
|
||||
</Code>
|
||||
keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.public)`}</TechnicalCode>
|
||||
|
||||
<Title order={3}>What crosses the service boundary</Title>
|
||||
<Text c="dimmed" lh={1.75}>
|
||||
The message packet contains sender and recipient identifiers, a
|
||||
timestamp, message ID, encrypted content, the recipient key envelope,
|
||||
and attachment descriptors. The server can route this packet, but it
|
||||
does not receive the direct-message content key or plaintext message.
|
||||
</Text>
|
||||
</TechnicalSection>
|
||||
<LegalText>
|
||||
The packet sent to the service contains routing identifiers, timestamp,
|
||||
message ID, encrypted content, recipient key envelope, and attachment
|
||||
descriptors. The service does not receive the direct-message content key
|
||||
or plaintext message.
|
||||
</LegalText>
|
||||
</LegalSection>
|
||||
|
||||
<TechnicalSection
|
||||
id="attachments"
|
||||
eyebrow="Attachments"
|
||||
title="Authenticated encryption, one chunk at a time."
|
||||
>
|
||||
<Text c="dimmed" lh={1.75}>
|
||||
Files, images, audio, and video are encrypted before upload. Large
|
||||
payloads are processed as independent chunks so the client can stream,
|
||||
retry, cache, and decrypt ranges without holding an entire file in
|
||||
<LegalSection title="3. Attachment encryption">
|
||||
<LegalText>
|
||||
Files, images, audio, and video are encrypted before upload. Binary data is
|
||||
split into independent chunks so that large files can be streamed, retried,
|
||||
cached, and decrypted by range without loading the complete file into
|
||||
memory.
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" mt="xl">
|
||||
{[
|
||||
['AES-256-GCM', 'Authenticated encryption for every binary chunk'],
|
||||
['PBKDF2-SHA-256', '100,000 iterations and a 16-byte random salt'],
|
||||
['Random IV', 'A fresh 12-byte IV for every chunk'],
|
||||
].map(([title, description], index) => {
|
||||
const Icon = [IconFileTextShield, IconBinaryTree, IconKey][index];
|
||||
return (
|
||||
<Paper key={title} withBorder radius="md" p="lg">
|
||||
<ThemeIcon variant="light" radius="xl">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} mt="md">
|
||||
{title}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" mt={6} lh={1.6}>
|
||||
{description}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
<Title order={3}>RAC2 encrypted chunk format</Title>
|
||||
<Code block className={classes.codeBlock}>
|
||||
</LegalText>
|
||||
<LegalText>
|
||||
The attachment password is derived from the secret of the message that
|
||||
carries the attachment. PBKDF2-SHA-256 with 100,000 iterations and a random
|
||||
16-byte salt derives an AES-256-GCM key. Each chunk receives a new random
|
||||
12-byte IV. AES-GCM authenticates the encrypted chunk and rejects modified
|
||||
data before plaintext is returned.
|
||||
</LegalText>
|
||||
<TechnicalCode>
|
||||
RAC2 || salt[16] || iv[12] || AES-256-GCM-ciphertext-and-tag
|
||||
</Code>
|
||||
<Table.ScrollContainer minWidth={620}>
|
||||
</TechnicalCode>
|
||||
<Table.ScrollContainer minWidth={560}>
|
||||
<Table verticalSpacing="sm" horizontalSpacing="md" className={classes.table}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -294,218 +141,104 @@ keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.publ
|
||||
<Table.Tbody>
|
||||
{ATTACHMENT_LAYOUT.map(([segment, length, purpose]) => (
|
||||
<Table.Tr key={segment}>
|
||||
<Table.Td>
|
||||
<Code>{segment}</Code>
|
||||
</Table.Td>
|
||||
<Table.Td>{segment}</Table.Td>
|
||||
<Table.Td>{length}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text c="dimmed" size="sm">
|
||||
{purpose}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{purpose}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<LegalText>
|
||||
The CDN stores ciphertext and transfer metadata such as object ID, size,
|
||||
and chunk count. It does not receive the attachment decryption secret.
|
||||
</LegalText>
|
||||
</LegalSection>
|
||||
|
||||
<Text c="dimmed" mt="xl" lh={1.75}>
|
||||
One random salt and derived AES key are reused within an attachment
|
||||
encryption session; every chunk receives an independent random IV.
|
||||
AES-GCM's authentication tag lets the client reject modified or
|
||||
corrupted chunks before exposing plaintext. The CDN stores encrypted
|
||||
chunks and object metadata, not the file decryption secret.
|
||||
</Text>
|
||||
</TechnicalSection>
|
||||
<LegalSection title="4. Groups and compatibility format">
|
||||
<LegalText>
|
||||
A group uses a shared group secret available to its members. Group messages
|
||||
do not carry a new recipient envelope for every member. The current group
|
||||
and inline-payload compatibility codec compresses the input with DEFLATE,
|
||||
derives an AES key with PBKDF2, and encrypts it with AES-CBC using a random
|
||||
IV.
|
||||
</LegalText>
|
||||
<TechnicalCode>{`input
|
||||
→ DEFLATE
|
||||
→ PBKDF2-SHA-256(password, "rosetta", 1,000 iterations)
|
||||
→ AES-CBC with a random 16-byte IV
|
||||
→ Base64(iv) : Base64(ciphertext)`}</TechnicalCode>
|
||||
<LegalText>
|
||||
This compatibility format provides confidentiality but does not include an
|
||||
AEAD authentication tag. Binary attachments use the newer RAC2 AES-GCM
|
||||
format described above.
|
||||
</LegalText>
|
||||
</LegalSection>
|
||||
|
||||
<TechnicalSection
|
||||
id="groups"
|
||||
eyebrow="Groups and compatibility"
|
||||
title="Shared group keys and inline payloads."
|
||||
>
|
||||
<Text c="dimmed" lh={1.75}>
|
||||
A Rosetta group has a shared secret available to its members. Group
|
||||
messages do not transmit a new recipient envelope with every message;
|
||||
the shared group key is used by the client's compatibility codec.
|
||||
Inline attachment data, sender-readable message copies, and protected
|
||||
local values use the same codec.
|
||||
</Text>
|
||||
<Code block className={classes.codeBlock}>
|
||||
{`input
|
||||
→ DEFLATE compression
|
||||
→ PBKDF2(password, "rosetta", 1,000 iterations)
|
||||
→ AES-CBC with random 16-byte IV
|
||||
→ Base64(iv) : Base64(ciphertext)`}
|
||||
</Code>
|
||||
<Text c="dimmed" lh={1.75}>
|
||||
This compatibility format provides confidentiality but does not include
|
||||
an AEAD authentication tag. Binary attachments use the newer RAC2
|
||||
AES-GCM format described above.
|
||||
</Text>
|
||||
</TechnicalSection>
|
||||
<LegalSection title="5. Account keys and local storage">
|
||||
<LegalText>
|
||||
The recovery phrase is expanded to a BIP39 seed. SHA-256 of that seed
|
||||
becomes the 32-byte secp256k1 private key, and the client derives the
|
||||
compressed public key used as the account identity.
|
||||
</LegalText>
|
||||
<LegalText>
|
||||
The private key, recovery phrase, readable message cache, and sender-side
|
||||
copy of the message secret are encrypted locally with material derived from
|
||||
the user's password or account private key. The plaintext account
|
||||
password is not sent to the service.
|
||||
</LegalText>
|
||||
</LegalSection>
|
||||
|
||||
<TechnicalSection
|
||||
id="key-material"
|
||||
eyebrow="Identity and storage"
|
||||
title="Long-term keys remain device-controlled."
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<ThemeIcon variant="light" radius="xl">
|
||||
<IconKey size={19} />
|
||||
</ThemeIcon>
|
||||
<Title order={3}>Account identity</Title>
|
||||
<Text c="dimmed" size="sm" mt="sm" lh={1.7}>
|
||||
The recovery phrase is expanded to a BIP39 seed. SHA-256 of that
|
||||
seed becomes the 32-byte secp256k1 private key, and the client
|
||||
derives a compressed public key for the account identity.
|
||||
</Text>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<ThemeIcon variant="light" radius="xl">
|
||||
<IconLock size={19} />
|
||||
</ThemeIcon>
|
||||
<Title order={3}>Local protection</Title>
|
||||
<Text c="dimmed" size="sm" mt="sm" lh={1.7}>
|
||||
The private key, recovery phrase, readable message cache, and
|
||||
sender-side message secret are encrypted locally with material
|
||||
derived from the user's password or account private key.
|
||||
The plaintext account password is not sent to the service.
|
||||
</Text>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</TechnicalSection>
|
||||
<LegalSection title="6. Security boundaries">
|
||||
<LegalText>
|
||||
End-to-end encryption protects direct-message content and binary attachment
|
||||
contents. It does not hide sender and recipient identifiers, timestamps,
|
||||
message IDs, delivery state, attachment size, object IDs, network address,
|
||||
or request timing.
|
||||
</LegalText>
|
||||
<LegalText>
|
||||
The direct-message ciphertext has AEAD integrity, but the current recipient
|
||||
key envelope is not a digital signature. Compromise of a recipient's
|
||||
long-term private key can expose previously captured key envelopes, so the
|
||||
current design does not claim long-term forward secrecy. Group keys also
|
||||
require rotation when membership changes.
|
||||
</LegalText>
|
||||
</LegalSection>
|
||||
|
||||
<TechnicalSection
|
||||
id="security-properties"
|
||||
eyebrow="Security properties"
|
||||
title="What the current design protects — and what remains visible."
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Text c="teal" fw={700}>
|
||||
Protected
|
||||
</Text>
|
||||
<List spacing="sm" mt="md" className={classes.list}>
|
||||
<List.Item>Direct-message plaintext and per-message secret</List.Item>
|
||||
<List.Item>Binary attachment contents and integrity</List.Item>
|
||||
<List.Item>Locally stored private key and recovery phrase</List.Item>
|
||||
<List.Item>Sender-side readable message cache</List.Item>
|
||||
</List>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Text c="orange" fw={700}>
|
||||
Not hidden by end-to-end encryption
|
||||
</Text>
|
||||
<List spacing="sm" mt="md" className={classes.list}>
|
||||
<List.Item>Sender and recipient or group identifiers</List.Item>
|
||||
<List.Item>Message timestamps, IDs, and delivery metadata</List.Item>
|
||||
<List.Item>Attachment object IDs, sizes, and transfer metadata</List.Item>
|
||||
<List.Item>Network information such as IP addresses and timing</List.Item>
|
||||
</List>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder radius="md" p="xl" mt="lg" className={classes.boundary}>
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="xl" color="orange">
|
||||
<IconRoute size={19} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Important implementation boundaries</Text>
|
||||
<Text c="dimmed" size="sm" mt={6} lh={1.7}>
|
||||
Direct-message ciphertext has AEAD integrity, but the
|
||||
current recipient key envelope is not a digital signature.
|
||||
Compromise of a recipient's long-term private key can
|
||||
expose previously captured direct-message key envelopes, so
|
||||
this design does not claim long-term forward secrecy.
|
||||
Shared group keys similarly require careful rotation when
|
||||
membership changes.
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
</TechnicalSection>
|
||||
|
||||
<TechnicalSection
|
||||
id="terminology"
|
||||
eyebrow="Terminology"
|
||||
title="Cryptographic terms used by Rosetta."
|
||||
>
|
||||
<Box component="dl" className={classes.terms}>
|
||||
<Term term="AEAD">
|
||||
Authenticated Encryption with Associated Data. A cipher mode that
|
||||
protects confidentiality and detects unauthorized modification.
|
||||
</Term>
|
||||
<Term term="XChaCha20-Poly1305">
|
||||
A stream-cipher-based AEAD construction with a 192-bit nonce,
|
||||
allowing securely generated random nonces with a very low collision
|
||||
probability.
|
||||
</Term>
|
||||
<Term term="AES-GCM">
|
||||
An AES mode that combines counter-mode encryption with a Galois
|
||||
authentication tag. Rosetta uses it for RAC2 attachment chunks.
|
||||
</Term>
|
||||
<Term term="ECDH">
|
||||
Elliptic Curve Diffie–Hellman. Two parties combine a private key
|
||||
with another party's public key to calculate the same shared
|
||||
secret without transmitting that secret.
|
||||
</Term>
|
||||
<Term term="secp256k1">
|
||||
The elliptic curve used for Rosetta account keys and ephemeral
|
||||
direct-message key envelopes.
|
||||
</Term>
|
||||
<Term term="PBKDF2">
|
||||
A password-based key derivation function that repeatedly applies a
|
||||
pseudorandom function with a salt to derive key material.
|
||||
</Term>
|
||||
<Term term="Nonce / IV">
|
||||
A value that must be unique for a key and encryption operation.
|
||||
Rosetta generates random nonces and IVs using the platform
|
||||
cryptographic random number generator.
|
||||
</Term>
|
||||
<Term term="Forward secrecy">
|
||||
A property in which compromise of a long-term key does not reveal
|
||||
earlier session keys. The current Rosetta direct-message envelope
|
||||
does not claim this property.
|
||||
</Term>
|
||||
</Box>
|
||||
|
||||
<Stack gap="xs" mt="xl">
|
||||
<Text fw={700}>Standards and implementation references</Text>
|
||||
<Anchor
|
||||
href="https://csrc.nist.gov/pubs/sp/800/38/d/final"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
NIST SP 800-38D — GCM and GMAC
|
||||
<LegalSection title="7. Terminology and references">
|
||||
<LegalList>
|
||||
<List.Item>
|
||||
<strong>AEAD:</strong> authenticated encryption that provides
|
||||
confidentiality and detects modification.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<strong>ECDH:</strong> elliptic-curve key agreement used to calculate a
|
||||
shared secret without transmitting it.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<strong>Nonce / IV:</strong> a value that must not repeat for a given
|
||||
key and encryption mode.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<strong>Forward secrecy:</strong> protection of old session keys after a
|
||||
long-term key is compromised.
|
||||
</List.Item>
|
||||
</LegalList>
|
||||
<Text className={classes.references}>
|
||||
References:{' '}
|
||||
<Anchor href="https://csrc.nist.gov/pubs/sp/800/38/d/final">
|
||||
NIST SP 800-38D
|
||||
</Anchor>
|
||||
<Anchor
|
||||
href="https://www.rfc-editor.org/rfc/rfc8018"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
RFC 8018 — PBKDF2 and PKCS #5
|
||||
,{' '}
|
||||
<Anchor href="https://www.rfc-editor.org/rfc/rfc8018">RFC 8018</Anchor>,{' '}
|
||||
<Anchor href="https://www.secg.org/sec2-v2.pdf">SEC 2</Anchor>, and{' '}
|
||||
<Anchor href="https://github.com/paulmillr/noble-ciphers">
|
||||
noble-ciphers
|
||||
</Anchor>
|
||||
<Anchor
|
||||
href="https://www.secg.org/sec2-v2.pdf"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
SEC 2 — secp256k1 domain parameters
|
||||
</Anchor>
|
||||
<Anchor
|
||||
href="https://github.com/paulmillr/noble-ciphers"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
noble-ciphers — XChaCha20-Poly1305 implementation
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</TechnicalSection>
|
||||
</Container>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
.
|
||||
</Text>
|
||||
</LegalSection>
|
||||
</LegalPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,103 +1,41 @@
|
||||
.hero {
|
||||
overflow: hidden;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.heroGrid {
|
||||
.heroContainer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(340px, 0.92fr);
|
||||
gap: 72px;
|
||||
min-height: calc(100vh - 72px);
|
||||
padding-top: 72px;
|
||||
padding-bottom: 72px;
|
||||
place-items: center;
|
||||
min-height: 620px;
|
||||
padding-top: 88px;
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
max-width: 720px;
|
||||
margin-top: 30px;
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
font-size: clamp(52px, 7vw, 76px);
|
||||
font-size: clamp(44px, 6vw, 64px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.06;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
max-width: 680px;
|
||||
margin-top: 28px;
|
||||
font-size: 20px;
|
||||
max-width: 650px;
|
||||
margin-top: 24px;
|
||||
font-size: 19px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
.preview::before {
|
||||
position: absolute;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
border-radius: 50%;
|
||||
background: light-dark(rgba(34, 139, 230, 0.11), rgba(34, 139, 230, 0.16));
|
||||
content: '';
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.phone {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(100%, 380px);
|
||||
padding: 20px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.phoneHeader {
|
||||
padding-bottom: var(--mantine-spacing-md);
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: 32px 0 24px;
|
||||
}
|
||||
|
||||
.messageIncoming,
|
||||
.messageOutgoing {
|
||||
max-width: 84%;
|
||||
}
|
||||
|
||||
.messageIncoming {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.messageOutgoing {
|
||||
margin-left: auto;
|
||||
background: light-dark(var(--mantine-color-blue-0), rgba(34, 139, 230, 0.18));
|
||||
}
|
||||
|
||||
.encryptionNote {
|
||||
color: var(--mantine-color-blue-6);
|
||||
padding-top: var(--mantine-spacing-md);
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.floatingCard {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: -24px;
|
||||
bottom: 84px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-md);
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.featuresSection,
|
||||
.appsSection,
|
||||
.encryptionSection {
|
||||
padding: 112px 0;
|
||||
padding: 104px 0;
|
||||
}
|
||||
|
||||
.featuresSection {
|
||||
@@ -115,10 +53,10 @@
|
||||
.sectionHeading h2 {
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
font-size: clamp(36px, 5vw, 52px);
|
||||
font-size: clamp(34px, 5vw, 48px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.08;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sectionHeading > p:last-child {
|
||||
@@ -135,11 +73,15 @@
|
||||
}
|
||||
|
||||
.appCard {
|
||||
min-height: 320px;
|
||||
min-height: 280px;
|
||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.encryptionSection {
|
||||
padding-top: 0;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
@@ -150,65 +92,47 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 48px;
|
||||
padding: 56px;
|
||||
padding: 52px;
|
||||
background: linear-gradient(135deg, var(--mantine-color-blue-8), var(--mantine-color-blue-5));
|
||||
}
|
||||
|
||||
.encryptionCard h2 {
|
||||
font-size: clamp(32px, 4vw, 46px);
|
||||
font-size: clamp(30px, 4vw, 42px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.encryptionButton {
|
||||
flex: 0 0 auto;
|
||||
color: var(--mantine-color-blue-8) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 62em) {
|
||||
.heroGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 28px;
|
||||
padding-top: 88px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
.heroContainer {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.floatingCard {
|
||||
right: 8%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.heroGrid {
|
||||
min-height: auto;
|
||||
.heroContainer {
|
||||
min-height: 500px;
|
||||
padding-top: 64px;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(44px, 13vw, 62px);
|
||||
font-size: clamp(40px, 12vw, 54px);
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.floatingCard {
|
||||
right: 0;
|
||||
bottom: 40px;
|
||||
}
|
||||
|
||||
.featuresSection,
|
||||
.appsSection,
|
||||
.encryptionSection {
|
||||
padding: 80px 0;
|
||||
padding: 76px 0;
|
||||
}
|
||||
|
||||
.encryptionSection {
|
||||
@@ -218,24 +142,18 @@
|
||||
.encryptionCard {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 36px 28px;
|
||||
padding: 34px 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 36em) {
|
||||
.heroCopy :global(.mantine-Group-root) {
|
||||
.heroActions {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.heroCopy :global(.mantine-Button-root) {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview {
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.floatingCard {
|
||||
display: none;
|
||||
.heroActions :global(.mantine-Button-root) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -18,14 +17,11 @@ import {
|
||||
IconBrandAndroid,
|
||||
IconBrandApple,
|
||||
IconBrandWindows,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconDatabaseOff,
|
||||
IconDeviceDesktop,
|
||||
IconFileTextShield,
|
||||
IconKey,
|
||||
IconLock,
|
||||
IconMessageCircle,
|
||||
IconPhoneOff,
|
||||
type TablerIcon,
|
||||
} from '@tabler/icons-react';
|
||||
@@ -73,7 +69,7 @@ const FEATURES: Array<{ icon: TablerIcon; title: string; description: string }>
|
||||
icon: IconCode,
|
||||
title: 'Documented protocol',
|
||||
description:
|
||||
'Every packet, object, enum, and field is published from the protocol source schemas.',
|
||||
'Every packet, type, enum, and field is published from the protocol source schemas.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -175,7 +171,7 @@ function DownloadApps() {
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg" mt={48}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg" mt={48}>
|
||||
{PLATFORMS.map(({ key, title, description, icon: Icon }) => {
|
||||
const platformReleases = groupedReleases[key] || [];
|
||||
|
||||
@@ -200,6 +196,7 @@ function DownloadApps() {
|
||||
variant="light"
|
||||
justify="space-between"
|
||||
rightSection={<IconArrowDown size={16} />}
|
||||
className={classes.downloadButton}
|
||||
>
|
||||
{architectureName(release.arch)} · {release.version}
|
||||
</Button>
|
||||
@@ -239,32 +236,18 @@ export function LandingPage() {
|
||||
<SiteHeader />
|
||||
<main>
|
||||
<Box component="section" className={classes.hero}>
|
||||
<Container className={classes.heroGrid}>
|
||||
<Stack justify="center" gap={0} className={classes.heroCopy}>
|
||||
<Group gap="md" mb="xl">
|
||||
<RosettaLogo size={74} />
|
||||
<Badge size="lg" variant="light" radius="xl">
|
||||
Private messaging
|
||||
</Badge>
|
||||
</Group>
|
||||
<Container className={classes.heroContainer}>
|
||||
<Stack align="center" gap={0} className={classes.heroCopy}>
|
||||
<RosettaLogo size={82} />
|
||||
<Title order={1} className={classes.heroTitle}>
|
||||
A new era of{' '}
|
||||
<Text
|
||||
component="span"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'blue', to: 'cyan' }}
|
||||
inherit
|
||||
>
|
||||
private
|
||||
</Text>{' '}
|
||||
messaging.
|
||||
Private messaging, simply.
|
||||
</Title>
|
||||
<Text className={classes.heroDescription} c="dimmed">
|
||||
<Text className={classes.heroDescription} c="dimmed" ta="center">
|
||||
Rosetta keeps identity, conversations, and media under your control
|
||||
— without requiring a phone number or building a readable message
|
||||
archive on the server.
|
||||
</Text>
|
||||
<Group mt={36}>
|
||||
<Group mt={32} className={classes.heroActions}>
|
||||
<Button component="a" href="#apps" size="lg" radius="md">
|
||||
Download Rosetta
|
||||
</Button>
|
||||
@@ -278,86 +261,7 @@ export function LandingPage() {
|
||||
Explore Proto
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg" mt={48}>
|
||||
{[
|
||||
['No phone', 'Identity without personal contact data'],
|
||||
['Encrypted', 'Messages and media protected by default'],
|
||||
['Local', 'Readable history stays on your devices'],
|
||||
].map(([value, label]) => (
|
||||
<div key={value}>
|
||||
<Text fw={700}>{value}</Text>
|
||||
<Text c="dimmed" size="xs" mt={4} lh={1.45}>
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Box className={classes.preview} aria-label="Rosetta conversation preview">
|
||||
<Paper withBorder radius={32} shadow="xl" className={classes.phone}>
|
||||
<Group justify="space-between" className={classes.phoneHeader}>
|
||||
<Group gap="sm">
|
||||
<RosettaLogo size={36} />
|
||||
<div>
|
||||
<Text fw={700} size="sm">
|
||||
Rosetta
|
||||
</Text>
|
||||
<Text c="blue" size="xs">
|
||||
Encrypted
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<ThemeIcon variant="light" radius="xl">
|
||||
<IconLock size={16} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
|
||||
<Stack gap="md" className={classes.messages}>
|
||||
<Paper radius="lg" p="md" className={classes.messageIncoming}>
|
||||
<Text size="sm">Your recovery phrase never leaves your device.</Text>
|
||||
<Text c="dimmed" size="xs" ta="right" mt={6}>
|
||||
10:42
|
||||
</Text>
|
||||
</Paper>
|
||||
<Paper radius="lg" p="md" className={classes.messageOutgoing}>
|
||||
<Text size="sm">And the server cannot read this conversation.</Text>
|
||||
<Group justify="flex-end" gap={4} mt={6}>
|
||||
<Text c="blue" size="xs">
|
||||
10:43
|
||||
</Text>
|
||||
<IconCheck size={14} color="var(--mantine-color-blue-6)" />
|
||||
</Group>
|
||||
</Paper>
|
||||
<Paper radius="lg" p="md" className={classes.messageIncoming}>
|
||||
<Text size="sm">Exactly. Privacy is the default.</Text>
|
||||
<Text c="dimmed" size="xs" ta="right" mt={6}>
|
||||
10:43
|
||||
</Text>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Group justify="center" gap="xs" className={classes.encryptionNote}>
|
||||
<IconKey size={15} />
|
||||
<Text size="xs">End-to-end encrypted session</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="lg" shadow="md" className={classes.floatingCard}>
|
||||
<ThemeIcon radius="xl" variant="light">
|
||||
<IconMessageCircle size={17} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
Delivered privately
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
No readable server history
|
||||
</Text>
|
||||
</div>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
@@ -413,7 +317,7 @@ export function LandingPage() {
|
||||
<Button
|
||||
component={Link}
|
||||
to="/encryption"
|
||||
color="white"
|
||||
color="blue"
|
||||
variant="white"
|
||||
size="lg"
|
||||
className={classes.encryptionButton}
|
||||
|
||||
@@ -1,117 +1,168 @@
|
||||
.protoCode {
|
||||
margin: 18px 0;
|
||||
padding: 16px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.docsLink {
|
||||
display: inline-block;
|
||||
margin-top: 44px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 48px 0 112px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
padding: 40px 0 104px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));
|
||||
}
|
||||
|
||||
.hero {
|
||||
max-width: 780px;
|
||||
padding: 64px 0 48px;
|
||||
max-width: 760px;
|
||||
padding: 48px 0 44px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
font-size: clamp(52px, 7vw, 76px);
|
||||
font-size: clamp(32px, 5vw, 44px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
font-size: 19px;
|
||||
line-height: 1.65;
|
||||
margin-top: var(--mantine-spacing-md);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-bottom: 56px;
|
||||
margin: 0 0 52px;
|
||||
}
|
||||
|
||||
.docsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
gap: 48px;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 44px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 104px;
|
||||
top: 92px;
|
||||
max-height: calc(100vh - 112px);
|
||||
padding-right: 10px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar a {
|
||||
width: fit-content;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar a:hover {
|
||||
color: var(--mantine-color-blue-6);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.layerDescription {
|
||||
padding: 12px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.layerDescription code {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.documentation {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
gap: var(--mantine-spacing-md);
|
||||
margin-bottom: 56px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
gap: var(--mantine-spacing-sm);
|
||||
margin-bottom: 16px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.schemaSection {
|
||||
scroll-margin-top: 104px;
|
||||
margin-top: 72px;
|
||||
}
|
||||
|
||||
.schemaSection:first-of-type {
|
||||
margin-top: 0;
|
||||
scroll-margin-top: 96px;
|
||||
margin-top: 64px;
|
||||
}
|
||||
|
||||
.schemaSection > h2 {
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
font-size: 36px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.definition {
|
||||
scroll-margin-top: 104px;
|
||||
margin-top: 44px;
|
||||
padding-top: 36px;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
scroll-margin-top: 96px;
|
||||
margin-top: 38px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.definition h3 {
|
||||
margin-top: 5px;
|
||||
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
|
||||
font-size: 27px;
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.yamlDetails {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.yamlSummary {
|
||||
width: fit-content;
|
||||
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.yamlBlock {
|
||||
margin: var(--mantine-spacing-lg) 0;
|
||||
margin-top: 10px;
|
||||
padding: 12px 12px 16px 18px;
|
||||
border: 1px solid light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-9));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: light-dark(var(--mantine-color-blue-0), rgba(34, 139, 230, 0.1));
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.yamlBlock code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-1));
|
||||
font-size: 13px;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.table {
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.typeLink {
|
||||
@@ -132,30 +183,32 @@
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
padding: var(--mantine-spacing-lg);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.page {
|
||||
padding-top: 32px;
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding-top: 48px;
|
||||
padding-top: 38px;
|
||||
}
|
||||
|
||||
.docsGrid {
|
||||
gap: 32px;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.schemaSection {
|
||||
margin-top: 56px;
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.definition {
|
||||
margin-top: 36px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
Container,
|
||||
CopyButton,
|
||||
Group,
|
||||
List,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
IconBraces,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconFileCode,
|
||||
IconList,
|
||||
IconPackage,
|
||||
IconSearch,
|
||||
@@ -31,6 +30,12 @@ import {
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SEO } from '../components/SEO/SEO';
|
||||
import {
|
||||
LegalList,
|
||||
LegalPage,
|
||||
LegalSection,
|
||||
LegalText,
|
||||
} from '../components/LegalPage/LegalPage';
|
||||
import { SiteFooter } from '../components/SiteFooter/SiteFooter';
|
||||
import { SiteHeader } from '../components/SiteHeader/SiteHeader';
|
||||
import { protoLayers } from '../generated/protoDocs';
|
||||
@@ -43,8 +48,6 @@ import type {
|
||||
} from '../types/proto';
|
||||
import classes from './ProtoPage.module.css';
|
||||
|
||||
type SchemaCategory = 'all' | 'packets' | 'types' | 'enums';
|
||||
|
||||
const PRIMITIVE_TYPES = new Set([
|
||||
'bool',
|
||||
'bytes',
|
||||
@@ -86,16 +89,29 @@ function entityMatches(
|
||||
return searchable.includes(query);
|
||||
}
|
||||
|
||||
function TypeReference({ type, layer }: { type: string; layer: ProtoLayer }) {
|
||||
function TypeReference({
|
||||
type,
|
||||
layer,
|
||||
onNavigate,
|
||||
}: {
|
||||
type: string;
|
||||
layer: ProtoLayer;
|
||||
onNavigate: (targetId: string) => void;
|
||||
}) {
|
||||
const array = type.endsWith('[]');
|
||||
const baseType = array ? type.slice(0, -2) : type;
|
||||
const targetType = layer.types.find((item) => item.name === baseType);
|
||||
const targetEnum = layer.enums.find((item) => item.name === baseType);
|
||||
|
||||
if (targetType || targetEnum) {
|
||||
const targetId = `${targetType ? 'type' : 'enum'}-${slug(baseType)}`;
|
||||
return (
|
||||
<Anchor
|
||||
href={`#${targetType ? 'type' : 'enum'}-${slug(baseType)}`}
|
||||
href={`#${targetId}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onNavigate(targetId);
|
||||
}}
|
||||
className={classes.typeLink}
|
||||
>
|
||||
{baseType}
|
||||
@@ -111,7 +127,15 @@ function TypeReference({ type, layer }: { type: string; layer: ProtoLayer }) {
|
||||
return <Code>{type}</Code>;
|
||||
}
|
||||
|
||||
function FieldTable({ fields, layer }: { fields: ProtoField[]; layer: ProtoLayer }) {
|
||||
function FieldTable({
|
||||
fields,
|
||||
layer,
|
||||
onNavigate,
|
||||
}: {
|
||||
fields: ProtoField[];
|
||||
layer: ProtoLayer;
|
||||
onNavigate: (targetId: string) => void;
|
||||
}) {
|
||||
if (fields.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" size="sm">
|
||||
@@ -148,7 +172,11 @@ function FieldTable({ fields, layer }: { fields: ProtoField[]; layer: ProtoLayer
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<TypeReference type={field.type} layer={layer} />
|
||||
<TypeReference
|
||||
type={field.type}
|
||||
layer={layer}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text c={field.description ? undefined : 'dimmed'} size="sm">
|
||||
@@ -166,10 +194,7 @@ function FieldTable({ fields, layer }: { fields: ProtoField[]; layer: ProtoLayer
|
||||
function YamlSnippet({ value }: { value: string }) {
|
||||
return (
|
||||
<Box className={classes.yamlBlock}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={700} size="xs" c="dimmed" tt="uppercase">
|
||||
YAML schema
|
||||
</Text>
|
||||
<Group justify="flex-end" mb="xs">
|
||||
<CopyButton value={value} timeout={1600}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? 'Copied' : 'Copy YAML'}>
|
||||
@@ -190,7 +215,26 @@ function YamlSnippet({ value }: { value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PacketDefinition({ packet, layer }: { packet: ProtoPacket; layer: ProtoLayer }) {
|
||||
function HiddenYaml({ value }: { value: string }) {
|
||||
return (
|
||||
<Box component="details" className={classes.yamlDetails}>
|
||||
<Box component="summary" className={classes.yamlSummary}>
|
||||
See YAML schema
|
||||
</Box>
|
||||
<YamlSnippet value={value} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PacketDefinition({
|
||||
packet,
|
||||
layer,
|
||||
onNavigate,
|
||||
}: {
|
||||
packet: ProtoPacket;
|
||||
layer: ProtoLayer;
|
||||
onNavigate: (targetId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Box component="article" id={`packet-${slug(packet.name)}`} className={classes.definition}>
|
||||
<Text c="blue" fw={700} size="xs" tt="uppercase">
|
||||
@@ -200,24 +244,32 @@ function PacketDefinition({ packet, layer }: { packet: ProtoPacket; layer: Proto
|
||||
<Text c="dimmed" mt="sm" lh={1.65}>
|
||||
{packet.description || `Packet ${packet.name} in protocol layer ${layer.version}.`}
|
||||
</Text>
|
||||
<YamlSnippet value={packet.yaml} />
|
||||
<FieldTable fields={packet.fields} layer={layer} />
|
||||
<HiddenYaml value={packet.yaml} />
|
||||
<FieldTable fields={packet.fields} layer={layer} onNavigate={onNavigate} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeDefinition({ type, layer }: { type: ProtoType; layer: ProtoLayer }) {
|
||||
function TypeDefinition({
|
||||
type,
|
||||
layer,
|
||||
onNavigate,
|
||||
}: {
|
||||
type: ProtoType;
|
||||
layer: ProtoLayer;
|
||||
onNavigate: (targetId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Box component="article" id={`type-${slug(type.name)}`} className={classes.definition}>
|
||||
<Text c="blue" fw={700} size="xs" tt="uppercase">
|
||||
Object
|
||||
Type
|
||||
</Text>
|
||||
<Title order={3}>{type.name}</Title>
|
||||
<Text c="dimmed" mt="sm" lh={1.65}>
|
||||
{type.description || `Object ${type.name} in protocol layer ${layer.version}.`}
|
||||
{type.description || `Type ${type.name} in protocol layer ${layer.version}.`}
|
||||
</Text>
|
||||
<YamlSnippet value={type.yaml} />
|
||||
<FieldTable fields={type.fields} layer={layer} />
|
||||
<HiddenYaml value={type.yaml} />
|
||||
<FieldTable fields={type.fields} layer={layer} onNavigate={onNavigate} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -232,7 +284,7 @@ function EnumDefinition({ item, layer }: { item: ProtoEnum; layer: ProtoLayer })
|
||||
<Text c="dimmed" mt="sm" lh={1.65}>
|
||||
{item.description || `Enumeration ${item.name} in protocol layer ${layer.version}.`}
|
||||
</Text>
|
||||
<YamlSnippet value={item.yaml} />
|
||||
<HiddenYaml value={item.yaml} />
|
||||
<Table.ScrollContainer minWidth={520}>
|
||||
<Table verticalSpacing="sm" horizontalSpacing="md" className={classes.table}>
|
||||
<Table.Thead>
|
||||
@@ -268,9 +320,80 @@ function EnumDefinition({ item, layer }: { item: ProtoEnum; layer: ProtoLayer })
|
||||
}
|
||||
|
||||
export function ProtoPage() {
|
||||
return (
|
||||
<>
|
||||
<SEO
|
||||
title="Rosetta Proto"
|
||||
description="A technical overview of the Rosetta TLV protocol and its versioned schemas."
|
||||
canonical="https://rosetta.im/proto/"
|
||||
keywords="Rosetta protocol, TLV, packet schema, binary protocol"
|
||||
/>
|
||||
<LegalPage eyebrow="Protocol" title="Proto">
|
||||
<LegalText lead>
|
||||
Rosetta uses a compact binary protocol based on TLV fields. A packet starts
|
||||
with a two-byte unsigned packet ID. The remaining payload is a sequence of
|
||||
typed fields that can be decoded without relying on their physical order.
|
||||
</LegalText>
|
||||
|
||||
<LegalSection title="Packet layout">
|
||||
<LegalText>
|
||||
Each field begins with a one-byte field ID and a one-byte wire type. Fixed
|
||||
numeric and boolean types are followed directly by their value. Strings,
|
||||
byte arrays, nested types, and other variable-length values include a
|
||||
two-byte unsigned length before the value bytes.
|
||||
</LegalText>
|
||||
<Code block className={classes.protoCode}>
|
||||
{`packet_id:uint16
|
||||
field_id:uint8 | wire_type:uint8 | [length:uint16] | value
|
||||
field_id:uint8 | wire_type:uint8 | [length:uint16] | value
|
||||
...`}
|
||||
</Code>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="TLV behavior">
|
||||
<LegalList>
|
||||
<List.Item>
|
||||
<strong>Tag:</strong> the decimal field ID defined in the selected
|
||||
schema layer.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<strong>Type:</strong> the wire type used to interpret the value.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<strong>Length:</strong> present for strings, byte arrays, and nested
|
||||
values; fixed-width primitives do not need it.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<strong>Value:</strong> the serialized field data in network byte order.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
Optional fields are omitted when absent. Lists are represented by
|
||||
repeating the same field ID. Nested types are encoded as
|
||||
length-delimited TLV payloads.
|
||||
</List.Item>
|
||||
</LegalList>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Layers and compatibility">
|
||||
<LegalText>
|
||||
Protocol layers are generated from RCC YAML schemas. Every layer defines
|
||||
packet IDs, field IDs, primitive types, reusable types, enumerations, and
|
||||
optional values. Keeping field IDs stable allows compatible decoders to
|
||||
read known fields while the schema evolves.
|
||||
</LegalText>
|
||||
</LegalSection>
|
||||
|
||||
<Anchor component={Link} to="/proto/docs" className={classes.docsLink}>
|
||||
See docs of PROTO →
|
||||
</Anchor>
|
||||
</LegalPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtoDocsPage() {
|
||||
const latestLayer = protoLayers.at(-1)!;
|
||||
const [selectedVersion, setSelectedVersion] = useState(String(latestLayer.version));
|
||||
const [category, setCategory] = useState<SchemaCategory>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const layer =
|
||||
@@ -279,17 +402,24 @@ export function ProtoPage() {
|
||||
const packets = layer.packets.filter((packet) => entityMatches(packet, query));
|
||||
const types = layer.types.filter((type) => entityMatches(type, query));
|
||||
const enums = layer.enums.filter((item) => entityMatches(item, query));
|
||||
const resultCount =
|
||||
(category === 'all' || category === 'packets' ? packets.length : 0) +
|
||||
(category === 'all' || category === 'types' ? types.length : 0) +
|
||||
(category === 'all' || category === 'enums' ? enums.length : 0);
|
||||
const resultCount = packets.length + types.length + enums.length;
|
||||
|
||||
const navigateToDefinition = (targetId: string) => {
|
||||
setSearch('');
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.history.replaceState(null, '', `#${targetId}`);
|
||||
document.getElementById(targetId)?.scrollIntoView({ block: 'start' });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEO
|
||||
title="Rosetta Proto Documentation"
|
||||
description="Generated Rosetta protocol documentation for packets, objects, enums, fields, and schema layers."
|
||||
canonical="https://rosetta.im/proto/"
|
||||
description="Generated Rosetta protocol documentation for packets, types, enums, fields, and schema layers."
|
||||
canonical="https://rosetta.im/proto/docs/"
|
||||
keywords="Rosetta protocol, packet schema, YAML documentation, Proto"
|
||||
/>
|
||||
<SiteHeader />
|
||||
@@ -300,42 +430,182 @@ export function ProtoPage() {
|
||||
</Anchor>
|
||||
<Text component="span" c="dimmed" size="sm">
|
||||
{' '}
|
||||
/ Proto
|
||||
/ Proto / Docs
|
||||
</Text>
|
||||
|
||||
<Box className={classes.hero}>
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={46} radius="md" variant="light">
|
||||
<IconFileCode size={24} />
|
||||
</ThemeIcon>
|
||||
<Badge variant="light">Generated from YAML</Badge>
|
||||
</Group>
|
||||
<Title order={1}>Rosetta Proto</Title>
|
||||
<Title order={1}>Protocol documentation</Title>
|
||||
<Text c="dimmed" className={classes.heroDescription}>
|
||||
Complete protocol reference generated from the RCC layer schemas. Browse
|
||||
packet definitions, objects, enumerations, field IDs, optional values,
|
||||
packet definitions, types, enumerations, field IDs, optional values,
|
||||
comments, and linked types.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className={classes.docsGrid}>
|
||||
<aside className={classes.sidebar}>
|
||||
<Stack gap="lg">
|
||||
<Select
|
||||
label="Protocol layer"
|
||||
value={selectedVersion}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
setSelectedVersion(value);
|
||||
setSearch('');
|
||||
}
|
||||
}}
|
||||
data={protoLayers.map((item) => ({
|
||||
value: String(item.version),
|
||||
label: `Layer ${item.version}${item === latestLayer ? ' · latest' : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Box className={classes.layerDescription}>
|
||||
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
|
||||
Description
|
||||
</Text>
|
||||
<Text size="xs" mt={6} lh={1.55}>
|
||||
{layer.description || 'Initial protocol schema.'}
|
||||
</Text>
|
||||
<Code mt="xs">{layer.source}</Code>
|
||||
</Box>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
|
||||
Contents
|
||||
</Text>
|
||||
<Anchor
|
||||
href="#packets"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToDefinition('packets');
|
||||
}}
|
||||
>
|
||||
Packets ({layer.packets.length})
|
||||
</Anchor>
|
||||
<Anchor
|
||||
href="#types"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToDefinition('types');
|
||||
}}
|
||||
>
|
||||
Types ({layer.types.length})
|
||||
</Anchor>
|
||||
<Anchor
|
||||
href="#enums"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToDefinition('enums');
|
||||
}}
|
||||
>
|
||||
Enums ({layer.enums.length})
|
||||
</Anchor>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
|
||||
Packets
|
||||
</Text>
|
||||
{layer.packets.map((packet) => {
|
||||
const targetId = `packet-${slug(packet.name)}`;
|
||||
return (
|
||||
<Anchor
|
||||
key={packet.name}
|
||||
href={`#${targetId}`}
|
||||
size="xs"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToDefinition(targetId);
|
||||
}}
|
||||
>
|
||||
{packet.name}
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
|
||||
Types
|
||||
</Text>
|
||||
{layer.types.map((type) => {
|
||||
const targetId = `type-${slug(type.name)}`;
|
||||
return (
|
||||
<Anchor
|
||||
key={type.name}
|
||||
href={`#${targetId}`}
|
||||
size="xs"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToDefinition(targetId);
|
||||
}}
|
||||
>
|
||||
{type.name}
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
|
||||
Enums
|
||||
</Text>
|
||||
{layer.enums.map((item) => {
|
||||
const targetId = `enum-${slug(item.name)}`;
|
||||
return (
|
||||
<Anchor
|
||||
key={item.name}
|
||||
href={`#${targetId}`}
|
||||
size="xs"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToDefinition(targetId);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</aside>
|
||||
|
||||
<Box className={classes.documentation}>
|
||||
<Paper withBorder radius="sm" p="lg" className={classes.filters}>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={17} />}
|
||||
placeholder="Search packets, types, fields, or enums"
|
||||
aria-label="Search Proto documentation"
|
||||
/>
|
||||
<Text c="dimmed" size="xs">
|
||||
{resultCount} definitions in layer {layer.version}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" className={classes.stats}>
|
||||
{[
|
||||
[IconPackage, layer.packets.length, 'Packets'],
|
||||
[IconBraces, layer.types.length, 'Objects'],
|
||||
[IconList, layer.enums.length, 'Enums'],
|
||||
[IconPackage, packets.length, 'Packets'],
|
||||
[IconBraces, types.length, 'Types'],
|
||||
[IconList, enums.length, 'Enums'],
|
||||
].map(([Icon, value, label]) => {
|
||||
const StatIcon = Icon as typeof IconPackage;
|
||||
return (
|
||||
<Paper key={String(label)} withBorder radius="md" p="lg">
|
||||
<Paper key={String(label)} withBorder radius="sm" p="md">
|
||||
<Group>
|
||||
<ThemeIcon variant="light" radius="xl">
|
||||
<StatIcon size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="xl">
|
||||
<Text fw={700} fz="lg">
|
||||
{String(value)}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
<Text c="dimmed" size="xs">
|
||||
{String(label)}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -345,72 +615,7 @@ export function ProtoPage() {
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
<Box className={classes.docsGrid}>
|
||||
<aside className={classes.sidebar}>
|
||||
<Stack gap="lg">
|
||||
<Select
|
||||
label="Protocol layer"
|
||||
value={selectedVersion}
|
||||
onChange={(value) => value && setSelectedVersion(value)}
|
||||
data={protoLayers.map((item) => ({
|
||||
value: String(item.version),
|
||||
label: `Layer ${item.version}${item === latestLayer ? ' · latest' : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
|
||||
Contents
|
||||
</Text>
|
||||
<Anchor href="#packets" size="sm">
|
||||
Packets ({layer.packets.length})
|
||||
</Anchor>
|
||||
<Anchor href="#objects" size="sm">
|
||||
Objects ({layer.types.length})
|
||||
</Anchor>
|
||||
<Anchor href="#enums" size="sm">
|
||||
Enums ({layer.enums.length})
|
||||
</Anchor>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
|
||||
Source
|
||||
</Text>
|
||||
<Code>{layer.source}</Code>
|
||||
<Text c="dimmed" size="xs" lh={1.5}>
|
||||
{layer.description || 'Initial protocol schema.'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</aside>
|
||||
|
||||
<Box className={classes.documentation}>
|
||||
<Paper withBorder radius="lg" p="lg" className={classes.filters}>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={17} />}
|
||||
placeholder="Search packets, objects, fields, or types"
|
||||
aria-label="Search Proto documentation"
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={category}
|
||||
onChange={(value) => setCategory(value as SchemaCategory)}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Packets', value: 'packets' },
|
||||
{ label: 'Objects', value: 'types' },
|
||||
{ label: 'Enums', value: 'enums' },
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
<Text c="dimmed" size="xs">
|
||||
{resultCount} definitions in layer {layer.version}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
{(category === 'all' || category === 'packets') && packets.length > 0 && (
|
||||
{packets.length > 0 && (
|
||||
<Box component="section" id="packets" className={classes.schemaSection}>
|
||||
<Title order={2}>Packets</Title>
|
||||
<Text c="dimmed" mt="xs">
|
||||
@@ -421,24 +626,30 @@ export function ProtoPage() {
|
||||
key={packet.name}
|
||||
packet={packet}
|
||||
layer={layer}
|
||||
onNavigate={navigateToDefinition}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(category === 'all' || category === 'types') && types.length > 0 && (
|
||||
<Box component="section" id="objects" className={classes.schemaSection}>
|
||||
<Title order={2}>Objects</Title>
|
||||
{types.length > 0 && (
|
||||
<Box component="section" id="types" className={classes.schemaSection}>
|
||||
<Title order={2}>Types</Title>
|
||||
<Text c="dimmed" mt="xs">
|
||||
Reusable structured values referenced by packet fields.
|
||||
</Text>
|
||||
{types.map((type) => (
|
||||
<TypeDefinition key={type.name} type={type} layer={layer} />
|
||||
<TypeDefinition
|
||||
key={type.name}
|
||||
type={type}
|
||||
layer={layer}
|
||||
onNavigate={navigateToDefinition}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(category === 'all' || category === 'enums') && enums.length > 0 && (
|
||||
{enums.length > 0 && (
|
||||
<Box component="section" id="enums" className={classes.schemaSection}>
|
||||
<Title order={2}>Enums</Title>
|
||||
<Text c="dimmed" mt="xs">
|
||||
@@ -454,7 +665,7 @@ export function ProtoPage() {
|
||||
<Paper withBorder radius="lg" p="xl" ta="center">
|
||||
<Text fw={600}>No schema definitions found.</Text>
|
||||
<Text c="dimmed" size="sm" mt="xs">
|
||||
Try another search term or category.
|
||||
Try another search term.
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
@@ -1,74 +1,72 @@
|
||||
.lead {
|
||||
max-width: 780px;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
font-size: 20px;
|
||||
line-height: 1.6;
|
||||
.intro {
|
||||
margin-top: 36px;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
height: 54px;
|
||||
padding-inline: 38px;
|
||||
.searchArea {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.topic {
|
||||
.problemField textarea {
|
||||
padding-top: 16px;
|
||||
padding-left: 48px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
margin-top: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
display: block;
|
||||
padding: 12px 10px;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.topic:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: light-dark(var(--mantine-color-blue-3), var(--mantine-color-blue-7));
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
.suggestion:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
scroll-margin-top: var(--mantine-spacing-xl);
|
||||
padding-top: 48px;
|
||||
.suggestion:hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.issuesSection,
|
||||
.contact {
|
||||
margin-top: 48px;
|
||||
padding-top: 36px;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.sectionTitle,
|
||||
.contactTitle {
|
||||
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
|
||||
.issuesSection > h2,
|
||||
.contact > h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: clamp(21px, 3vw, 26px);
|
||||
.issueList {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.section :global(.mantine-Accordion-root) {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
.issue {
|
||||
scroll-margin-top: 100px;
|
||||
padding: 26px 0;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.contact {
|
||||
border: 1px solid light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-9));
|
||||
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
|
||||
.issue:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.contactTitle {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.contactCopy {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.primaryAction {
|
||||
padding-inline: 18px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-top: 36px;
|
||||
}
|
||||
|
||||
.contact :global(.mantine-Group-root) {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.issue h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
@@ -1,252 +1,137 @@
|
||||
import {
|
||||
Accordion,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconKey,
|
||||
IconMail,
|
||||
IconMessageCircle,
|
||||
IconShieldLock,
|
||||
type TablerIcon,
|
||||
} from '@tabler/icons-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Anchor, Box, Button, Paper, Stack, Text, Textarea, Title } from '@mantine/core';
|
||||
import { IconMail, IconSearch } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import knownIssues from '../../known-issues.json';
|
||||
import { LegalPage } from '../components/LegalPage/LegalPage';
|
||||
import { SEO } from '../components/SEO/SEO';
|
||||
import classes from './SupportPage.module.css';
|
||||
|
||||
const TOPICS: Array<{
|
||||
href: string;
|
||||
icon: TablerIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
href: '#account',
|
||||
icon: IconKey,
|
||||
title: 'Account and access',
|
||||
description: 'Recovery phrases, device access, and account security.',
|
||||
},
|
||||
{
|
||||
href: '#messaging',
|
||||
icon: IconMessageCircle,
|
||||
title: 'Messaging and media',
|
||||
description: 'Messages, photos, videos, files, voice messages, and groups.',
|
||||
},
|
||||
{
|
||||
href: '#privacy',
|
||||
icon: IconShieldLock,
|
||||
title: 'Privacy and safety',
|
||||
description: 'Security questions, privacy information, and abuse reports.',
|
||||
},
|
||||
];
|
||||
|
||||
const ACCOUNT_FAQS = [
|
||||
{
|
||||
question: 'How do I access my account on a new device?',
|
||||
answer:
|
||||
'Install Rosetta and enter your recovery phrase in the exact order it was created. Rosetta may ask you to confirm a new device to protect your account.',
|
||||
},
|
||||
{
|
||||
question: 'I lost my recovery phrase. Can it be restored?',
|
||||
answer:
|
||||
'No. Your recovery phrase is the key to your account. Rosetta cannot recover it for you, so keep it private and backed up before you need it.',
|
||||
},
|
||||
{
|
||||
question: 'Why am I asked to confirm a new device?',
|
||||
answer:
|
||||
'New-device confirmation helps prevent someone who has obtained a recovery phrase from silently accessing your account on another device.',
|
||||
},
|
||||
{
|
||||
question: 'How do I report an account-access problem?',
|
||||
answer:
|
||||
'Email us with the device model, Rosetta version, and a short description of what happened. Do not include your recovery phrase or private keys.',
|
||||
},
|
||||
];
|
||||
|
||||
const MESSAGING_FAQS = [
|
||||
{
|
||||
question: 'How do I send a photo, video, or file?',
|
||||
answer:
|
||||
'Open a chat, tap the attachment button next to the message field, choose the item you want to share, then send it. Allow Photos or Files access when iOS asks for it.',
|
||||
},
|
||||
{
|
||||
question: 'Why is an attachment still sending?',
|
||||
answer:
|
||||
'Media uploads depend on the file size and your connection. Keep Rosetta open and connected until the send indicator completes. You can retry a message that failed.',
|
||||
},
|
||||
{
|
||||
question: 'How do I send a voice message?',
|
||||
answer:
|
||||
'In a chat, press and hold the microphone control to record, then release it to send. Microphone permission is required the first time you record.',
|
||||
},
|
||||
{
|
||||
question: 'How do I get help with a group?',
|
||||
answer:
|
||||
'Contact us with the group name and a description of the issue. Do not send private recovery phrases, message contents, or encryption keys.',
|
||||
},
|
||||
];
|
||||
|
||||
const PRIVACY_FAQS = [
|
||||
{
|
||||
question: 'How is message content protected?',
|
||||
answer:
|
||||
'Rosetta is designed to encrypt message content before it is sent. For details about how we handle data, read the Rosetta Privacy Policy.',
|
||||
},
|
||||
{
|
||||
question: 'How do I report abuse or a security issue?',
|
||||
answer: (
|
||||
<>
|
||||
Email{' '}
|
||||
<Anchor href="mailto:support@rosetta.im?subject=Rosetta%20Security%20Report">
|
||||
Support@rosetta.im
|
||||
</Anchor>{' '}
|
||||
with a description of the concern. Do not include recovery phrases or private
|
||||
keys.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface SupportSectionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
items: Array<{ question: string; answer: ReactNode }>;
|
||||
}
|
||||
|
||||
function SupportSection({ id, title, description, items }: SupportSectionProps) {
|
||||
return (
|
||||
<Box component="section" id={id} className={classes.section}>
|
||||
<div>
|
||||
<Title order={2} className={classes.sectionTitle}>
|
||||
{title}
|
||||
</Title>
|
||||
<Text c="dimmed" mt="xs" lh={1.65}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
<Accordion multiple variant="separated" radius="md">
|
||||
{items.map(({ question, answer }) => (
|
||||
<Accordion.Item key={question} value={question}>
|
||||
<Accordion.Control>{question}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Text c="dimmed" size="sm" lh={1.65}>
|
||||
{answer}
|
||||
</Text>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SupportPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const suggestions = useMemo(() => {
|
||||
if (!normalizedQuery) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const terms = normalizedQuery
|
||||
.split(/\s+/)
|
||||
.map((term) => term.replace(/[^a-z0-9-]/g, ''))
|
||||
.filter((term) => term.length > 2);
|
||||
|
||||
return knownIssues
|
||||
.map((issue) => {
|
||||
const searchable = [issue.title, issue.summary, issue.solution, ...issue.keywords]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const score = terms.filter((term) => searchable.includes(term)).length;
|
||||
|
||||
return { issue, score };
|
||||
})
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 4);
|
||||
}, [normalizedQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEO
|
||||
title="Rosetta Support"
|
||||
description="Contact Rosetta Support for help with account access, recovery phrases, messaging, media, privacy, and security."
|
||||
description="Find known Rosetta issues or contact support for help with account access, messaging, media, privacy, and security."
|
||||
canonical="https://rosetta.im/support/"
|
||||
keywords="Rosetta support, account recovery, messaging help, privacy help"
|
||||
keywords="Rosetta support, known issues, account recovery, messaging help"
|
||||
/>
|
||||
<LegalPage
|
||||
eyebrow="Rosetta Support"
|
||||
title="Help with your Rosetta account and messages."
|
||||
<LegalPage eyebrow="Help centre" title="Support">
|
||||
<Text className={classes.intro}>
|
||||
Describe the problem in your own words. Rosetta will suggest a relevant known
|
||||
issue while you type. The text is processed only in this page and is not sent
|
||||
to the support service.
|
||||
</Text>
|
||||
|
||||
<Box className={classes.searchArea}>
|
||||
<Textarea
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={18} />}
|
||||
leftSectionProps={{ style: { alignItems: 'flex-start', paddingTop: 16 } }}
|
||||
placeholder="For example: my attachment is still sending…"
|
||||
aria-label="Describe your Rosetta problem"
|
||||
minRows={6}
|
||||
autosize
|
||||
maxRows={10}
|
||||
radius="sm"
|
||||
className={classes.problemField}
|
||||
/>
|
||||
|
||||
{normalizedQuery && (
|
||||
<Paper withBorder radius="sm" className={classes.suggestions}>
|
||||
<Text fw={700} size="xs" tt="uppercase" c="dimmed">
|
||||
Suggested issues
|
||||
</Text>
|
||||
{suggestions.length > 0 ? (
|
||||
<Stack gap={0} mt="xs">
|
||||
{suggestions.map(({ issue }) => (
|
||||
<Anchor
|
||||
key={issue.id}
|
||||
href={`#${issue.id}`}
|
||||
className={classes.suggestion}
|
||||
>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Text className={classes.lead}>
|
||||
Find answers about account access, recovery phrases, private messaging,
|
||||
shared media, and privacy. For anything else, contact our support team.
|
||||
<Text fw={600} size="sm">
|
||||
{issue.title}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs" mt={3}>
|
||||
{issue.summary}
|
||||
</Text>
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed" size="sm" mt="xs">
|
||||
No matching known issue. You can contact support below.
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box component="section" className={classes.issuesSection}>
|
||||
<Title order={2}>Known issues</Title>
|
||||
<Text c="dimmed" size="sm" mt="xs" lh={1.7}>
|
||||
Common situations and the information needed to resolve them.
|
||||
</Text>
|
||||
|
||||
<Box className={classes.issueList}>
|
||||
{knownIssues.map((issue) => (
|
||||
<Box component="article" id={issue.id} key={issue.id} className={classes.issue}>
|
||||
<Title order={3}>{issue.title}</Title>
|
||||
<Text c="dimmed" size="sm" mt={6} lh={1.7}>
|
||||
{issue.summary}
|
||||
</Text>
|
||||
<Text size="sm" mt="md" lh={1.75}>
|
||||
{issue.solution}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box component="section" className={classes.contact}>
|
||||
<Title order={2}>Contact support</Title>
|
||||
<Text c="dimmed" size="sm" mt="xs" lh={1.7}>
|
||||
Include your operating system, Rosetta version, approximate time of the
|
||||
problem, and reproduction steps. Never send a recovery phrase or private
|
||||
key.
|
||||
</Text>
|
||||
<Button
|
||||
component="a"
|
||||
href="mailto:support@rosetta.im?subject=Rosetta%20Support%20Request"
|
||||
leftSection={<IconMail size={18} />}
|
||||
leftSection={<IconMail size={17} />}
|
||||
variant="default"
|
||||
mt="xl"
|
||||
size="xl"
|
||||
className={classes.primaryAction}
|
||||
mt="lg"
|
||||
>
|
||||
Email Rosetta Support
|
||||
support@rosetta.im
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{TOPICS.map(({ href, icon: Icon, title, description }) => (
|
||||
<Card
|
||||
key={href}
|
||||
component="a"
|
||||
href={href}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="lg"
|
||||
className={classes.topic}
|
||||
>
|
||||
<ThemeIcon variant="light" radius="xl" size="lg">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} mt="md">
|
||||
{title}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" mt={6} lh={1.5}>
|
||||
{description}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<SupportSection
|
||||
id="account"
|
||||
title="Account and access"
|
||||
description="Rosetta accounts are accessed with a recovery phrase instead of a phone number. Keep your phrase private and stored somewhere safe."
|
||||
items={ACCOUNT_FAQS}
|
||||
/>
|
||||
<SupportSection
|
||||
id="messaging"
|
||||
title="Messaging and media"
|
||||
description="Rosetta supports private chats, groups, photos, videos, files, and voice messages. Attachment choices can vary depending on your device permissions and the selected item."
|
||||
items={MESSAGING_FAQS}
|
||||
/>
|
||||
<SupportSection
|
||||
id="privacy"
|
||||
title="Privacy and safety"
|
||||
description="Rosetta is designed for private communication. If you have a security concern or a question about data handling, contact us directly."
|
||||
items={PRIVACY_FAQS}
|
||||
/>
|
||||
|
||||
<Paper radius="lg" p="xl" className={classes.contact}>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Title order={2} className={classes.contactTitle}>
|
||||
Need more help?
|
||||
</Title>
|
||||
<Text c="dimmed" mt="xs" className={classes.contactCopy}>
|
||||
Send us your device model, app version, and a short explanation.
|
||||
Never send a recovery phrase, private key, or private message
|
||||
content.
|
||||
</Text>
|
||||
</div>
|
||||
<Anchor
|
||||
href="mailto:support@rosetta.im?subject=Rosetta%20Support%20Request"
|
||||
fw={700}
|
||||
>
|
||||
Support@rosetta.im
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
</LegalPage>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user