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 @@ + + + + + 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 @@ + + + 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)} +
+

{item.t}

+

{item.d}

+
+ {/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} + +
+
+ + {@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"