···2121 readonly code: string;
2222}
23232424+/**
2525+ * Render a Mermaid diagram from its source string. Mermaid is dynamically
2626+ * imported so the ~500 KB library only loads on pages that use it (cabinet
2727+ * markdown preview, docs sequence diagrams).
2828+ */
2429export function MermaidBlock({ code }: MermaidBlockProps) {
2530 const containerRef = useRef<HTMLDivElement>(null);
2631 const [error, setError] = useState<string | null>(null);
···6469 );
6570 }
66716767- return <div ref={containerRef} className="my-4 flex justify-center [&>svg]:max-w-full" />;
7272+ // `role="img"` marks the rendered SVG as a single image for assistive tech.
7373+ // The accessible name comes from the wrapping `<figure aria-label=...>`
7474+ // in SequenceDiagram; if MermaidBlock is used standalone (MarkdownPreview),
7575+ // the surrounding context is expected to carry that meaning.
7676+ return (
7777+ <div
7878+ ref={containerRef}
7979+ role="img"
8080+ className="my-4 flex justify-center [&>svg]:max-w-full"
8181+ />
8282+ );
6883}
+173
apps/web/src/components/content/DocsSearch.tsx
···11+import { useEffect, useState, useMemo } from "react";
22+import { createPortal } from "react-dom";
33+import { useNavigate } from "@tanstack/react-router";
44+import { Command } from "cmdk";
55+import { MagnifyingGlassIcon } from "@phosphor-icons/react";
66+import { searchDocs, type SearchHit } from "@/lib/docs-search";
77+import { GROUP_META } from "@/lib/docs-registry";
88+99+/**
1010+ * Command-palette search over the docs. Triggered by `Cmd+K` / `Ctrl+K` or
1111+ * by clicking the sidebar search button. Searches doc titles, descriptions,
1212+ * and `##`+ headings extracted at build time from each MDX file.
1313+ *
1414+ * cmdk handles keyboard nav (Up/Down/Enter) and focus trapping within the
1515+ * palette. We just give it a list of candidates and a select handler.
1616+ */
1717+1818+interface DocsSearchProps {
1919+ readonly open: boolean;
2020+ readonly onOpenChange: (open: boolean) => void;
2121+}
2222+2323+export function DocsSearch({ open, onOpenChange }: DocsSearchProps) {
2424+ const navigate = useNavigate();
2525+ const [query, setQuery] = useState("");
2626+2727+ // Global Cmd+K / Ctrl+K toggle. Ignore when a modifier besides the intended
2828+ // meta/ctrl is active, to avoid intercepting browser shortcuts like Ctrl+Shift+K.
2929+ useEffect(() => {
3030+ const onKeyDown = (event: KeyboardEvent) => {
3131+ const isToggle =
3232+ (event.metaKey || event.ctrlKey) &&
3333+ !event.shiftKey &&
3434+ !event.altKey &&
3535+ event.key.toLowerCase() === "k";
3636+ if (isToggle) {
3737+ event.preventDefault();
3838+ onOpenChange(!open);
3939+ } else if (event.key === "Escape" && open) {
4040+ onOpenChange(false);
4141+ }
4242+ };
4343+ window.addEventListener("keydown", onKeyDown);
4444+ return () => window.removeEventListener("keydown", onKeyDown);
4545+ }, [open, onOpenChange]);
4646+4747+ // Clear the query on close so reopening starts fresh.
4848+ useEffect(() => {
4949+ if (!open) setQuery("");
5050+ }, [open]);
5151+5252+ const hits = useMemo(() => searchDocs(query), [query]);
5353+5454+ const onSelect = (hit: SearchHit) => {
5555+ onOpenChange(false);
5656+ // The href already includes the heading anchor when relevant.
5757+ void navigate({ to: hit.href });
5858+ };
5959+6060+ if (!open) return null;
6161+ // SSR guard: `document` isn't defined during server rendering. The palette
6262+ // is only ever opened via user interaction, so at that point we're on the
6363+ // client and the body is mounted. Portaling escapes any transformed/
6464+ // filtered ancestor that'd otherwise trap `position: fixed` to itself.
6565+ if (typeof document === "undefined") return null;
6666+6767+ return createPortal(
6868+ <div
6969+ role="presentation"
7070+ onClick={() => onOpenChange(false)}
7171+ className="fixed inset-0 z-50 flex items-start justify-center bg-black/30 p-4 pt-[10vh]"
7272+ >
7373+ <div
7474+ role="dialog"
7575+ aria-modal="true"
7676+ aria-label="Search documentation"
7777+ onClick={(event) => event.stopPropagation()}
7878+ className="bg-base-100 border-border-accent/40 w-full max-w-xl overflow-hidden rounded-xl border shadow-panel-lg"
7979+ >
8080+ <Command
8181+ label="Search documentation"
8282+ shouldFilter={false}
8383+ className="flex flex-col"
8484+ >
8585+ <div className="border-border-accent/30 flex items-center gap-2 border-b px-4 py-3">
8686+ <MagnifyingGlassIcon size={16} aria-hidden="true" className="text-text-muted" />
8787+ <Command.Input
8888+ autoFocus
8989+ value={query}
9090+ onValueChange={setQuery}
9191+ placeholder="Search docs…"
9292+ className="text-base-content placeholder:text-text-muted flex-1 bg-transparent text-[0.9rem] outline-none"
9393+ />
9494+ <kbd className="text-text-muted border-border-accent/40 rounded border px-1.5 py-0.5 text-[0.65rem]">
9595+ Esc
9696+ </kbd>
9797+ </div>
9898+ <Command.List className="max-h-[60vh] overflow-y-auto p-2">
9999+ {query.trim() === "" ? (
100100+ <div className="text-text-muted p-6 text-center text-[0.85rem]">
101101+ Start typing to search chapters, sections, and descriptions.
102102+ </div>
103103+ ) : hits.length === 0 ? (
104104+ <Command.Empty className="text-text-muted p-6 text-center text-[0.85rem]">
105105+ No matches for "{query}".
106106+ </Command.Empty>
107107+ ) : (
108108+ hits.map((hit, index) => (
109109+ <Command.Item
110110+ key={`${hit.docGroup ?? "flat"}-${hit.docSlug}`}
111111+ value={`${hit.docTitle} ${hit.docDescription} ${index}`}
112112+ onSelect={() => onSelect(hit)}
113113+ className="data-[selected=true]:bg-accent/40 flex cursor-pointer flex-col gap-0.5 rounded-lg px-3 py-2 transition-colors"
114114+ >
115115+ <div className="flex items-baseline gap-2">
116116+ <span className="text-base-content text-[0.9rem] font-medium">
117117+ {hit.docTitle}
118118+ </span>
119119+ {hit.docGroup && (
120120+ <span className="text-text-muted text-[0.7rem]">
121121+ {GROUP_META[hit.docGroup] ?? hit.docGroup}
122122+ </span>
123123+ )}
124124+ </div>
125125+ <span className="text-text-muted line-clamp-1 text-[0.75rem]">
126126+ {hit.docDescription}
127127+ </span>
128128+ </Command.Item>
129129+ ))
130130+ )}
131131+ </Command.List>
132132+ </Command>
133133+ </div>
134134+ </div>,
135135+ document.body,
136136+ );
137137+}
138138+139139+/**
140140+ * Button that opens the search palette. Meant for the docs sidebar header.
141141+ * Shows the current platform's Cmd/Ctrl shortcut hint.
142142+ */
143143+interface DocsSearchButtonProps {
144144+ readonly onClick: () => void;
145145+ readonly compact?: boolean;
146146+}
147147+148148+export function DocsSearchButton({ onClick, compact = false }: DocsSearchButtonProps) {
149149+ // Detect macOS once on mount — we need to pick ⌘ vs Ctrl for the hint.
150150+ const [isMac, setIsMac] = useState(false);
151151+ useEffect(() => {
152152+ setIsMac(/mac/i.test(navigator.platform) || /mac/i.test(navigator.userAgent));
153153+ }, []);
154154+ const modifierKey = isMac ? "⌘" : "Ctrl";
155155+156156+ return (
157157+ <button
158158+ type="button"
159159+ onClick={onClick}
160160+ aria-label="Search documentation (keyboard shortcut available)"
161161+ aria-keyshortcuts={isMac ? "Meta+K" : "Control+K"}
162162+ className={`border-border-accent/40 bg-base-100 text-text-muted hover:border-primary/40 hover:text-base-content flex w-full items-center gap-2 rounded-lg border px-3 transition-colors ${
163163+ compact ? "py-1.5 text-[0.72rem]" : "py-2 text-[0.82rem]"
164164+ }`}
165165+ >
166166+ <MagnifyingGlassIcon size={compact ? 12 : 14} aria-hidden="true" />
167167+ <span className="flex-1 text-left">Search docs</span>
168168+ <kbd className="border-border-accent/40 rounded border px-1 py-0.5 text-[0.65rem]">
169169+ {modifierKey}K
170170+ </kbd>
171171+ </button>
172172+ );
173173+}
+131-21
apps/web/src/components/content/chapter.tsx
···11import {
22 type ReactNode,
33 type ReactElement,
44+ type KeyboardEvent,
55+ Suspense,
66+ lazy,
77+ useId,
88+ useRef,
49 useState,
510 Children,
611 isValidElement,
77- createContext,
88- useContext,
912} from "react";
1013import { InfoIcon, WarningIcon, DesktopIcon, TerminalIcon } from "@phosphor-icons/react";
1114import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
1215import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
13161717+/* Mermaid is ~500 KB and only loads when a page actually renders a diagram. */
1818+const MermaidBlock = lazy(() =>
1919+ import("./MermaidBlock").then((m) => ({ default: m.MermaidBlock })),
2020+);
2121+1422/* ─── Chapter header ───────────────────────────────────────────────────────── */
15231624interface ChapterHeaderProps {
···4452 container: "border-info/30 bg-info/5",
4553 icon: InfoIcon,
4654 iconClass: "text-info",
5555+ /** Visually-hidden prefix so screen readers announce context ("Note:" vs "Warning:"). */
5656+ srLabel: "Note:",
4757 },
4858 warning: {
4959 container: "border-warning/30 bg-warning/5",
5060 icon: WarningIcon,
5161 iconClass: "text-warning",
6262+ srLabel: "Warning:",
5263 },
5364} as const;
5465···6677 role="note"
6778 className={`my-6 flex items-center gap-3 rounded-xl border p-4 ${style.container}`}
6879 >
6969- <IconComponent size={18} weight="fill" className={`mt-0.5 shrink-0 ${style.iconClass}`} />
7070- <div className="prose text-[0.88rem] leading-relaxed">{children}</div>
8080+ <IconComponent
8181+ size={18}
8282+ weight="fill"
8383+ aria-hidden="true"
8484+ className={`mt-0.5 shrink-0 ${style.iconClass}`}
8585+ />
8686+ <div className="prose text-[0.88rem] leading-relaxed">
8787+ <span className="sr-only">{style.srLabel} </span>
8888+ {children}
8989+ </div>
7190 </aside>
7291 );
7392}
74937594/* ─── Platform toggle (Web App / CLI tabs) ─────────────────────────────────── */
7676-7777-const PlatformContext = createContext("Web App");
78957996interface PlatformToggleProps {
8097 readonly children: ReactNode;
8198}
8299100100+/**
101101+ * WAI-ARIA tabs pattern for "how do I do this on the web app / on the CLI?"
102102+ * blocks. Implements the manual-activation variant of the
103103+ * [Tabs APG pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/):
104104+ * left/right/home/end navigate between tabs and also activate them, since
105105+ * each tab swap is cheap (just toggles a `hidden` attribute).
106106+ *
107107+ * PlatformTab is a prop-bag component whose props (`name`, `children`) are
108108+ * read here — it doesn't render anything on its own. All panels render into
109109+ * the DOM and `hidden` is used to mask inactive ones, which keeps
110110+ * `aria-controls` pointing at a real tabpanel element whether or not it's
111111+ * currently visible.
112112+ */
83113export function PlatformToggle({ children }: PlatformToggleProps) {
84114 const tabs = Children.toArray(children).filter(
85115 (child): child is ReactElement<PlatformTabProps> =>
···9112192122 const tabNames = tabs.map((tab) => tab.props.name);
93123 const [active, setActive] = useState(tabNames[0] ?? "Web App");
124124+ const id = useId();
125125+ const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
126126+127127+ const moveFocus = (currentIndex: number, key: string): boolean => {
128128+ // eslint-disable-next-line functional/no-let -- computed branching below
129129+ let nextIndex = -1;
130130+ if (key === "ArrowRight") nextIndex = (currentIndex + 1) % tabNames.length;
131131+ else if (key === "ArrowLeft")
132132+ nextIndex = (currentIndex - 1 + tabNames.length) % tabNames.length;
133133+ else if (key === "Home") nextIndex = 0;
134134+ else if (key === "End") nextIndex = tabNames.length - 1;
135135+136136+ if (nextIndex < 0) return false;
137137+138138+ const nextName = tabNames[nextIndex];
139139+ if (nextName) {
140140+ setActive(nextName);
141141+ tabRefs.current[nextIndex]?.focus();
142142+ }
143143+ return true;
144144+ };
145145+146146+ const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
147147+ if (moveFocus(index, event.key)) {
148148+ event.preventDefault();
149149+ }
150150+ };
9415195152 return (
96153 <div className="border-border-accent/40 my-6 overflow-hidden rounded-xl border">
9797- <div role="tablist" className="bg-base-200/60 flex border-b border-inherit">
9898- {tabNames.map((name) => {
154154+ <div
155155+ role="tablist"
156156+ aria-label="Platform"
157157+ className="bg-base-200/60 flex border-b border-inherit"
158158+ >
159159+ {tabNames.map((name, index) => {
99160 const isActive = name === active;
100161 const Icon = name === "CLI" ? TerminalIcon : DesktopIcon;
101162102163 return (
103164 <button
104165 key={name}
166166+ type="button"
105167 role="tab"
168168+ id={`${id}-tab-${index}`}
169169+ aria-controls={`${id}-panel-${index}`}
106170 aria-selected={isActive}
171171+ tabIndex={isActive ? 0 : -1}
172172+ ref={(el) => {
173173+ // eslint-disable-next-line functional/immutable-data -- ref array mutation is the React pattern
174174+ tabRefs.current[index] = el;
175175+ }}
107176 onClick={() => setActive(name)}
177177+ onKeyDown={(event) => handleKeyDown(event, index)}
108178 className={`text-ui flex items-center gap-1.5 px-4 py-2.5 font-medium transition-colors ${
109179 isActive
110180 ? "border-primary text-base-content border-b-2"
111181 : "text-text-muted hover:text-secondary"
112182 }`}
113183 >
114114- <Icon size={14} />
184184+ <Icon size={14} aria-hidden="true" />
115185 {name}
116186 </button>
117187 );
118188 })}
119189 </div>
120120- <div className="bg-base-100 p-4">
121121- <PlatformContext.Provider value={active}>{children}</PlatformContext.Provider>
122122- </div>
190190+ {tabs.map((tab, index) => {
191191+ const isActive = tab.props.name === active;
192192+ return (
193193+ <div
194194+ key={tab.props.name}
195195+ role="tabpanel"
196196+ id={`${id}-panel-${index}`}
197197+ aria-labelledby={`${id}-tab-${index}`}
198198+ hidden={!isActive}
199199+ tabIndex={0}
200200+ className="bg-base-100 p-4"
201201+ >
202202+ <div className="prose text-[0.88rem] leading-relaxed">{tab.props.children}</div>
203203+ </div>
204204+ );
205205+ })}
123206 </div>
124207 );
125208}
···129212 readonly children: ReactNode;
130213}
131214132132-export function PlatformTab({ name, children }: PlatformTabProps) {
133133- const active = useContext(PlatformContext);
134134- if (name !== active) return null;
135135-215215+/**
216216+ * Declarative marker for a single panel inside a {@link PlatformToggle}.
217217+ * The parent `PlatformToggle` reads this element's `name` and `children`
218218+ * props directly; this function body is only used as a fallback when the
219219+ * component is rendered outside a toggle (e.g. a misnested page).
220220+ */
221221+export function PlatformTab({ children }: PlatformTabProps) {
136222 return <div className="prose text-[0.88rem] leading-relaxed">{children}</div>;
137223}
138224···199285/* ─── Sequence diagram (placeholder) ───────────────────────────────────────── */
200286201287interface SequenceDiagramProps {
202202- readonly id: string;
288288+ /** Mermaid source. Typically imported from the page's local `_diagrams.ts`. */
289289+ readonly code: string;
290290+ /** Short caption rendered below the diagram for context and accessibility. */
291291+ readonly caption?: string;
203292}
204293205205-export function SequenceDiagram({ id }: SequenceDiagramProps) {
294294+/**
295295+ * Render a Mermaid sequence diagram (or any Mermaid-supported flowchart) in a
296296+ * docs page. Use alongside short prose explaining what the diagram shows —
297297+ * the caption is the primary accessible label, since the generated SVG isn't
298298+ * structured for screen readers.
299299+ */
300300+export function SequenceDiagram({ code, caption }: SequenceDiagramProps) {
206301 return (
207207- <div className="border-border-accent/30 bg-base-200/30 my-6 flex min-h-32 items-center justify-center rounded-xl border border-dashed">
208208- <span className="text-text-muted text-ui italic">Diagram: {id}</span>
209209- </div>
302302+ <figure className="not-prose my-8" role="group" aria-label={caption}>
303303+ <div className="border-border-accent/30 bg-base-200/30 overflow-x-auto rounded-xl border p-4">
304304+ <Suspense
305305+ fallback={
306306+ <div className="text-text-muted text-ui flex min-h-32 items-center justify-center italic">
307307+ Loading diagram…
308308+ </div>
309309+ }
310310+ >
311311+ <MermaidBlock code={code} />
312312+ </Suspense>
313313+ </div>
314314+ {caption && (
315315+ <figcaption className="text-text-muted text-ui mt-3 text-center italic">
316316+ {caption}
317317+ </figcaption>
318318+ )}
319319+ </figure>
210320 );
211321}
···11+// Mermaid sources for the /build/ docs pages.
22+//
33+// Kept in a `.ts` file rather than inline in the `.mdx` files because MDX 3
44+// dedents template literals inside `.mdx`, which mangles mermaid whitespace.
55+66+/** Device pairing via the CLI. Shows which command runs on which device. */
77+export const pairingCliFlow = `sequenceDiagram
88+ participant New as New device
99+ participant PDS as Your PDS
1010+ participant Old as Existing device
1111+1212+ Note over New: opake pair request
1313+ New->>New: generate one-time keypair
1414+ New->>PDS: publish pair request<br/>(new device's public half)
1515+1616+ Note over Old: opake pair approve
1717+ Old->>PDS: poll for pair requests
1818+ PDS-->>Old: request record
1919+ Old->>Old: wrap identity keys<br/>with new device's public half
2020+ Old->>PDS: publish pair response
2121+2222+ New->>PDS: poll for pair response
2323+ PDS-->>New: response record
2424+ New->>New: unwrap with one-time key<br/>→ identity installed
2525+ New->>PDS: delete both records
2626+`;
+199-51
apps/web/src/content/docs/build/cli.mdx
···11+import { pairingCliFlow } from "./_diagrams";
22+13<ChapterHeader title="The CLI Manual" />
2435<Lead>
44- The Opake CLI is the reference implementation of the protocol. Everything the web app and SDK
55- can do, the CLI can do first — identity, files, sharing, workspaces, device pairing, daemon
66- maintenance.
66+ `opake` is the reference client. Everything the web app does maps back to a command here, and
77+ a few things only the CLI does today: shell completions, the background maintenance daemon,
88+ and the scripting hooks you'd want for pipelines and cron jobs.
79</Lead>
81099-## Installation
1111+## Installing
10121111-The CLI is built in Rust and requires building from source. You'll need Rust 1.75+ and Git.
1313+The CLI is written in Rust. For now it only ships as source; pre-built binaries and a
1414+crates.io release are on the roadmap. Needs Rust 1.75 or later and Git.
12151316<CodeBlock language="sh">git clone https://tangled.org/opake.app/opake</CodeBlock>
14171518<CodeBlock language="sh">cd opake && cargo install --path apps/cli</CodeBlock>
16191717-This puts the `opake` binary in your `~/.cargo/bin/` directory. Pre-built binaries and crates.io publishing are planned.
2020+`cargo install` drops the binary at `~/.cargo/bin/opake`. Confirm with `opake --version`.
2121+2222+### Shell completions
2323+2424+Every modern shell gets tab-completion for commands, flags, and subcommands. Pick yours:
2525+2626+<CodeBlock language="sh">opake completions bash > ~/.local/share/bash-completion/completions/opake</CodeBlock>
2727+2828+<CodeBlock language="sh">opake completions zsh > ~/.zfunc/_opake</CodeBlock>
2929+3030+<CodeBlock language="sh">opake completions fish > ~/.config/fish/completions/opake.fish</CodeBlock>
3131+3232+---
3333+3434+## How the CLI thinks
3535+3636+Three concepts orient the rest of this page.
3737+3838+**Paths** look like file paths: `report.pdf`, `projects/Q4/notes.md`, `/`. Internally Opake
3939+identifies records by AT-URI (`at://did:plc:xxx/app.opake.document/yyy`), and most commands
4040+accept either — the friendly path when you're at a prompt, the URI when you're piping URIs
4141+around between commands.
4242+4343+**Workspaces** are a separate namespace from your personal cabinet. A bare `opake ls` lists
4444+the cabinet; `opake ls --workspace family-photos` lists that workspace. Every file-touching
4545+command takes the same `--workspace` flag.
4646+4747+**Accounts** stack. `opake account login` adds one; `opake account list` shows the stack;
4848+`--as alice.bsky.social` runs a single command against a specific account without flipping
4949+the default. Useful when you keep a personal account and a work account side by side and
5050+don't want to remember which is currently primary.
5151+5252+Two more globals worth knowing up front: `-v` increases log verbosity (`-vv` debug, `-vvv`
5353+trace), and `--help` works on every subcommand.
18541955---
20562121-## 1. Identity & Session Management
5757+## 1. Signing in
5858+5959+OAuth through your PDS is the default. The first `opake account login` on a fresh machine also
6060+generates your identity keys and prints a **24-word seed phrase** — write it down before you
6161+close the window. It's the only thing that can bring your identity back if you lose every
6262+device you're signed in on.
6363+6464+<CodeBlock language="sh">opake account login alice.bsky.social</CodeBlock>
6565+6666+If your PDS is somewhere other than the Bluesky cluster, point at it:
6767+6868+<CodeBlock language="sh">opake account login alice.example.com --pds https://pds.example.com</CodeBlock>
6969+7070+For PDSes that don't speak OAuth, fall back to app-password auth:
7171+7272+<CodeBlock language="sh">opake account login alice.example.com --legacy</CodeBlock>
22732323-Everything starts with a session. Opake supports multiple accounts and uses AT Protocol OAuth by default.
7474+### Coming back to an existing identity
24752525-### Login
7676+Two ways to sign in on a new machine without generating a fresh identity: pair off an existing
7777+device, or restore from the seed phrase.
26782727-<CodeBlock language="sh">opake account login you.bsky.social</CodeBlock>
7979+<CodeBlock language="sh">opake pair request # on the new device</CodeBlock>
8080+8181+<CodeBlock language="sh">opake pair approve # on the existing device</CodeBlock>
8282+8383+Pairing hands the new device a wrapped copy of your identity keys through your PDS. Both sides
8484+delete their half of the handshake as soon as the transfer is complete.
8585+8686+<SequenceDiagram
8787+ code={pairingCliFlow}
8888+ caption="Device pairing. Both devices coordinate through your PDS; neither side ever transmits your identity keys in the clear."
8989+/>
28902929-Override the PDS or fall back to legacy app-password auth:
9191+The other way back in is the seed phrase, in case no paired device is available:
30923131-<CodeBlock language="sh">opake account login you.bsky.social --pds https://pds.example.com</CodeBlock>
9393+<CodeBlock language="sh">opake recover # interactive; prompts for all 24 words</CodeBlock>
32943333-<CodeBlock language="sh">opake account login you.bsky.social --legacy</CodeBlock>
9595+<CodeBlock language="sh">opake recover -f seed-backup.txt</CodeBlock>
34963535-### Managing Accounts
9797+### Managing the account stack
36983799<CodeBlock language="sh">opake account list</CodeBlock>
3810039101<CodeBlock language="sh">opake account set-default bob.other.com</CodeBlock>
401024141-Use a specific account for a single command with `--as`:
103103+<CodeBlock language="sh">opake ls --as alice.bsky.social</CodeBlock>
421044343-<CodeBlock language="sh">opake ls --as alice.example.com</CodeBlock>
105105+`--as` accepts a handle or a DID. Handles get resolved fresh on each invocation, so if someone
106106+moved providers recently the CLI still finds them.
4410745108---
461094747-## 2. The Social Filesystem
110110+## 2. Files
481114949-Managing your encrypted vault from the terminal.
112112+`opake upload` puts a file in your cabinet. Destination paths are optional; omit them and the
113113+file lands at the root.
501145151-### Upload & Download
115115+<CodeBlock language="sh">opake upload report.pdf</CodeBlock>
521165353-<CodeBlock language="sh">opake upload photo.jpg --description "Beach vacation"</CodeBlock>
117117+<CodeBlock language="sh">opake upload report.pdf --dir projects/Q4</CodeBlock>
541185555-<CodeBlock language="sh">opake download photo.jpg -o ~/Downloads/copy.jpg</CodeBlock>
119119+<CodeBlock language="sh">opake upload report.pdf --description "Quarterly financial summary"</CodeBlock>
561205757-Decrypt and stream to stdout without saving locally:
121121+<CodeBlock language="sh">opake upload report.pdf --workspace team</CodeBlock>
581225959-<CodeBlock language="sh">opake cat notes.txt</CodeBlock>
123123+### Reading them back
601246161-### Organization
125125+<CodeBlock language="sh">opake ls</CodeBlock>
621266363-<CodeBlock language="sh">opake ls --long</CodeBlock>
127127+<CodeBlock language="sh">opake ls projects/Q4 -l # long format, sizes + mime types</CodeBlock>
641286565-<CodeBlock language="sh">opake mkdir Photos</CodeBlock>
129129+<CodeBlock language="sh">opake ls --tag finance</CodeBlock>
6613067131<CodeBlock language="sh">opake tree</CodeBlock>
681326969-### Metadata
133133+<CodeBlock language="sh">opake download report.pdf -o ~/Downloads/</CodeBlock>
701347171-View or modify a document's metadata after upload:
135135+<CodeBlock language="sh">opake cat notes.md # decrypts to stdout, pipe-friendly</CodeBlock>
721367373-<CodeBlock language="sh">opake metadata photo.jpg</CodeBlock>
137137+`opake cat` is `opake download --stdout` with a shorter name, and it composes with the rest of
138138+your shell:
741397575-<CodeBlock language="sh">opake metadata photo.jpg --rename "sunset.jpg"</CodeBlock>
140140+<CodeBlock language="sh">opake cat grocery-list.txt | grep -i bread</CodeBlock>
761417777-<CodeBlock language="sh">opake metadata photo.jpg --add-tag vacation --add-tag beach</CodeBlock>
142142+### Moving, renaming, deleting
781437979-<CodeBlock language="sh">
8080- opake metadata photo.jpg --description "Golden hour at Scheveningen"
8181-</CodeBlock>
144144+Rename is a metadata change. Move is structural.
145145+146146+<CodeBlock language="sh">opake metadata rename report.pdf quarterly-report.pdf</CodeBlock>
147147+148148+<CodeBlock language="sh">opake move report.pdf projects/Q4/</CodeBlock>
149149+150150+<CodeBlock language="sh">opake mkdir archive</CodeBlock>
151151+152152+<CodeBlock language="sh">opake rm old-draft.md</CodeBlock>
153153+154154+<CodeBlock language="sh">opake rm -r archive/</CodeBlock>
155155+156156+### Editing metadata
157157+158158+Document metadata is the part Opake still lets you see in the clear — locally. Names, tags,
159159+and descriptions are encrypted on the wire but rendered in your terminal.
160160+161161+<CodeBlock language="sh">opake metadata show report.pdf</CodeBlock>
162162+163163+<CodeBlock language="sh">opake metadata describe report.pdf "Q4 financial summary"</CodeBlock>
164164+165165+<CodeBlock language="sh">opake metadata tag add report.pdf finance</CodeBlock>
166166+167167+<CodeBlock language="sh">opake metadata tag remove report.pdf draft</CodeBlock>
8216883169---
841708585-## 3. Sharing & Collaboration
171171+## 3. Sharing
861728787-### Direct Sharing (Grants)
173173+### One file, one person
8817489175<CodeBlock language="sh">opake share new secret.pdf bob.bsky.social</CodeBlock>
901769191-<CodeBlock language="sh">opake share list</CodeBlock>
177177+Opake resolves the handle, fetches Bob's public key from his PDS, wraps the file's content key
178178+against it, and publishes a grant record. Bob sees the file appear in his inbox the next time
179179+his client syncs.
921809393-<CodeBlock language="sh">opake share inbox</CodeBlock>
181181+<CodeBlock language="sh">opake share list # grants you've issued</CodeBlock>
182182+183183+<CodeBlock language="sh">opake share inbox # grants issued to you</CodeBlock>
9418495185<CodeBlock language="sh">opake share revoke at://did:plc:123/app.opake.grant/tid456</CodeBlock>
961869797-### Group Sharing (Workspaces)
187187+Revoking deletes the grant record. It doesn't un-decrypt any copy Bob has already downloaded
188188+— that ship sailed the moment he opened the file. What revocation does buy you is: Bob can't
189189+re-fetch, can't get future updates, and anyone else browsing his repo won't see the grant.
190190+191191+Grab the URI of a specific grant from `opake share list`.
192192+193193+### A group of people
194194+195195+Workspaces are shared folders with a shared encryption key. Adding someone to the workspace
196196+gives them access to every file in it; removing them doesn't re-encrypt history, but it does
197197+cut them off from future changes.
198198+199199+<CodeBlock language="sh">opake workspace create family-photos</CodeBlock>
200200+201201+<CodeBlock language="sh">opake workspace add-member family-photos alice.bsky.social</CodeBlock>
202202+203203+<CodeBlock language="sh">opake workspace ls -l</CodeBlock>
204204+205205+<CodeBlock language="sh">opake upload vacation.jpg --workspace family-photos</CodeBlock>
206206+207207+<CodeBlock language="sh">opake workspace remove-member family-photos alice.bsky.social</CodeBlock>
208208+209209+<CodeBlock language="sh">opake workspace leave family-photos</CodeBlock>
982109999-<CodeBlock language="sh">opake workspace create "The Collective"</CodeBlock>
211211+---
100212101101-<CodeBlock language="sh">opake workspace add-member "The Collective" alice.bsky.social</CodeBlock>
213213+## 4. Looking someone up
102214103103-<CodeBlock language="sh">opake upload internal-docs.zip --workspace "The Collective"</CodeBlock>
215215+Before you ship a grant to a handle you got over email or Signal, it's sometimes worth
216216+verifying who's on the other end:
217217+218218+<CodeBlock language="sh">opake resolve bob.bsky.social</CodeBlock>
219219+220220+Prints Bob's DID, his PDS, and his X25519 encryption public key. Ask Bob to read his key
221221+fingerprint to you out-of-band; if it matches what `opake resolve` shows, you've confirmed the
222222+identity the grant will actually target.
104223105224---
106225107107-## 4. Maintenance & Security
226226+## 5. Background sync
108227109109-### Device Pairing
228228+The daemon runs maintenance tasks that don't need to be interactive: the SSE subscription for
229229+live updates, proactive token refresh, and re-encryption passes after a key rotation. You
230230+don't strictly need it for day-to-day use — every interactive command triggers the minimum
231231+sync it needs before running. The daemon just means you don't eat that latency each time.
110232111111-<CodeBlock language="sh">opake pair request</CodeBlock>
233233+<CodeBlock language="sh">opake daemon run # foreground, for systemd / launchd units</CodeBlock>
112234113113-Run the above on the **new** device, then approve on the **existing** device:
235235+<CodeBlock language="sh">opake daemon list-tasks # what's queued and what's running</CodeBlock>
114236115115-<CodeBlock language="sh">opake pair approve</CodeBlock>
237237+<CodeBlock language="sh">opake daemon install # generate + install a launchd or systemd unit</CodeBlock>
116238117117-### The Nuclear Option
239239+<CodeBlock language="sh">opake daemon uninstall</CodeBlock>
240240+241241+`opake daemon install` picks the right service format for your platform (launchd on macOS,
242242+systemd on Linux) and writes the unit file where the service manager expects it. The service
243243+starts automatically on login.
244244+245245+---
246246+247247+## 6. Starting over
248248+249249+Three escalating levels of clean slate.
118250119251<CodeBlock language="sh">opake account logout alice.bsky.social</CodeBlock>
252252+253253+Drops local session credentials for one account. Your encrypted files stay on your PDS
254254+untouched; signing back in picks them right up.
120255121256<CodeBlock language="sh">opake rm -r /</CodeBlock>
257257+258258+Empties your cabinet of every document and directory but keeps your identity, keys, and
259259+workspace memberships intact. Good for when you want to repopulate from scratch without
260260+re-pairing or regenerating anything.
122261123262<CodeBlock language="sh">opake purge --dry-run</CodeBlock>
124263125264<CodeBlock language="sh">opake purge --force</CodeBlock>
126265266266+The real nuke. Deletes every `app.opake.*` record from your PDS — files, grants, workspaces,
267267+published encryption key, the lot. Your atproto identity itself survives (that belongs to the
268268+PDS, not Opake), but every trace of Opake having been there is gone. `--dry-run` lists what
269269+would go without touching anything.
270270+127271<Callout type="warning">
128128- **The Mini Nuke:** Running `opake rm -r /` clears your entire vault while keeping your
129129- cryptographic identity intact. Use it when you want a fresh start without re-pairing devices.
272272+ `opake purge --force` is irreversible and doesn't prompt. Grants you've issued to other
273273+ people still point at records that no longer exist after a purge — the links break silently
274274+ on their end.
130275</Callout>
131276132277<Callout type="info">
133133- **Pro-Tip:** Every command supports `--help` for detailed usage and sub-command options.
278278+ Every subcommand takes `--help` for full flags and arguments. Pair it with `-v` when
279279+ something's misbehaving and you want to see the underlying XRPC calls.
134280</Callout>
281281+282282+<DocsNext slug="cli" />
+2
apps/web/src/content/docs/build/lexicons.mdx
···205205re-exports the field-level types via `@opake/sdk` — check
206206[Files & directories](/docs/sdk/files) and [Sharing](/docs/sdk/sharing) for how they
207207surface in the high-level API.
208208+209209+<DocsNext slug="lexicons" />
+3-3
apps/web/src/content/docs/faq.mdx
···2626</FaqItem>
27272828<FaqItem question="Can I share files with people who don't use Opake?">
2929- The recipient needs an AT Protocol identity (a DID) to receive a [Grant](/docs/sharing) — the
3030- grant wraps the file's content key to their published public key, and there's no public key to
3131- wrap to if they've never set up an account. If you want to share with someone outside the
2929+ The recipient needs an AT Protocol identity (a DID) to receive a [Grant](/docs/sharing). The
3030+ grant is the file's key, encrypted just for them — and if they've never set up an account,
3131+ there's no public key to encrypt it against. If you want to share with someone outside the
3232 network, help them sign up first.
3333</FaqItem>
3434
+27-22
apps/web/src/content/docs/index.mdx
···29293030 <DocsIndexCard href="/docs/sharing" icon="share">
3131 <DocsIndexTitle>Sharing</DocsIndexTitle>
3232- <DocsIndexBody>Share a file with one other person — no accounts, no invites, just their handle.</DocsIndexBody>
3232+ <DocsIndexBody>Share a file with someone else. No account setup on their end, no invite link, just their handle.</DocsIndexBody>
3333 </DocsIndexCard>
34343535 <DocsIndexCard href="/docs/workspaces" icon="group">
3636 <DocsIndexTitle>Workspaces</DocsIndexTitle>
3737- <DocsIndexBody>Share folders with teams, families, or research groups without re-encrypting each file.</DocsIndexBody>
3737+ <DocsIndexBody>Share folders with teams, families, or research groups. Add and remove people without re-uploading files.</DocsIndexBody>
3838 </DocsIndexCard>
39394040 <DocsIndexCard href="/docs/pairing" icon="pairing">
4141 <DocsIndexTitle>Multi-Device</DocsIndexTitle>
4242- <DocsIndexBody>Move your identity to a new phone or laptop without exposing it to the network.</DocsIndexBody>
4242+ <DocsIndexBody>Move your identity onto a new phone or laptop without putting it on the network in plaintext.</DocsIndexBody>
4343 </DocsIndexCard>
44444545 <DocsIndexCard href="/docs/seed-phrase" icon="seedling">
4646- <DocsIndexTitle>Your Seed Phrase</DocsIndexTitle>
4747- <DocsIndexBody>Back up your identity with a 24-word phrase — the fallback when all else fails.</DocsIndexBody>
4646+ <DocsIndexTitle>What your key actually looks like</DocsIndexTitle>
4747+ <DocsIndexBody>Twenty-four words that can bring your identity back on any device — the fallback when nothing else is left.</DocsIndexBody>
4848 </DocsIndexCard>
49495050 <DocsIndexCard href="/docs/troubleshooting" icon="question">
···5555</DocsIndexSection>
56565757<DocsIndexSection
5858+ label="For developers"
5959+ description="Program against Opake. The CLI is the reference implementation; the SDK and React bindings wrap the same primitives for building your own clients."
6060+>
6161+ <DocsIndexGrid>
6262+ <DocsIndexCard href="/docs/cli" icon="terminal">
6363+ <DocsIndexTitle>The CLI Manual</DocsIndexTitle>
6464+ <DocsIndexBody>Complete command reference for the Opake CLI — identity, files, sharing, and more.</DocsIndexBody>
6565+ </DocsIndexCard>
6666+6767+ <DocsIndexCard href="/docs/sdk/overview" icon="book">
6868+ <DocsIndexTitle>@opake/sdk</DocsIndexTitle>
6969+ <DocsIndexBody>Install, initialise, and ship your first encrypted upload with the TypeScript SDK.</DocsIndexBody>
7070+ </DocsIndexCard>
7171+7272+ <DocsIndexCard href="/docs/react/overview" icon="react">
7373+ <DocsIndexTitle>@opake/react</DocsIndexTitle>
7474+ <DocsIndexBody>Quickly ship front-end applications using our React wrappers.</DocsIndexBody>
7575+ </DocsIndexCard>
7676+ </DocsIndexGrid>
7777+</DocsIndexSection>
7878+7979+<DocsIndexSection
5880 label="Under the hood"
5981 description="How Opake protects your data — the crypto, the records, the protocol. Written for anyone curious enough to look, not just developers."
6082>
···7698 </DocsIndexGrid>
7799</DocsIndexSection>
781007979-<DocsIndexSection
8080- label="For developers"
8181- description="Program against Opake. The CLI is the reference implementation; the SDK and React bindings wrap the same primitives for building your own clients."
8282->
8383- <DocsIndexGrid>
8484- <DocsIndexCard href="/docs/cli" icon="terminal">
8585- <DocsIndexTitle>The CLI Manual</DocsIndexTitle>
8686- <DocsIndexBody>Complete command reference for the Opake CLI — identity, files, sharing, and more.</DocsIndexBody>
8787- </DocsIndexCard>
8888-8989- <DocsIndexCard href="/docs/sdk/overview" icon="book">
9090- <DocsIndexTitle>@opake/sdk</DocsIndexTitle>
9191- <DocsIndexBody>Install, initialise, and ship your first encrypted upload with the TypeScript SDK.</DocsIndexBody>
9292- </DocsIndexCard>
9393- </DocsIndexGrid>
9494-</DocsIndexSection>
9595-
+98
apps/web/src/content/docs/understand/_diagrams.ts
···11+// Mermaid sources for the /understand/ docs pages.
22+//
33+// Kept in a `.ts` file rather than inline in the `.mdx` files because MDX 3
44+// dedents template literals inside `.mdx`, which mangles mermaid whitespace
55+// (mermaid is sensitive to leading indentation on lines inside
66+// sequenceDiagram / flowchart blocks). Imports bypass that.
77+//
88+// These are simplified versions of the internal flows in `docs/flows/*.md` —
99+// implementation-detail names like `FileManager`, `#[signoff]`, and
1010+// `com.atproto.repo.createRecord` are collapsed to the underlying operation
1111+// a reader actually needs to picture.
1212+1313+/** Encrypt-and-upload, end-to-end. */
1414+export const uploadFlow = `sequenceDiagram
1515+ participant App as Opake App
1616+ participant Crypto as Client-side crypto
1717+ participant PDS as Your PDS
1818+1919+ App->>Crypto: generate random content key K (256-bit)
2020+ App->>Crypto: encrypt file with K
2121+ Crypto-->>App: ciphertext blob
2222+2323+ App->>PDS: upload ciphertext as a blob
2424+ PDS-->>App: blob reference (CID)
2525+2626+ App->>Crypto: wrap K with your public key
2727+ Crypto-->>App: wrappedKey
2828+2929+ App->>Crypto: encrypt metadata with K
3030+ Crypto-->>App: encrypted metadata
3131+3232+ App->>PDS: publish document record<br/>(blob ref + wrappedKey + encrypted metadata)
3333+ PDS-->>App: record URI
3434+3535+ Note over PDS: PDS never sees<br/>the plaintext or K
3636+`;
3737+3838+/** Granting access to another user and the recipient's download. */
3939+export const sharingFlow = `sequenceDiagram
4040+ participant You
4141+ participant YourPDS as Your PDS
4242+ participant Indexer
4343+ participant Friend
4444+4545+ You->>YourPDS: resolve friend's handle → DID
4646+ You->>YourPDS: fetch friend's public encryption key
4747+4848+ You->>You: wrap file's content key<br/>with friend's public key
4949+ You->>YourPDS: publish Grant record<br/>(document URI + wrappedKey)
5050+5151+ YourPDS-->>Indexer: firehose event: new grant
5252+ Indexer-->>Friend: inbox: "new share from you"
5353+5454+ Friend->>YourPDS: fetch the grant
5555+ YourPDS-->>Friend: grant record
5656+ Friend->>Friend: unwrap content key<br/>with their private key
5757+5858+ Friend->>YourPDS: fetch ciphertext blob
5959+ YourPDS-->>Friend: ciphertext
6060+ Friend->>Friend: decrypt with content key
6161+6262+ Note over YourPDS: File never leaves your PDS —<br/>friend streams it directly
6363+`;
6464+6565+/** Device pairing — how identity keys reach a new device. */
6666+export const pairingFlow = `sequenceDiagram
6767+ participant NewDevice as New Device
6868+ participant PDS as Your PDS
6969+ participant OldDevice as Existing Device
7070+7171+ NewDevice->>NewDevice: generate one-time keypair
7272+ NewDevice->>PDS: publish pair request<br/>(contains new device's public half)
7373+7474+ OldDevice->>PDS: poll for pair requests
7575+ PDS-->>OldDevice: pair request record
7676+7777+ OldDevice->>OldDevice: wrap your identity<br/>with new device's public half
7878+7979+ OldDevice->>PDS: publish pair response<br/>(wrapped identity)
8080+8181+ NewDevice->>PDS: poll for pair response
8282+ PDS-->>NewDevice: pair response record
8383+8484+ NewDevice->>NewDevice: unwrap with<br/>one-time private half<br/>→ identity ready
8585+8686+ NewDevice->>PDS: delete both records
8787+`;
8888+8989+/** Turning 24 words into the two identity keypairs. */
9090+export const derivationFlow = `flowchart LR
9191+ A["24 words<br/><small>BIP-39 wordlist</small>"] -->|"PBKDF2-HMAC-SHA512<br/><small>2048 rounds, salt 'mnemonic'</small>"| B["512-bit<br/>master seed"]
9292+ B -->|"HKDF-SHA256<br/><small>info: opake-v1-x25519-identity</small>"| C["X25519 keypair<br/><small>encryption + key wrapping</small>"]
9393+ B -->|"HKDF-SHA256<br/><small>info: opake-v1-ed25519-signing</small>"| D["Ed25519 keypair<br/><small>indexer auth + record signing</small>"]
9494+9595+ style A fill:#F5E9D0,stroke:#9A7840
9696+ style C fill:#EEF2E8,stroke:#5C7A54
9797+ style D fill:#EEF2E8,stroke:#5C7A54
9898+`;
···11+import { sharingFlow } from "./_diagrams";
22+13<ChapterHeader title="The Open Network: Your Data, Anywhere" />
2435<Lead>
···7173different host and they stream it straight from your cabinet. The PDS on each side only ever
7274sees ciphertext; the encryption is the access control.
73757676+<SequenceDiagram
7777+ code={sharingFlow}
7878+ caption="Sharing across the Atmosphere: the grant travels through the indexer, the ciphertext stays on the original PDS, and the recipient fetches directly."
7979+/>
8080+7481---
75827683## Resources & Further Reading
···9097- **[A Social Filesystem](https://overreacted.io/a-social-filesystem/):** Dan Abramov's vision of the AT Protocol as a decentralized filesystem—the very vision Opake is building.
919892999393-Ready to learn about how we keep this open network private? Read about [Encryption & Keys](/docs/encryption).
100100+<DocsNext slug="at-protocol" />
···11import { encryptBlob } from "./_snippets";
22+import { uploadFlow, derivationFlow } from "./_diagrams";
2334<ChapterHeader title="Encryption & Keys" />
45···32333334The result is a `WrappedKey`. This lockbox is safe to store publicly while being streamed through the AT Protocol firehose because it can only be opened by your private X25519 key.
34353636+<SequenceDiagram
3737+ code={uploadFlow}
3838+ caption="Full upload flow: generate a content key, encrypt the file, wrap the key to you, publish both."
3939+/>
4040+3541<Callout type="info">
3642 **A Note on JWE:** We intentionally avoided the JSON Web Encryption (JWE) standard. Our specific
3743 HKDF info string (`opake-v1-x25519-hkdf-a256kw-{did}`) provides strict domain separation,
···54605561## Where Do Keys Come From?
56625757-Your keys are derived from a **24-word secret phrase** generated when you first set up Opake. The same phrase always produces the same keys — this is how you can recover your identity on a new device without needing your old one.
6363+Your keys are derived from a **24-word secret phrase** generated when you first set up Opake.
6464+The same phrase always produces the same keys — this is how you can recover your identity on
6565+a new device without needing your old one. For the user-facing walkthrough (what to do with
6666+the words, how to back them up), see
6767+[What your key actually looks like](/docs/seed-phrase).
6868+6969+### The derivation pipeline
7070+7171+Turning 24 words into a working identity is a three-stage process; each stage uses a standard,
7272+audited primitive.
7373+7474+1. **BIP-39 → 256 bits of entropy.** The 24 words are drawn from the BIP-39 English wordlist:
7575+ 2048 entries, 11 bits each, so 24 × 11 = 264 bits total. The last 8 bits are a checksum,
7676+ leaving 256 bits of actual randomness — the same entropy budget as the AES-256 keys that
7777+ get derived from it.
58785959-For nerds: the derivation uses standard cryptographic primitives: PBKDF2 to stretch the phrase into a master seed, then HKDF to derive separate X25519 (encryption) and Ed25519 (signing) keys. For the full technical details, see [Your Seed Phrase](/docs/seed-phrase).
7979+2. **PBKDF2-HMAC-SHA512 → 512-bit master seed.** The words (as UTF-8 bytes) are stretched
8080+ through PBKDF2 with 2048 rounds and salt `"mnemonic"` — the BIP-39 standard. The rounds are
8181+ intentional cost: unnoticeable on a legitimate login, meaningful when an attacker is trying
8282+ to run billions of guesses.
8383+8484+3. **HKDF-SHA256 → two keys.** The 512-bit master seed is fed through HKDF-SHA256 twice, with
8585+ different `info` strings:
8686+8787+ - `opake-v1-x25519-identity` → X25519 private key (encryption + key wrapping)
8888+ - `opake-v1-ed25519-signing` → Ed25519 signing key (indexer authentication + record
8989+ signing)
9090+9191+ The `info` strings provide **domain separation**: even with the same master seed, you can't
9292+ substitute one key for the other, and a future schema version (`opake-v2-...`) produces
9393+ cleanly independent keys from the same words.
9494+9595+The pipeline is **deterministic**: same 24 words, same master seed, same X25519 and Ed25519
9696+keys, every time, on every device. That's what makes seed-phrase recovery possible.
9797+9898+<SequenceDiagram
9999+ code={derivationFlow}
100100+ caption="The derivation pipeline: 24 words turn into two independent keypairs via a slow KDF and two domain-separated HKDF applications."
101101+/>
102102+103103+The PBKDF2 salt is the fixed string `"mnemonic"` per the BIP-39 specification. Security rests
104104+on the 256-bit entropy of the words themselves, which is already enough randomness to make
105105+per-user salting redundant.
106106+107107+### The two keys, and what they do
108108+109109+| Key | Purpose |
110110+|-----|---------|
111111+| **X25519 keypair** | Encryption. The private half unwraps content keys sent to you. The public half is published on your PDS so others can wrap content keys to you. |
112112+| **Ed25519 keypair** | Signing. The private half signs indexer auth tokens and (eventually) record envelopes. The public half is published alongside the X25519 key so services can verify signatures. |
113113+114114+Both public halves live in a single `app.opake.publicKey/self` record on your PDS — a
115115+singleton, republished on every login so it stays current.
6011661117## Where Are My Keys Stored?
62118···65121- **On the Web:** They are stored in your browser's `IndexedDB`, accessible only to the Opake web app domain.
66122- **On the CLI:** They are stored in `~/.config/opake/accounts/`, guarded by strict `0600` UNIX file permissions.
671236868-Your seed phrase is shown once at setup and never stored. To learn how to back it up and recover, read [Your Seed Phrase](/docs/seed-phrase). To transfer keys between devices without re-entering the phrase, read about [Device Pairing](/docs/pairing).
124124+Your seed phrase is shown once at setup and never stored. To learn how to back it up and
125125+recover, see [What your key actually looks like](/docs/seed-phrase). To transfer keys
126126+between devices without re-entering the phrase, read about [Device Pairing](/docs/pairing).
127127+128128+<DocsNext slug="encryption" />
+40
apps/web/src/content/docs/understand/glossary.mdx
···6677The decentralized social networking protocol that Opake is built on. It handles identity, data storage, and the communication between different servers.
8899+### Atmosphere
1010+1111+The broader ecosystem of apps and services built on the AT Protocol — Bluesky, Opake, and
1212+anything else speaking the same identity and storage layer. "The Atmosphere" is the
1313+shorthand for that family of products as a whole.
1414+915### AES-256-GCM
10161117The symmetric encryption algorithm used for file content. Authenticated (detects tampering),
1218fast on modern hardware, and well-studied.
13192020+### BIP-39
2121+2222+A standardized scheme for encoding random bytes as a 24-word phrase drawn from a fixed
2323+wordlist of 2048 common English words. Opake uses it to generate your seed phrase — the words
2424+*are* your key material, expressed in a form you can actually write down.
2525+2626+### Blob
2727+2828+A binary blob of data uploaded to a PDS. For Opake, that means the encrypted ciphertext of a
2929+file. The PDS stores blobs separately from records and hands them back on request by content
3030+hash.
3131+1432### Indexer
15331634A specialized service that indexes the [Firehose](#firehose) and provides a fast way to discover which files have been shared with you. It never sees your plaintext data. Fills the atproto "appview" protocol role, but we call it the indexer because all payloads are ciphertext and it serves no rendered views.
···28462947Your permanent, cryptographic ID on the AT Protocol (e.g., `did:plc:123...`). Unlike a handle, a DID never changes.
30484949+### Ed25519
5050+5151+The elliptic curve Opake uses for *signing*. Separate from [X25519](#x25519), which handles
5252+*encryption*. Your Ed25519 private key signs indexer auth tokens and (eventually) record
5353+envelopes; the public half is published alongside your encryption key.
5454+3155### Firehose
32563357The real-time stream of all public records being created on the AT Protocol. Our indexer "listens" to the firehose to find new Sharing Grants.
···50745175The part of your cryptographic identity that you share with the world. Others use your public key to "wrap" files specifically for you.
52767777+### Publish
7878+7979+Writing a record to your PDS. In Opake, uploading a file, creating a workspace, sharing with
8080+someone, and pairing a device all involve publishing records under the `app.opake.*`
8181+namespace. The PDS holds them and makes them available to anyone your lexicons say can read
8282+them.
8383+8484+### Seed Phrase
8585+8686+The 24 words Opake shows you at first setup. Same words in, same keys out — the phrase is a
8787+portable, human-writable form of your private key. Your only way back into your cabinet if
8888+you lose access to every signed-in device. See
8989+[What your key actually looks like](/docs/seed-phrase).
9090+5391### Wrapped Key
54925593A [Content Key](#content-key) that has been encrypted for a specific recipient using their [Public Key](#public-key). It's like a small lockbox that only one person can open.
···62100---
6310164102Still curious? Head back to the [Handbook Index](/docs).
103103+104104+<DocsNext slug="glossary" />
+9-7
apps/web/src/content/docs/use/getting-started.mdx
···14141515When you first set up Opake, you'll receive a **24-word secret phrase** — a backup that can
1616reconstruct your keys on any device. If you lose both the phrase and every device you're signed
1717-in on, your encrypted files stay permanently unreadable. That's how end-to-end encryption works;
1818-there's no recovery channel to exploit.
1717+in on, your encrypted files stay permanently unreadable. That's how end-to-end encryption
1818+works: no hidden override, no emergency hatch, no support team with a backup copy. Any of
1919+those would also be a way in for everyone else.
19202021<Callout type="warning">
2121- Write down your 24 words and store it somewhere safe. It is the only way to recover your files
2222- if you lose access to all your devices. Read more in [Your Seed Phrase](/docs/seed-phrase).
2222+ Write down your 24 words and store them somewhere safe. They're the only way to recover your
2323+ files if you lose access to all your devices. Read more in
2424+ [What your key actually looks like](/docs/seed-phrase).
2325</Callout>
24262527---
···5961the network, decrypts the metadata locally, and reconstructs the folder hierarchy — instantly,
6062and only for you.
61636262-Even if your server is compromised, an admin only sees opaque blobs and encrypted records. They
6363-can't tell which file is a PDF or an image, or what folder it sits in.
6464+Even if your server is compromised, an admin only sees scrambled, unreadable data. They can't
6565+tell which file is a PDF or an image, or what folder it sits in.
64666565-Ready to go deeper into how the math actually works? Read about [Encryption & Keys](/docs/encryption).
6767+<DocsNext slug="getting-started" />
+11-11
apps/web/src/content/docs/use/pairing.mdx
···1111Traditional apps sync your data by copying everything to a central server. If that server is
1212compromised, so is your privacy.
13131414-Opake uses **Device Pairing** instead: a direct exchange between two of your own devices. They
1515-perform a cryptographic handshake that transfers your encryption identity through the PDS as
1616-ciphertext. The PDS relays the messages without understanding what's inside them.
1414+Opake uses **Device Pairing** instead: a direct, encrypted exchange between two of your own
1515+devices. Your identity moves from the old device to the new one through the PDS, but it's
1616+scrambled on the way. The PDS relays the bytes without seeing what's in them.
17171818---
19192020## The Pairing Process
21212222Your private keys only leave one device to arrive on another. At no point are they readable by
2323-anyone but the two devices in the handshake.
2323+anyone but the two devices involved.
24242525### 1. Requesting access (New Device)
2626···3737 </PlatformTab>
3838</PlatformToggle>
39394040-### 2. Approvaling access (Existing Device)
4040+### 2. Approving access (Existing Device)
41414242Your current device will see a notification that a new guest is asking to join your cabinet.
4343···59596060Even if someone is actively watching your PDS during the handshake, they can't recover the keys.
61616262-The "temporary lock" your new device made in step 1 is unique to that device — only it holds
6363-the matching key. When your existing device wraps your identity inside that lock, the only
6464-party who can unlock the package is the new device itself. The PDS shuttles the locked package
6565-between the two devices; it never holds the key.
6262+The "temporary lock" your new device made in step 1 belongs only to that device; nobody else
6363+holds the matching key. When your existing device wraps your identity inside that lock, only
6464+the new device can unlock the package. The PDS shuttles the locked package between the two
6565+devices; it never holds the key.
66666767-For the specific algorithms Opake uses underneath, see [Encryption & Keys](/docs/encryption).
6767+For the specific algorithms Opake uses, see [Encryption & Keys](/docs/encryption).
68686969<Callout type="warning">
7070 Verify that the pairing request you're approving is actually from your own device. Approving a
···7272 and trust.
7373</Callout>
74747575-Ready to learn about the foundation of all this? Read about the [AT Protocol](/docs/at-protocol).
7575+<DocsNext slug="pairing" />
+46-30
apps/web/src/content/docs/use/seed-phrase.mdx
···11-<ChapterHeader title="Your Seed Phrase" />
11+<ChapterHeader title="What your key actually looks like" />
2233<Lead>
44- Your seed phrase is the master key to your entire Opake identity. Twenty-four words that can
55- reconstruct your encryption keys on any device, at any time.
44+ When Opake sets up your identity, it shows you twenty-four ordinary English words. Those
55+ words *are* your key — write them down and you can bring your identity back on any device,
66+ at any time. Lose them and nothing in the world can help you.
67</Lead>
7889## What Is It?
9101010-When you first set up Opake, the app generates 24 random words from a standardised wordlist (BIP-39). These words encode 256 bits of entropy — the mathematical seed from which your encryption and signing keys are derived.
1111+When you first set up Opake, the app picks 24 words at random from a fixed list of around
1212+2000 common English words. Those 24 words carry enough randomness that no other person on
1313+earth will ever land on the same sequence. Your identity is unique by construction.
11141212-The same 24 words will always produce the same keys. This means you can recover your entire identity on a new device by entering the same phrase, without needing access to your old device.
1515+Type the same 24 words back in on a different device (a new phone, a fresh laptop, a rebuild
1616+after you reinstalled the OS) and Opake rebuilds the exact same keys from them. Same words
1717+in, same identity out.
13181419<Callout type="warning">
1515- **Write it down. Store it safely. Never share it.** Your seed phrase is the only way to recover
1616- your identity if you lose access to all your devices. Opake cannot recover it for you.
2020+ **Write it down. Store it safely. Never share it.** Your 24 words are the only way back
2121+ into your cabinet if you lose access to every device you're signed in on. Opake can't
2222+ recover them for you; there's nobody behind the scenes holding a backup.
1723</Callout>
18241925---
20262127## How It Works
22282323-When you enter your seed phrase, Opake runs it through a deterministic derivation pipeline:
2929+Same words in, same keys out; every time, on every device.
24302525-1. **PBKDF2** stretches the phrase into a 512-bit master seed (this is slow on purpose — it makes brute-force attacks expensive).
2626-2. **HKDF** derives two separate keys from that seed:
2727- - An **X25519 key** for encrypting and decrypting files
2828- - An **Ed25519 key** for signing and authenticating
3131+Opake runs your 24 words through a fixed, slow-by-design process. "Fixed" means you get
3232+identical keys every time you type the phrase, which is how old files still decrypt on a new
3333+phone. "Slow-by-design" means it takes a computer more effort to turn the words into a key — fine
3434+for you (a second or two of wait on first setup) but very difficult for someone trying to
3535+guess your words.
29363030-The important part: this process is entirely deterministic. Same words in, same keys out. Every time, on every device.
3737+<Callout type="info">
3838+ For the specific algorithms behind this derivation, see [Encryption & Keys](/docs/encryption).
3939+</Callout>
31403241---
3342···3847 After signing in, Opake will display your 24 words in a numbered grid. You can:
39484049 - **Copy** them to your clipboard
4141- - **Download** them as a `.txt` file (saved with restricted permissions)
5050+ - **Download** them as a `.txt` file (saved so only your user account can read it)
42514352 You'll then be asked to confirm 3 randomly chosen words to prove you've saved them.
44534554 </PlatformTab>
4655 <PlatformTab name="CLI">
4747- On your first login, the CLI displays your seed phrase in a numbered grid:
5656+ On your first login, the CLI displays your 24 words in a numbered grid:
48574958 ```
5059 1. mimic 7. action 13. zebra 19. crawl
···5564 6. awful 12. palm 18. space 24. focus
5665 ```
57665858- You can save this to a file when prompted. You'll then confirm 3 words to verify you've recorded them.
6767+ You can save this to a file when prompted. You'll then confirm 3 words to verify you've
6868+ recorded them.
59696070 </PlatformTab>
6171</PlatformToggle>
62726363-After confirmation, Opake derives your keys and publishes the public key to your PDS. The seed phrase is never stored or shown again.
7373+After confirmation, Opake derives your keys and publishes the public half to your PDS. The
7474+words themselves are never stored and never shown again.
64756576---
66776778## Recovering on a New Device
68796969-If you get a new device (or reinstall), you can recover your identity without needing your old device.
8080+If you get a new device (or reinstall), you can bring your identity back without needing the
8181+old device at all.
70827183<PlatformToggle>
7284 <PlatformTab name="Web App">
7385 After signing in, choose **"Use your recovery phrase"** and enter your 24 words in the grid.
7474- You can also paste all 24 words at once — the app will distribute them across the fields automatically.
8686+ You can also paste all 24 words at once — the app distributes them across the fields
8787+ automatically.
7588 </PlatformTab>
7689 <PlatformTab name="CLI">
7790 <CodeBlock language="sh">opake recover</CodeBlock>
···8396 </PlatformTab>
8497</PlatformToggle>
85988686-If the derived key doesn't match the one published on your PDS, Opake will warn you. This usually means either the phrase is wrong, or the account was set up before seed phrases were the default.
9999+If the key Opake rebuilds doesn't match the public half that's already published to your
100100+PDS, Opake will warn you. That usually means one of two things: a typo in the phrase, or an
101101+older account that was set up before 24-word phrases were the default.
8710288103---
891049090-## Seed Phrase vs. Device Pairing
105105+## 24 Words vs. Device Pairing
911069292-Both accomplish the same goal — getting your keys onto a new device. The difference:
107107+Both get your identity onto a new device. The difference:
931089494-| | Seed Phrase | Device Pairing |
109109+| | 24 Words | Device Pairing |
95110| ------------------------ | ----------------------------------- | -------------------------------------- |
96111| **Requires old device?** | No | Yes |
97112| **Works offline?** | Partially (need PDS for publishing) | No (PDS relay required) |
98113| **Input method** | Type or paste 24 words | Approve on existing device |
99114| **Best for** | Disaster recovery, first-time setup | Quick setup when you have both devices |
100115101101-Device pairing is faster when you have both devices handy. The seed phrase is your safety net when you don't.
116116+Device pairing is faster when you have both devices handy. The 24 words are your safety net
117117+when you don't.
102118103119---
104120105105-## Storing Your Seed Phrase
121121+## Storing Your Words
106122107123Some options, in rough order of paranoia:
108124···113129What you should _not_ do:
114130115131- Store it in a cloud notes app (defeats the purpose)
116116-- Take a screenshot (phone backups are not encrypted end-to-end by default)
132132+- Take a screenshot (phone backups aren't end-to-end encrypted by default)
117133- Email it to yourself
118134119135<Callout type="info">
120120- **The `.txt` file** saved during setup uses restrictive file permissions (0600 on Unix). It's a
121121- reasonable short-term backup, but you should transfer the words to a more permanent medium and
122122- delete the file.
136136+ **The `.txt` file** saved during CLI setup is set up so only your user account can read it
137137+ — a reasonable short-term backup. Still, move the words somewhere more permanent and delete
138138+ the file when you can.
123139</Callout>
124140125125-Ready to learn how device pairing works as an alternative? Read about [Multi-Device Magic](/docs/pairing).
141141+<DocsNext slug="seed-phrase" />
+20-19
apps/web/src/content/docs/use/sharing.mdx
···11-<ChapterHeader title="Sharing & DIDs" />
11+<ChapterHeader title="Sharing a file" />
2233<Lead>
44 Sharing on a traditional cloud means granting a server permission to show your data to someone.
···66 profile.
77</Lead>
8899-## The Grant model
99+## How sharing works
10101111-When you share a file, you create a **Grant** — a small record containing the file's content
1212-key, encrypted so that only the recipient's private key can open it. You publish the grant
1313-record to your own PDS. The recipient finds it via the indexer, unwraps the key with their
1414-private key, and then fetches the encrypted file directly from your PDS.
1111+Sharing a file doesn't move the file. It stays right in your cabinet.
15121616-Neither PDS sees the key or the file content. They're just relaying ciphertext between two
1717-clients.
1313+What you actually send your recipient is a small, one-of-a-kind key for that one file. The
1414+key is locked so only that specific recipient can open it. Opake calls this little key-package
1515+a **Grant**.
1616+1717+Your recipient uses their own identity to unlock the Grant, then reads the file straight from
1818+your storage. Your storage provider can't read any of it — not the file, not its name, not
1919+the key inside the Grant. They just hold scrambled bytes.
18201921---
2022···2830 1. Select a file in your cabinet.
2931 2. Click the **Share** icon.
3032 3. Enter the recipient's handle.
3131- 4. Opake resolves their handle to a DID, fetches their public encryption key, wraps the file key, and publishes the Grant record.
3333+ 4. Opake looks up their identity, encrypts the file's key just for them, and publishes the Grant.
32343335 </PlatformTab>
3436 <PlatformTab name="CLI">
···3638 </PlatformTab>
3739</PlatformToggle>
38403939-## 2. Why DIDs matter
4141+## 2. What if my friend's handle changes?
40424141-You might know your friend as `@bob.bsky.social`, but Opake stores them internally as
4242-`did:plc:z724xy...`.
4343-4444-A handle is a nickname that can change. A **DID (Decentralized Identifier)** is a permanent,
4545-cryptographic ID. Grants are keyed to DIDs, so access stays valid if your friend moves to a
4646-different PDS or swaps their handle.
4343+Handles can change. Someone might move to a different PDS provider, or just pick a new
4444+handle for a rebrand. Every account also has a permanent **DID** (like `did:plc:z724xy...`)
4545+that stays put for life. When you share a file by handle, Opake quietly resolves the
4646+handle to the DID and writes the grant against that. Even if the handle changes later, the
4747+grant still points at the right person. Nothing you need to do.
47484849<Callout type="info">
4950 Opake publishes your public encryption key to your PDS when you first log in. That's how
5050- someone else's client can wrap a file to you without you having to exchange an address
5151- out-of-band.
5151+ someone else's Opake can encrypt a file just for you, without the two of you needing to swap
5252+ keys over a separate channel beforehand.
5253</Callout>
53545455---
···6263they lose their copy, but it can't reach out across the network to delete what they already
6364have on disk.
64656565-Ready to see how to manage your identity across multiple devices? Read about [Multi-Device Magic](/docs/pairing).
6666+<DocsNext slug="sharing" />
+18-11
apps/web/src/content/docs/use/troubleshooting.mdx
···2020If the browser window opens for login but never redirects you back to Opake:
21212222- **Check for ad-blockers:** Some aggressive browser extensions might block the redirect URL.
2323-- **CLI specific:** The CLI uses a temporary server on `127.0.0.1`. Ensure your firewall is not blocking local loopback connections.
2323+- **CLI specific:** The CLI spins up a small server on your own machine (`127.0.0.1`) to
2424+ receive the redirect. Make sure your firewall isn't blocking connections to your own
2525+ machine.
24262527---
2628···28302931### Upload fails halfway (storage limits)
30323131-Opake uploads files as single blobs to the AT Protocol, so large files stress both your
3232-connection and the PDS's blob limits.
3333+Opake uploads each file to the AT Protocol in one piece, so large files lean on both your
3434+connection and whatever size limit your PDS enforces.
33353436- **Default size cap:** Opake caps upload size to stay within what most PDS providers accept
3537 without prior coordination.
3636-- **Increasing the cap:** You (or your PDS administrator) can raise it by publishing
3737- configuration records to your repository. See the [Lexicon reference](/docs/lexicons) for the
3838- schema.
3838+- **Increasing the cap:** You (or your PDS administrator) can raise it by publishing a
3939+ configuration record. See the [Lexicon reference](/docs/lexicons) for the exact format.
3940- **Network stability:** If your connection drops mid-upload, the upload fails and you have to
4041 re-send. Resume support is on the roadmap, not there yet.
41424243### "Unable to Decrypt File"
43444444-The most serious error. Opake can't unwrap the key for this file under the identity you're
4545+The most serious error. Opake can't unlock the key for this file under the identity you're
4546signed in with. Two common causes:
46474748- **Wrong account.** Check you're logged into the account the file was shared with.
···5859If you can't share a file with someone:
59606061- **Handle vs. DID:** Ensure the handle is correct.
6161-- **Public Key Missing:** The recipient must have logged into Opake at least once to publish their [Public Encryption Key](/docs/encryption). If they haven't done this, you cannot "wrap" a file to them.
6262+- **Public Key Missing:** The recipient has to have logged into Opake at least once, so their
6363+ [Public Encryption Key](/docs/encryption) is published on their PDS. If it isn't, Opake has
6464+ no key to encrypt the file against.
62656366### "I don't see shared files in my inbox"
64676565-Opake uses an **indexer** to index sharing grants.
6868+Opake relies on an **indexer** — a helper service that watches for new grants across the
6969+network and routes each one to the right inbox.
66706767-- **Index Lag:** Sometimes it takes a few moments for the firehose to catch up.
6868-- **Indexer Status:** Check if the indexer service is healthy. If the indexer is down, your inbox will appear empty even if the files exist.
7171+- **Index Lag:** Sometimes it takes a few moments for newly published grants to be picked up.
7272+- **Indexer Status:** If the indexer service is down or unhealthy, your inbox will appear
7373+ empty even if the files exist.
69747075---
7176···7479 Tangled](https://tangled.org/opake.app/opake/issues). Include any error messages and
7580 details about your environment (Web or CLI).
7681</Callout>
8282+8383+<DocsNext slug="troubleshooting" />
+25-22
apps/web/src/content/docs/use/workspaces.mdx
···11<ChapterHeader title="Workspaces: group sharing" />
2233<Lead>
44- Direct sharing works for one-off files. For a folder shared with a family, a team, or a
55- research group, you want something that scales past pairwise encryption.
44+ Direct sharing works fine for one-off files. For a folder that a family, a team, or a
55+ research group all need to read, you want something that scales without re-encrypting
66+ everything each time someone joins or leaves.
67</Lead>
7889## The group-key model
9101010-A naive encrypted file-sharing app wraps every file to every recipient's public key. Ten members
1111-means ten copies of each content key. An eleventh person joining means re-encrypting everything.
1111+The most direct way to share an encrypted file with several people is to encrypt it separately
1212+for each of them. Ten members means ten copies of each file's key. An eleventh person joining
1313+means touching every file.
12141313-Opake uses **workspaces** instead — a named group that owns a single shared symmetric key (the
1414-"group key"). Files in the workspace have their content keys wrapped under the group key, not
1515-directly under each member's key.
1515+Opake uses **workspaces** instead — a named group that shares a single **group key**. Every
1616+file in the workspace is encrypted with that one key. Members then each hold a personal copy of
1717+the group key itself, encrypted with their own public key.
16181717-1. The file's content key is wrapped once under the group key.
1818-2. The group key is wrapped once per member, under each member's X25519 public key.
1919+1. The file's content key is encrypted once for the whole group.
2020+2. The group key is encrypted once per member, using their personal public key.
19212020-Adding a member means wrapping the group key for them once. They immediately gain access to
2121-every file ever uploaded under that workspace — no re-encryption of the files themselves.
2222+Adding a member means encrypting the group key for them once. They immediately gain access to
2323+every file ever uploaded under that workspace; the files themselves stay exactly as they were.
22242325---
24262527## 1. Creating a workspace
26282727-A workspace is a single record on the AT Protocol (`app.opake.keyring`).
2929+A workspace is a single record on your PDS that describes the group and carries the group key
3030+(encrypted, of course).
28312932<PlatformToggle>
3033 <PlatformTab name="Web App">
31343235 1. Click the **(+)** icon in the Sidebar and select **New Workspace**.
3336 2. Give it a name (e.g., "Family Photos").
3434- 3. Opake generates a group key, wraps it to your public key, and publishes the record.
3737+ 3. Opake generates a group key, encrypts it just for you, and publishes the record.
35383639 </PlatformTab>
3740 <PlatformTab name="CLI">
···43464447### Adding a member
45484646-Adding someone means wrapping the current group key to their public key and updating the
4747-workspace record. They can then decrypt anything stored under that workspace.
4949+Adding someone means encrypting the current group key for them and updating the workspace
5050+record. They can then decrypt anything stored under that workspace.
48514952### Removing a member (key rotation)
50535154When you remove a member, Opake rotates the group key so the removed member can't decrypt
5255files uploaded after their removal:
53565454-1. Generate a new group key.
5555-2. Wrap it to all remaining members.
5656-3. Archive the previous group key into the workspace's key history so remaining members can
5757- still decrypt files uploaded under older rotations.
5757+1. A new group key is generated.
5858+2. It's encrypted for each of the remaining members.
5959+3. The previous group key is archived in the workspace's history, so remaining members can
6060+ still decrypt files that were uploaded under older rotations.
58615962<Callout type="warning">
6063 Key rotation prevents the removed member from decrypting _future_ files. Anything they already
6161- downloaded and decrypted locally is theirs — rotation can't reach across the network to
6262- delete copies.
6464+ downloaded and decrypted locally is theirs. Rotation can't reach across the network to delete
6565+ copies.
6366</Callout>
64676568---
···7174Every member of that workspace sees the file in their directory tree and can decrypt it
7275without extra steps.
73767474-Ready for a quick reference on the terminology? Check the [Glossary](/docs/glossary).
7777+<DocsNext slug="workspaces" />
+20
apps/web/src/index.css
···317317 margin-bottom: 0.5em;
318318 color: var(--color-base-content);
319319 }
320320+ /* rehype-autolink-headings appends a link with class `.heading-anchor` to
321321+ every h2/h3/h4 whose target is the heading's own slug. Hidden until the
322322+ heading is hovered or keyboard-focused so it doesn't clutter the layout,
323323+ but still keyboard-reachable via Tab for assistive tech. */
324324+ .prose h2 .heading-anchor,
325325+ .prose h3 .heading-anchor,
326326+ .prose h4 .heading-anchor {
327327+ color: var(--color-text-faint);
328328+ opacity: 0;
329329+ margin-left: 0.35em;
330330+ font-weight: 400;
331331+ text-decoration: none;
332332+ transition: opacity 0.15s ease-out;
333333+ }
334334+ .prose h2:hover .heading-anchor,
335335+ .prose h3:hover .heading-anchor,
336336+ .prose h4:hover .heading-anchor,
337337+ .prose .heading-anchor:focus-visible {
338338+ opacity: 1;
339339+ }
320340 .prose ul {
321341 list-style-type: disc;
322342 margin-block: 0.75em;
+35-33
apps/web/src/lib/docs-registry.ts
···5050 description: "Store, share, and recover files with Opake.",
5151 },
5252 {
5353- key: "understand",
5454- label: "Under the hood",
5555- description: "How Opake protects your data — the crypto, the records, the protocol.",
5656- },
5757- {
5853 key: "build",
5954 label: "For developers",
6055 description: "Program against Opake: CLI, SDK, React hooks, lexicons.",
5656+ },
5757+ {
5858+ key: "understand",
5959+ label: "Under the hood",
6060+ description: "How Opake protects your data — the crypto, the records, the protocol.",
6161 },
6262];
6363···8181 title: "Multi-Device Magic",
8282 icon: "pairing",
8383 description:
8484- "Securely transfer your identity keypair to new devices using your PDS as a relay.",
8484+ "Move your identity onto a new phone or laptop without putting it on the network in plaintext.",
8585 },
8686 {
8787 slug: "seed-phrase",
8888 category: "use",
8989- title: "Your Seed Phrase",
8989+ title: "What your key actually looks like",
9090 icon: "seedling",
9191- description: "Back up and recover your identity with a 24-word recovery phrase.",
9191+ description:
9292+ "Twenty-four words that can bring your identity back on any device — your fallback when nothing else is left.",
9293 },
9394 {
9495 slug: "sharing",
9596 category: "use",
9697 title: "Sharing",
9798 icon: "share",
9898- description: "Share files with another person — one-to-one grants and recipient discovery.",
9999+ description: "Share a file with someone else. All you need is their handle.",
99100 },
100101 {
101102 slug: "workspaces",
102103 category: "use",
103104 title: "Workspaces",
104105 icon: "group",
105105- description: "Share folders with teams, families, and research groups — no re-encryption.",
106106+ description:
107107+ "Share folders with teams, families, and research groups. Add and remove people without re-uploading files.",
106108 },
107109 {
108110 slug: "troubleshooting",
···110112 title: "Troubleshooting",
111113 icon: "question",
112114 description: "Common problems and how to fix them.",
113113- },
114114-115115- // -- Under the hood --------------------------------------------------------
116116- {
117117- slug: "encryption",
118118- category: "understand",
119119- title: "Encryption & Keys",
120120- icon: "lock",
121121- description: "How end-to-end encryption works in Opake and how your keys are managed.",
122122- },
123123- {
124124- slug: "at-protocol",
125125- category: "understand",
126126- title: "AT Protocol",
127127- icon: "network",
128128- description: "The open standard powering Opake — identity, data portability, and federation.",
129129- },
130130- {
131131- slug: "glossary",
132132- category: "understand",
133133- title: "Glossary",
134134- icon: "book",
135135- description: "A quick-hit reference for the terminology and acronyms we use in Opake.",
136115 },
137116138117 // -- For developers --------------------------------------------------------
···259238 icon: "network",
260239 description:
261240 "The atproto collections, record schemas, and encryption envelope Opake publishes to a PDS.",
241241+ },
242242+243243+ // -- Under the hood --------------------------------------------------------
244244+ {
245245+ slug: "encryption",
246246+ category: "understand",
247247+ title: "Encryption & Keys",
248248+ icon: "lock",
249249+ description: "How end-to-end encryption works in Opake and how your keys are managed.",
250250+ },
251251+ {
252252+ slug: "at-protocol",
253253+ category: "understand",
254254+ title: "AT Protocol",
255255+ icon: "network",
256256+ description: "The open standard powering Opake — identity, data portability, and federation.",
257257+ },
258258+ {
259259+ slug: "glossary",
260260+ category: "understand",
261261+ title: "Glossary",
262262+ icon: "book",
263263+ description: "A quick-hit reference for the terminology and acronyms we use in Opake.",
262264 },
263265264266 // -- Cross-cutting ---------------------------------------------------------
+63
apps/web/src/lib/docs-search.ts
···11+import { DOCS_REGISTRY, docPath } from "./docs-registry";
22+33+/**
44+ * A single searchable hit. Today this is one per registered doc; future work
55+ * could extend it with per-heading hits once there's a build-time pre-pass
66+ * that extracts `##`+ headings from MDX source (the natural `import.meta.glob
77+ * + ?raw` approach collides with the MDX plugin's `enforce: "pre"` transform,
88+ * so section-anchor search needs its own small build step to land cleanly).
99+ */
1010+export interface SearchHit {
1111+ readonly docSlug: string;
1212+ readonly docTitle: string;
1313+ readonly docGroup: string | undefined;
1414+ readonly docDescription: string;
1515+ readonly href: string;
1616+}
1717+1818+/**
1919+ * All searchable hits. Memoised in module scope — the registry is a
2020+ * build-time constant, so this array is stable for the lifetime of the app.
2121+ */
2222+const ALL_HITS: readonly SearchHit[] = DOCS_REGISTRY.map((doc) => ({
2323+ docSlug: doc.slug,
2424+ docTitle: doc.title,
2525+ docGroup: doc.group,
2626+ docDescription: doc.description,
2727+ href: docPath(doc),
2828+}));
2929+3030+/**
3131+ * Simple substring match, case-insensitive, across title and description.
3232+ * Results are ordered by match strength:
3333+ * 1. title prefix match
3434+ * 2. title contains
3535+ * 3. description contains
3636+ * Within each bucket, registry order is preserved.
3737+ */
3838+export function searchDocs(query: string): readonly SearchHit[] {
3939+ const q = query.trim().toLowerCase();
4040+ if (!q) return [];
4141+4242+ const prefixMatches: SearchHit[] = [];
4343+ const titleContainsMatches: SearchHit[] = [];
4444+ const descriptionMatches: SearchHit[] = [];
4545+4646+ for (const hit of ALL_HITS) {
4747+ const title = hit.docTitle.toLowerCase();
4848+ const description = hit.docDescription.toLowerCase();
4949+5050+ if (title.startsWith(q)) {
5151+ // eslint-disable-next-line functional/immutable-data -- builder array
5252+ prefixMatches.push(hit);
5353+ } else if (title.includes(q)) {
5454+ // eslint-disable-next-line functional/immutable-data -- builder array
5555+ titleContainsMatches.push(hit);
5656+ } else if (description.includes(q)) {
5757+ // eslint-disable-next-line functional/immutable-data -- builder array
5858+ descriptionMatches.push(hit);
5959+ }
6060+ }
6161+6262+ return [...prefixMatches, ...titleContainsMatches, ...descriptionMatches];
6363+}