diff --git a/.gitignore b/.gitignore
index efce008..4eae4ca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,5 @@ pnpm-debug.log*
# Env
.env
.env.*
+
+ref/
\ No newline at end of file
diff --git a/apps/homepage/.gitignore b/apps/homepage/.gitignore
new file mode 100644
index 0000000..3b462cb
--- /dev/null
+++ b/apps/homepage/.gitignore
@@ -0,0 +1,23 @@
+node_modules
+
+# Output
+.output
+.vercel
+.netlify
+.wrangler
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
diff --git a/apps/homepage/.npmrc b/apps/homepage/.npmrc
new file mode 100644
index 0000000..b6f27f1
--- /dev/null
+++ b/apps/homepage/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/apps/homepage/.prettierignore b/apps/homepage/.prettierignore
new file mode 100644
index 0000000..7d74fe2
--- /dev/null
+++ b/apps/homepage/.prettierignore
@@ -0,0 +1,9 @@
+# Package Managers
+package-lock.json
+pnpm-lock.yaml
+yarn.lock
+bun.lock
+bun.lockb
+
+# Miscellaneous
+/static/
diff --git a/apps/homepage/.prettierrc b/apps/homepage/.prettierrc
new file mode 100644
index 0000000..819fa57
--- /dev/null
+++ b/apps/homepage/.prettierrc
@@ -0,0 +1,16 @@
+{
+ "useTabs": true,
+ "singleQuote": true,
+ "trailingComma": "none",
+ "printWidth": 100,
+ "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
+ "overrides": [
+ {
+ "files": "*.svelte",
+ "options": {
+ "parser": "svelte"
+ }
+ }
+ ],
+ "tailwindStylesheet": "./src/routes/layout.css"
+}
diff --git a/apps/homepage/README.md b/apps/homepage/README.md
new file mode 100644
index 0000000..8204aa0
--- /dev/null
+++ b/apps/homepage/README.md
@@ -0,0 +1,42 @@
+# sv
+
+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
+
+## Creating a project
+
+If you're seeing this, you've probably already done this step. Congrats!
+
+```sh
+# create a new project
+npx sv create my-app
+```
+
+To recreate this project with the same configuration:
+
+```sh
+# recreate this project
+pnpm dlx sv@0.15.3 create --template minimal --types ts --add prettier eslint tailwindcss="plugins:typography,forms" --install pnpm homepage
+```
+
+## Developing
+
+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+
+```sh
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+To create a production version of your app:
+
+```sh
+npm run build
+```
+
+You can preview the production build with `npm run preview`.
+
+> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
diff --git a/apps/homepage/eslint.config.js b/apps/homepage/eslint.config.js
new file mode 100644
index 0000000..0014edd
--- /dev/null
+++ b/apps/homepage/eslint.config.js
@@ -0,0 +1,44 @@
+import prettier from 'eslint-config-prettier';
+import path from 'node:path';
+import { includeIgnoreFile } from '@eslint/compat';
+import js from '@eslint/js';
+import svelte from 'eslint-plugin-svelte';
+import { defineConfig } from 'eslint/config';
+import globals from 'globals';
+import ts from 'typescript-eslint';
+import svelteConfig from './svelte.config.js';
+
+const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
+
+export default defineConfig(
+ includeIgnoreFile(gitignorePath),
+ js.configs.recommended,
+ ts.configs.recommended,
+ svelte.configs.recommended,
+ prettier,
+ svelte.configs.prettier,
+ {
+ languageOptions: { globals: { ...globals.browser, ...globals.node } },
+ rules: {
+ // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
+ // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
+ 'no-undef': 'off'
+ }
+ },
+ {
+ files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
+ languageOptions: {
+ parserOptions: {
+ projectService: true,
+ extraFileExtensions: ['.svelte'],
+ parser: ts.parser,
+ svelteConfig
+ }
+ }
+ },
+ {
+ // Override or add rule settings here, such as:
+ // 'svelte/button-has-type': 'error'
+ rules: {}
+ }
+);
diff --git a/apps/homepage/package.json b/apps/homepage/package.json
new file mode 100644
index 0000000..f0dde9b
--- /dev/null
+++ b/apps/homepage/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "homepage",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync || echo ''",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "lint": "prettier --check . && eslint .",
+ "format": "prettier --write ."
+ },
+ "dependencies": {
+ "shiki": "^4.1.0"
+ },
+ "devDependencies": {
+ "@eslint/compat": "^2.0.4",
+ "@eslint/js": "^10.0.1",
+ "@sveltejs/adapter-auto": "^7.0.1",
+ "@sveltejs/kit": "^2.57.0",
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
+ "@tailwindcss/forms": "^0.5.11",
+ "@tailwindcss/typography": "^0.5.19",
+ "@tailwindcss/vite": "^4.2.2",
+ "@types/node": "^22",
+ "eslint": "^10.2.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-svelte": "^3.17.0",
+ "globals": "^17.4.0",
+ "prettier": "^3.8.1",
+ "prettier-plugin-svelte": "^3.5.1",
+ "prettier-plugin-tailwindcss": "^0.7.2",
+ "svelte": "^5.55.2",
+ "svelte-check": "^4.4.6",
+ "tailwindcss": "^4.2.2",
+ "typescript": "^6.0.2",
+ "typescript-eslint": "^8.58.1",
+ "vite": "^8.0.7"
+ }
+}
diff --git a/apps/homepage/src/app.d.ts b/apps/homepage/src/app.d.ts
new file mode 100644
index 0000000..da08e6d
--- /dev/null
+++ b/apps/homepage/src/app.d.ts
@@ -0,0 +1,13 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/apps/homepage/src/app.html b/apps/homepage/src/app.html
new file mode 100644
index 0000000..38da952
--- /dev/null
+++ b/apps/homepage/src/app.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/homepage/src/lib/assets/favicon.svg b/apps/homepage/src/lib/assets/favicon.svg
new file mode 100644
index 0000000..cc5dc66
--- /dev/null
+++ b/apps/homepage/src/lib/assets/favicon.svg
@@ -0,0 +1 @@
+svelte-logo
\ No newline at end of file
diff --git a/apps/homepage/src/lib/components/Logomark.svelte b/apps/homepage/src/lib/components/Logomark.svelte
new file mode 100644
index 0000000..b548842
--- /dev/null
+++ b/apps/homepage/src/lib/components/Logomark.svelte
@@ -0,0 +1,67 @@
+
+
+ (shaking = true)}
+ onanimationend={() => (shaking = false)}
+>
+
+
+
+
+
diff --git a/apps/homepage/src/lib/components/ThemeToggle.svelte b/apps/homepage/src/lib/components/ThemeToggle.svelte
new file mode 100644
index 0000000..8c69b9b
--- /dev/null
+++ b/apps/homepage/src/lib/components/ThemeToggle.svelte
@@ -0,0 +1,83 @@
+
+
+
+ {#if theme === 'system'}
+
+
+
+
+ {:else if theme === 'light'}
+
+
+
+
+ {:else}
+
+
+
+ {/if}
+
diff --git a/apps/homepage/src/lib/config.ts b/apps/homepage/src/lib/config.ts
new file mode 100644
index 0000000..9845031
--- /dev/null
+++ b/apps/homepage/src/lib/config.ts
@@ -0,0 +1,22 @@
+// Constants for the marketing site (landing + docs). The webapp (PWA) and the
+// relay live on their own domains; these point at them.
+
+/** Product name shown in the UI. */
+export const PROJECT_NAME = 'notify.atmo.tools';
+
+/** The webapp (PWA) — where the header/landing "Sign in" buttons send users. */
+export const WEBAPP_URL = 'https://notify.atmo.tools';
+
+/** GitHub repo link in the footer. */
+export const GITHUB_URL = 'https://github.com/flo-bit/atproto-notify';
+
+// --- Relay (used only by the /docs code examples) --------------------------
+
+/** `https://notifs.atmo.tools` — base for the relay XRPC examples. */
+export const RELAY_ORIGIN = 'https://notifs.atmo.tools';
+
+/** The relay's `did:web` identity (the `aud` for service-auth JWTs in examples). */
+export const RELAY_DID = 'did:web:notifs.atmo.tools';
+
+/** Lexicon NSID prefix for relay methods. */
+export const LEXICON_PREFIX = 'tools.atmo.notifs';
diff --git a/apps/homepage/src/lib/index.ts b/apps/homepage/src/lib/index.ts
new file mode 100644
index 0000000..856f2b6
--- /dev/null
+++ b/apps/homepage/src/lib/index.ts
@@ -0,0 +1 @@
+// place files you want to import through the `$lib` alias in this folder.
diff --git a/apps/homepage/src/lib/server/highlight.ts b/apps/homepage/src/lib/server/highlight.ts
new file mode 100644
index 0000000..6995d76
--- /dev/null
+++ b/apps/homepage/src/lib/server/highlight.ts
@@ -0,0 +1,37 @@
+// Server-only syntax highlighting via Shiki. Uses the fine-grained core + the
+// JavaScript regex engine (no WASM) so it runs on Cloudflare Workers, and a
+// single lazily-created highlighter is reused across requests.
+import { createHighlighterCore, type HighlighterCore } from 'shiki/core';
+import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
+import bash from 'shiki/langs/bash.mjs';
+import typescript from 'shiki/langs/typescript.mjs';
+import githubDark from 'shiki/themes/github-dark.mjs';
+import githubLight from 'shiki/themes/github-light.mjs';
+
+export type CodeLang = 'bash' | 'ts';
+
+const LANG_ID: Record = { bash: 'bash', ts: 'typescript' };
+
+let highlighterPromise: Promise | undefined;
+
+function getHighlighter(): Promise {
+ highlighterPromise ??= createHighlighterCore({
+ themes: [githubLight, githubDark],
+ langs: [bash, typescript],
+ engine: createJavaScriptRegexEngine()
+ });
+ return highlighterPromise;
+}
+
+/**
+ * Render code to themed HTML. Emits dual-theme output (light inline + a
+ * `--shiki-dark` CSS variable); `layout.css` swaps to the dark variable under our
+ * dark theme.
+ */
+export async function highlight(code: string, lang: CodeLang): Promise {
+ const highlighter = await getHighlighter();
+ return highlighter.codeToHtml(code, {
+ lang: LANG_ID[lang],
+ themes: { light: 'github-light', dark: 'github-dark' }
+ });
+}
diff --git a/apps/homepage/src/routes/+layout.svelte b/apps/homepage/src/routes/+layout.svelte
new file mode 100644
index 0000000..18f7086
--- /dev/null
+++ b/apps/homepage/src/routes/+layout.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
+
+
diff --git a/apps/homepage/src/routes/+page.svelte b/apps/homepage/src/routes/+page.svelte
new file mode 100644
index 0000000..04c99b2
--- /dev/null
+++ b/apps/homepage/src/routes/+page.svelte
@@ -0,0 +1,52 @@
+
+
+
+ {PROJECT_NAME} — notifications for the atmosphere
+
+
+
+
+
+
+
+
+ {PROJECT_NAME}
+
+
+ Lets any AT Protocol app send you notifications via Telegram (and soon more). You stay in
+ control: every app must ask permission, and you can revoke any time.
+
+
+
+
+
+
+ {#each features as item (item.t)}
+
+ {/each}
+
diff --git a/apps/homepage/src/routes/docs/+page.server.ts b/apps/homepage/src/routes/docs/+page.server.ts
new file mode 100644
index 0000000..9c6bd49
--- /dev/null
+++ b/apps/homepage/src/routes/docs/+page.server.ts
@@ -0,0 +1,79 @@
+import { LEXICON_PREFIX, RELAY_DID, RELAY_ORIGIN } from '$lib/config';
+import { highlight, type CodeLang } from '$lib/server/highlight';
+
+import type { PageServerLoad } from './$types';
+
+const requestExample = `curl -X POST ${RELAY_ORIGIN}/xrpc/${LEXICON_PREFIX}.requestPermission \\
+ -H "Authorization: Bearer $USER_JWT" \\
+ -H "Content-Type: application/json" \\
+ -d '{
+ "senderDid": "did:web:yourapp.example",
+ "title": "Bookhive",
+ "description": "New comments on your books"
+ }'`;
+
+const sendJwtExample = `import { createServiceJwt } from '@atcute/xrpc-server/auth';
+
+// Signed with YOUR app's key — this proves the sender identity.
+const jwt = await createServiceJwt({
+ keypair: yourKeypair,
+ issuer: 'did:web:yourapp.example',
+ audience: '${RELAY_DID}',
+ lxm: '${LEXICON_PREFIX}.send'
+});`;
+
+// Easier: use @atcute/client instead of hand-rolling fetch (pass the JWT per call).
+const sendAtcuteExample = `import { Client, simpleFetchHandler } from '@atcute/client';
+
+const client = new Client({
+ handler: simpleFetchHandler({ service: '${RELAY_ORIGIN}' })
+});
+
+await client.post('${LEXICON_PREFIX}.send', {
+ headers: { authorization: \`Bearer \${jwt}\` },
+ input: {
+ recipient: 'did:plc:recipient',
+ title: 'New reply',
+ body: 'alice replied to your post',
+ uri: 'https://yourapp.example/thread/123'
+ }
+});`;
+
+const sendCurlExample = `curl -X POST ${RELAY_ORIGIN}/xrpc/${LEXICON_PREFIX}.send \\
+ -H "Authorization: Bearer $JWT" \\
+ -H "Content-Type: application/json" \\
+ -d '{
+ "recipient": "did:plc:recipient",
+ "title": "New reply",
+ "body": "alice replied to your post",
+ "uri": "https://yourapp.example/thread/123"
+ }'`;
+
+export interface CodeBlock {
+ lang: CodeLang;
+ raw: string;
+ html: string;
+}
+
+// Highlight the (static) examples once per server instance, then reuse.
+let cached: Promise> | undefined;
+function buildBlocks() {
+ cached ??= (async () => {
+ const make = async (raw: string, lang: CodeLang): Promise => ({
+ lang,
+ raw,
+ html: await highlight(raw, lang)
+ });
+ return {
+ request: await make(requestExample, 'bash'),
+ sendJwt: await make(sendJwtExample, 'ts'),
+ sendAtcute: await make(sendAtcuteExample, 'ts'),
+ sendCurl: await make(sendCurlExample, 'bash')
+ };
+ })();
+ return cached;
+}
+
+export const load: PageServerLoad = async () => {
+ return { code: await buildBlocks() };
+};
diff --git a/apps/homepage/src/routes/docs/+page.svelte b/apps/homepage/src/routes/docs/+page.svelte
new file mode 100644
index 0000000..b1429b5
--- /dev/null
+++ b/apps/homepage/src/routes/docs/+page.svelte
@@ -0,0 +1,159 @@
+
+
+Developer docs — {PROJECT_NAME}
+
+{#snippet codeblock(block: Block)}
+
+
+ {block.lang}
+ copy(block.raw)}
+ >
+ {copied === block.raw ? 'copied' : 'copy'}
+
+
+
+
+ {@html block.html}
+
+
+{/snippet}
+
+
+
+ Developer docs
+
+ Any atproto app can ask users to receive notifications via {PROJECT_NAME}. Users approve in
+ the dashboard; the relay delivers via Telegram.
+
+
+ Two endpoints, two auth mechanisms.
+ requestPermission proves
+ the user authorized this request (user OAuth);
+ send proves
+ the sender identity (your app's own DID key).
+
+
+ Prefer a working example? A complete, ~300-line app wiring up
+ both flows is live at
+ example.notify.atmo.tools
+ — try it, then read the
+ source ↗ .
+
+
+
+
+ 1. Get a DID for your app
+
+ Needed for send. The simplest option is
+ did:web:
+
+
+ Host /.well-known/did.json on your app's domain.
+
+ Generate a P-256 keypair and put the public key in the DID document as a
+ verificationMethod whose id ends in
+ #atproto.
+
+
+ Reference:
+ atproto DID spec ↗
+
+
+
+
+
+ 2. Request permission (user OAuth)
+
+ The user signs into your app via atproto OAuth. Add just the
+ requestPermission method to your app's OAuth scope —
+ send uses your app's own key, not the user's session,
+ so it doesn't belong here:
+
+
+ atproto rpc?lxm=tools.atmo.notifs.requestPermission&aud=*
+
+
+ Then mint a service-auth JWT on the user's PDS via
+ com.atproto.server.getServiceAuth and call:
+
+ {@render codeblock(data.code.request)}
+
+ Returns { id, status }
+ (pending or
+ alreadyGranted). The user approves in their dashboard or
+ via Telegram. title ≤ 50 chars,
+ description ≤ 200 chars, optional
+ iconUrl.
+
+
+
+
+ 3. Send a notification (your app's key)
+
+ Once granted, sign with your app's own key (no user involved) and send. Field limits:
+ title ≤ 100, body
+ ≤ 500, optional uri and
+ threadKey.
+
+ {@render codeblock(data.code.sendJwt)}
+
+ Easiest with @atcute/client (pass the JWT per call):
+
+ {@render codeblock(data.code.sendAtcute)}
+ …or any HTTP client:
+ {@render codeblock(data.code.sendCurl)}
+
+
+
+ 4. Rate limits
+
+ At most 1 outstanding pending request per (sender, recipient).
+ requestPermission: 50 / hour per recipient and 100 / hour per sender.
+ send: 1 / second and 100 / day per (sender, recipient).
+
+
+
+
+ 5. Error handling
+ Common XRPC errors:
+
+ AuthenticationRequired — missing/invalid JWT.
+ NotAuthorized — no active grant for this recipient.
+ RateLimitExceeded — slow down (see Retry-After).
+ InvalidRequest — malformed body (e.g. bad senderDid).
+
+
+
diff --git a/apps/homepage/src/routes/layout.css b/apps/homepage/src/routes/layout.css
new file mode 100644
index 0000000..4188ea5
--- /dev/null
+++ b/apps/homepage/src/routes/layout.css
@@ -0,0 +1,131 @@
+@import 'tailwindcss';
+@plugin '@tailwindcss/forms';
+@plugin '@tailwindcss/typography';
+
+/*
+ * Semantic design tokens (palette adapted from the design bundle).
+ * Colors are exposed to Tailwind via `@theme inline`, so utilities like
+ * `bg-surface`, `text-muted`, `border-line` resolve to the live CSS variables
+ * and switch automatically between light and dark.
+ */
+@theme inline {
+ --color-bg: var(--bg);
+ --color-surface: var(--surface);
+ --color-surface-2: var(--surface-2);
+ --color-line: var(--line);
+ --color-line-2: var(--line-2);
+ --color-fg: var(--fg);
+ --color-muted: var(--muted);
+ --color-muted-2: var(--muted-2);
+ --color-accent: var(--accent);
+ --color-accent-fg: var(--accent-fg);
+ --color-accent-soft: var(--accent-soft);
+ --color-success: var(--success);
+ --color-warn: var(--warn);
+ --color-danger: var(--danger);
+
+ --font-sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
+ --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, monospace;
+
+ --radius-card: 0.75rem;
+}
+
+/* Light theme (default). */
+:root {
+ color-scheme: light;
+ --bg: oklch(0.985 0.003 80);
+ --surface: oklch(1 0 0);
+ --surface-2: oklch(0.975 0.003 80);
+ --line: oklch(0.92 0.005 80);
+ --line-2: oklch(0.95 0.004 80);
+ --fg: oklch(0.22 0.01 60);
+ --muted: oklch(0.5 0.01 60);
+ --muted-2: oklch(0.68 0.01 60);
+ --accent: oklch(0.55 0.18 250);
+ --accent-fg: oklch(1 0 0);
+ --accent-soft: oklch(0.55 0.18 250 / 0.1);
+ --success: oklch(0.6 0.14 150);
+ --warn: oklch(0.7 0.14 80);
+ --danger: oklch(0.58 0.18 28);
+}
+
+/* Dark theme values, shared by the explicit toggle and the system fallback. */
+:root[data-theme='dark'],
+:root.theme-dark {
+ color-scheme: dark;
+ --bg: oklch(0.18 0.005 60);
+ --surface: oklch(0.22 0.005 60);
+ --surface-2: oklch(0.25 0.005 60);
+ --line: oklch(0.32 0.005 60);
+ --line-2: oklch(0.28 0.005 60);
+ --fg: oklch(0.97 0.005 80);
+ --muted: oklch(0.72 0.01 60);
+ --muted-2: oklch(0.55 0.01 60);
+ --accent: oklch(0.72 0.18 250);
+ --accent-fg: oklch(0.18 0.005 60);
+ --accent-soft: oklch(0.72 0.18 250 / 0.16);
+ --success: oklch(0.78 0.14 150);
+ --warn: oklch(0.78 0.14 80);
+ --danger: oklch(0.72 0.18 28);
+}
+
+/* No-JS / pre-hydration fallback: honor the OS preference when no explicit
+ theme has been chosen (the inline script in app.html sets data-theme). */
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme='light']):not([data-theme='dark']) {
+ color-scheme: dark;
+ --bg: oklch(0.18 0.005 60);
+ --surface: oklch(0.22 0.005 60);
+ --surface-2: oklch(0.25 0.005 60);
+ --line: oklch(0.32 0.005 60);
+ --line-2: oklch(0.28 0.005 60);
+ --fg: oklch(0.97 0.005 80);
+ --muted: oklch(0.72 0.01 60);
+ --muted-2: oklch(0.55 0.01 60);
+ --accent: oklch(0.72 0.18 250);
+ --accent-fg: oklch(0.18 0.005 60);
+ --accent-soft: oklch(0.72 0.18 250 / 0.16);
+ --success: oklch(0.78 0.14 150);
+ --warn: oklch(0.78 0.14 80);
+ --danger: oklch(0.72 0.18 28);
+ }
+}
+
+html {
+ background: var(--bg);
+ color: var(--fg);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* Consistent, visible focus ring for keyboard users. */
+:where(a, button, input, select, textarea):focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+/*
+ * Shiki code blocks (rendered via {@html} in /docs). Shiki emits dual-theme
+ * output: light colors inline + a `--shiki-dark` variable per token. We keep the
+ * card's own surface background (transparent ) and swap to the dark tokens
+ * under our dark theme.
+ */
+.codeblock :where(pre.shiki) {
+ margin: 0;
+ background-color: transparent !important;
+ font-family: var(--font-mono);
+}
+:root[data-theme='dark'] .codeblock :where(pre.shiki, pre.shiki span) {
+ color: var(--shiki-dark) !important;
+ background-color: transparent !important;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme='light']):not([data-theme='dark'])
+ .codeblock
+ :where(pre.shiki, pre.shiki span) {
+ color: var(--shiki-dark) !important;
+ background-color: transparent !important;
+ }
+}
diff --git a/apps/homepage/static/robots.txt b/apps/homepage/static/robots.txt
new file mode 100644
index 0000000..b6dd667
--- /dev/null
+++ b/apps/homepage/static/robots.txt
@@ -0,0 +1,3 @@
+# allow crawling everything by default
+User-agent: *
+Disallow:
diff --git a/apps/homepage/svelte.config.js b/apps/homepage/svelte.config.js
new file mode 100644
index 0000000..0c3412e
--- /dev/null
+++ b/apps/homepage/svelte.config.js
@@ -0,0 +1,17 @@
+import adapter from '@sveltejs/adapter-auto';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ compilerOptions: {
+ // Force runes mode for the project, except for libraries. Can be removed in svelte 6.
+ runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
+ },
+ kit: {
+ // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
+ // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
+ // See https://svelte.dev/docs/kit/adapters for more information about adapters.
+ adapter: adapter()
+ }
+};
+
+export default config;
diff --git a/apps/homepage/tsconfig.json b/apps/homepage/tsconfig.json
new file mode 100644
index 0000000..2c2ed3c
--- /dev/null
+++ b/apps/homepage/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "rewriteRelativeImportExtensions": true,
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
+ //
+ // To make changes to top-level options such as include and exclude, we recommend extending
+ // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
+}
diff --git a/apps/homepage/vite.config.ts b/apps/homepage/vite.config.ts
new file mode 100644
index 0000000..56f40c7
--- /dev/null
+++ b/apps/homepage/vite.config.ts
@@ -0,0 +1,5 @@
+import tailwindcss from '@tailwindcss/vite';
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d49d05b..05318a5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -106,6 +106,79 @@ importers:
specifier: ^8.0.7
version: 8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)
+ apps/homepage:
+ dependencies:
+ shiki:
+ specifier: ^4.1.0
+ version: 4.1.0
+ devDependencies:
+ '@eslint/compat':
+ specifier: ^2.0.4
+ version: 2.1.0(eslint@10.4.0(jiti@2.7.0))
+ '@eslint/js':
+ specifier: ^10.0.1
+ version: 10.0.1(eslint@10.4.0(jiti@2.7.0))
+ '@sveltejs/adapter-auto':
+ specifier: ^7.0.1
+ version: 7.0.1(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))
+ '@sveltejs/kit':
+ specifier: ^2.57.0
+ version: 2.60.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0))
+ '@sveltejs/vite-plugin-svelte':
+ specifier: ^7.0.0
+ version: 7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0))
+ '@tailwindcss/forms':
+ specifier: ^0.5.11
+ version: 0.5.11(tailwindcss@4.3.0)
+ '@tailwindcss/typography':
+ specifier: ^0.5.19
+ version: 0.5.19(tailwindcss@4.3.0)
+ '@tailwindcss/vite':
+ specifier: ^4.2.2
+ version: 4.3.0(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0))
+ '@types/node':
+ specifier: ^22
+ version: 22.19.19
+ eslint:
+ specifier: ^10.2.0
+ version: 10.4.0(jiti@2.7.0)
+ eslint-config-prettier:
+ specifier: ^10.1.8
+ version: 10.1.8(eslint@10.4.0(jiti@2.7.0))
+ eslint-plugin-svelte:
+ specifier: ^3.17.0
+ version: 3.17.1(eslint@10.4.0(jiti@2.7.0))(svelte@5.55.9(@typescript-eslint/types@8.59.4))
+ globals:
+ specifier: ^17.4.0
+ version: 17.6.0
+ prettier:
+ specifier: ^3.8.1
+ version: 3.8.3
+ prettier-plugin-svelte:
+ specifier: ^3.5.1
+ version: 3.5.2(prettier@3.8.3)(svelte@5.55.9(@typescript-eslint/types@8.59.4))
+ prettier-plugin-tailwindcss:
+ specifier: ^0.7.2
+ version: 0.7.4(prettier-plugin-svelte@3.5.2(prettier@3.8.3)(svelte@5.55.9(@typescript-eslint/types@8.59.4)))(prettier@3.8.3)
+ svelte:
+ specifier: ^5.55.2
+ version: 5.55.9(@typescript-eslint/types@8.59.4)
+ svelte-check:
+ specifier: ^4.4.6
+ version: 4.4.8(picomatch@4.0.4)(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)
+ tailwindcss:
+ specifier: ^4.2.2
+ version: 4.3.0
+ typescript:
+ specifier: ^6.0.2
+ version: 6.0.3
+ typescript-eslint:
+ specifier: ^8.58.1
+ version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
+ vite:
+ specifier: ^8.0.7
+ version: 8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)
+
apps/relay:
dependencies:
'@atcute/client':
@@ -1023,6 +1096,11 @@ packages:
peerDependencies:
acorn: ^8.9.0
+ '@sveltejs/adapter-auto@7.0.1':
+ resolution: {integrity: sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==}
+ peerDependencies:
+ '@sveltejs/kit': ^2.0.0
+
'@sveltejs/adapter-cloudflare@7.2.8':
resolution: {integrity: sha512-bIdhY/Fi4AQmqiBdQVKnafH1h9Gw+xbCvHyUu4EouC8rJOU02zwhi14k/FDhQ0mJF1iblIu3m8UNQ8GpGIvIOQ==}
peerDependencies:
@@ -3020,6 +3098,10 @@ snapshots:
dependencies:
acorn: 8.16.0
+ '@sveltejs/adapter-auto@7.0.1(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))':
+ dependencies:
+ '@sveltejs/kit': 2.60.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0))
+
'@sveltejs/adapter-cloudflare@7.2.8(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(wrangler@4.94.0(@cloudflare/workers-types@4.20260522.1))':
dependencies:
'@cloudflare/workers-types': 4.20260522.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 82f7927..190519d 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -11,4 +11,4 @@ minimumReleaseAgeExclude:
- "@cloudflare/workers-types@4.20260522.1"
- miniflare@4.20260521.0
- wrangler@4.94.0
- - '@svelte-atproto/oauth@0.3.0'
+ - "@svelte-atproto/oauth@0.3.0"