diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..61e2a92 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.build +.next +.turbo +.git +node_modules +apps/web/.next +apps/web/node_modules +packages/lexicons/node_modules +*.log +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e4f88a0 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +ATELIER_ENV=development +ATELIER_PUBLIC_URL=http://localhost:3000 +ATELIER_API_URL=http://localhost:8080 +NEXT_PUBLIC_ATELIER_API_URL=http://localhost:8080 +ATELIER_DB_URL=postgres://atelier:atelier@localhost:5432/atelier_mail +ATELIER_KV_BACKEND=postgres +ATELIER_KMS_PROVIDER=local-dev +ATELIER_TOKEN_KEK_ID=local-dev-kek +ATELIER_ATPROTO_CLIENT_ID=http://localhost:3000/client-metadata.json +ATELIER_ATPROTO_REDIRECT_URI=http://localhost:3000/auth/callback +ATELIER_GMAIL_CLIENT_ID= +ATELIER_GMAIL_CLIENT_SECRET= +ATELIER_GMAIL_PUBSUB_AUDIENCE= +ATELIER_MCP_ENABLED=true +ATELIER_MCP_MYCONTEXTPROTOCOL_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7dcaf9e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + pull_request: + push: + branches: [main, dev] + +jobs: + web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun run lint + - run: bun run test + - run: bun run build + + swift: + runs-on: macos-15 + steps: + - uses: actions/checkout@v5 + - run: swift test + working-directory: services/api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9255f32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.DS_Store +.env +.env.local +.env.*.local +node_modules +.next +dist +coverage +.turbo +.bun-install-cache +*.tsbuildinfo +.build +.swiftpm +DerivedData +xcuserdata +*.xcodeproj +*.xcworkspace +*.sqlite +*.sqlite-shm +*.sqlite-wal +infra/.data diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..ffa51ed --- /dev/null +++ b/Package.swift @@ -0,0 +1,56 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "AtelierMailWorkspace", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable(name: "AtelierAPI", targets: ["AtelierAPI"]), + .library(name: "AtelierCore", targets: ["AtelierCore"]), + .library(name: "AtelierATProto", targets: ["AtelierATProto"]), + .library(name: "AtelierMCP", targets: ["AtelierMCP"]), + .library(name: "AtelierSync", targets: ["AtelierSync"]), + .library(name: "AtelierPlatform", targets: ["AtelierPlatform"]), + .library(name: "AtelierMail", targets: ["AtelierMail"]) + ], + targets: [ + .target(name: "AtelierCore", path: "packages/swift/AtelierCore/Sources/AtelierCore"), + .target( + name: "AtelierATProto", + dependencies: ["AtelierCore"], + path: "packages/swift/AtelierATProto/Sources/AtelierATProto" + ), + .target( + name: "AtelierMCP", + dependencies: ["AtelierCore"], + path: "packages/swift/AtelierMCP/Sources/AtelierMCP" + ), + .target( + name: "AtelierSync", + dependencies: ["AtelierCore"], + path: "packages/swift/AtelierSync/Sources/AtelierSync" + ), + .target( + name: "AtelierPlatform", + dependencies: ["AtelierCore", "AtelierATProto", "AtelierMCP"], + path: "packages/swift/AtelierPlatform/Sources/AtelierPlatform" + ), + .target( + name: "AtelierMail", + dependencies: ["AtelierCore", "AtelierPlatform", "AtelierSync"], + path: "packages/swift/AtelierMail/Sources/AtelierMail" + ), + .executableTarget( + name: "AtelierAPI", + dependencies: ["AtelierCore", "AtelierPlatform", "AtelierMail", "AtelierMCP"], + path: "services/api/Sources/AtelierAPI" + ), + .testTarget( + name: "AtelierAPITests", + dependencies: ["AtelierAPI", "AtelierCore", "AtelierPlatform", "AtelierMail", "AtelierMCP"], + path: "services/api/Tests/AtelierAPITests" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..adb7608 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Atelier Mail + +Atelier Mail is the first application scaffold for the Atelier Work suite at `atelierwork.space`. + +The MVP is a SaaS-first mail client with: + +- ATProto OAuth identity with DID as the primary account key. +- Server-side Gmail, JMAP, and IMAP provider access. +- A local normalized mail domain model backed by PostgreSQL. +- Private permissioned Lexicon concepts stored in Atelier KV until ATProto Permissioned Data is finalized. +- MCP hooks for agent access through an explicit grant model. +- A Next.js/shadcn/Tailwind web client inspired by the scalable Social Wire shell and the warmer Skej interaction style. + +## Structure + +- `apps/web` - Next.js 16 app shell for `mail.atelierwork.space`. +- `services/api` - Swift/Hummingbird service scaffold. +- `packages/swift` - extraction-ready Swift package boundaries. +- `packages/lexicons` - `space.atelierwork.*` draft schemas and validation tests. +- `infra` - local Docker Compose and operational scaffolding. +- `docs` - architecture and implementation notes. + +## Local Commands + +```bash +bun install +bun run verify +``` + +Swift checks: + +```bash +DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer swift test +``` + +Full verification: + +```bash +bun run verify:all +``` + +Local deploy smoke: + +```bash +docker compose -f infra/docker-compose.yml up --build +``` + +See `docs/deployment.md` for deployment environment and process details. diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..87f0885 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,21 @@ +FROM oven/bun:1.3.14-alpine AS deps +WORKDIR /app +COPY package.json bun.lock bunfig.toml ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/lexicons/package.json packages/lexicons/package.json +RUN bun install --frozen-lockfile + +FROM deps AS builder +WORKDIR /app +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN bun run --filter './apps/web' build + +FROM oven/bun:1.3.14-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +COPY --from=builder /app ./ +EXPOSE 3000 +CMD ["bun", "run", "--filter", "./apps/web", "start"] diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..8d886db --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..5d8d8a3 --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,6 @@ +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = [...nextVitals, ...nextTs]; + +export default eslintConfig; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..407606a --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,16 @@ +import type { NextConfig } from "next"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const appDir = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(appDir, "../.."); + +const nextConfig: NextConfig = { + typedRoutes: true, + devIndicators: false, + turbopack: { + root: workspaceRoot, + }, +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..e4d3558 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,48 @@ +{ + "name": "@atelier-mail/web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --webpack", + "dev:turbo": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "eslint --max-warnings 0", + "test": "bun test --preload ./src/test/setup.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@atelierwork/lexicons": "workspace:*", + "@tanstack/react-virtual": "^3.13.24", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.14.0", + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@testing-library/react": "^16.3.2", + "@types/bun": "^1.3.4", + "@types/jsdom": "^28.0.3", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "jsdom": "^27.2.0", + "playwright": "^1.61.1", + "tailwindcss": "^4", + "typescript": "^5" + }, + "ignoreScripts": [ + "sharp", + "unrs-resolver" + ], + "trustedDependencies": [ + "sharp", + "unrs-resolver" + ] +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/apps/web/scripts/capture-screenshots.mjs b/apps/web/scripts/capture-screenshots.mjs new file mode 100644 index 0000000..b24a3b3 --- /dev/null +++ b/apps/web/scripts/capture-screenshots.mjs @@ -0,0 +1,46 @@ +import { chromium } from "playwright"; + +const browser = await chromium.launch({ headless: true }); + +try { + const desktop = await browser.newPage({ + viewport: { width: 1440, height: 1000 }, + deviceScaleFactor: 1, + }); + await desktop.goto("http://localhost:3000", { waitUntil: "networkidle" }); + await desktop.screenshot({ + path: "../../.codex-desktop.png", + fullPage: true, + }); + + await desktop.getByLabel("Switch to dark mode").click(); + await desktop.waitForTimeout(150); + await desktop.screenshot({ + path: "../../.codex-desktop-dark.png", + fullPage: true, + }); + + const mobile = await browser.newPage({ + viewport: { width: 390, height: 844 }, + isMobile: true, + }); + await mobile.goto("http://localhost:3000", { waitUntil: "networkidle" }); + await mobile.screenshot({ + path: "../../.codex-mobile.png", + fullPage: true, + }); + + const result = { + desktopTitle: await desktop.locator("text=Atelier Mail").first().isVisible(), + threadVisible: await desktop + .locator("text=Permissioned KV model for linked mail threads") + .first() + .isVisible(), + mobileSearch: await mobile.locator('input[aria-label="Search"]').isVisible(), + darkMode: await desktop.locator("html.dark").count(), + }; + + console.log(JSON.stringify(result, null, 2)); +} finally { + await browser.close(); +} diff --git a/apps/web/src/__tests__/mail-workspace.test.tsx b/apps/web/src/__tests__/mail-workspace.test.tsx new file mode 100644 index 0000000..9aaa27c --- /dev/null +++ b/apps/web/src/__tests__/mail-workspace.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, test } from "bun:test"; +import { MailWorkspace } from "@/components/mail/MailWorkspace"; + +beforeEach(() => { + window.localStorage.clear(); + document.documentElement.className = ""; + delete document.documentElement.dataset.theme; + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: undefined, + }); +}); + +test("renders the signed-in mail workspace as the first screen", () => { + render(); + + expect(screen.getByText("Atelier Mail")).toBeTruthy(); + expect(screen.getByText("Unified Inbox")).toBeTruthy(); + expect(screen.getByText("Permissioned KV model for linked mail threads")).toBeTruthy(); + expect(screen.getByText("Smart Filters")).toBeTruthy(); +}); + +test("starts from a saved dark mode preference", async () => { + window.localStorage.setItem("atelier-mail-theme", "dark"); + + render(); + + expect(screen.getByRole("button", { name: "Switch to light mode" })).toBeTruthy(); + await waitFor(() => expect(document.documentElement.classList.contains("dark")).toBe(true)); +}); + +test("uses system dark mode as the first-run preference", () => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: (query: string) => + ({ + matches: query === "(prefers-color-scheme: dark)", + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent() { + return false; + }, + }) satisfies MediaQueryList, + }); + + render(); + + expect(screen.getByRole("button", { name: "Switch to light mode" })).toBeTruthy(); +}); + +test("persists explicit theme toggles", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Switch to dark mode" })); + + await waitFor(() => expect(window.localStorage.getItem("atelier-mail-theme")).toBe("dark")); + expect(document.documentElement.classList.contains("dark")).toBe(true); +}); diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..4a5ad74 --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,254 @@ +@import "tailwindcss"; + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --background: #fffaf7; + --foreground: #242120; + --card: #ffffff; + --card-foreground: #242120; + --popover: #ffffff; + --popover-foreground: #242120; + --primary: #ff6542; + --primary-foreground: #ffffff; + --secondary: #e8fbff; + --secondary-foreground: #18333a; + --muted: #f4eeeb; + --muted-foreground: #716966; + --accent: #fff0a8; + --accent-foreground: #342a05; + --destructive: #d94b4b; + --destructive-foreground: #ffffff; + --border: #eadeda; + --input: #eadeda; + --ring: #1fb8ca; + --sidebar: #fffdfb; + --sidebar-foreground: #2d2927; + --radius: 0.5rem; +} + +.dark { + --background: #080706; + --foreground: #f6eee9; + --card: #0d0b0a; + --card-foreground: #f6eee9; + --popover: #100d0c; + --popover-foreground: #f6eee9; + --primary: #ff7656; + --primary-foreground: #1d120d; + --secondary: #102f34; + --secondary-foreground: #d9fbff; + --muted: #181412; + --muted-foreground: #a89b95; + --accent: #3d2f0c; + --accent-foreground: #fff2b6; + --destructive: #ff6b6b; + --destructive-foreground: #210b0b; + --border: #271f1b; + --input: #271f1b; + --ring: #2ed3e6; + --sidebar: #090807; + --sidebar-foreground: #f2e9e3; +} + +* { + border-color: var(--border); +} + +html { + min-height: 100%; + background: var(--background); + color-scheme: light; +} + +html.dark { + color-scheme: dark; +} + +body { + min-height: 100dvh; + margin: 0; + background: + linear-gradient(135deg, rgba(255, 240, 168, 0.48), transparent 26rem) 0 0 / 38rem 38rem no-repeat, + linear-gradient(145deg, rgba(198, 244, 255, 0.46), transparent 24rem) 100% 0 / 34rem 34rem no-repeat, + linear-gradient(180deg, #fffaf7 0%, #fffdfb 52%, #f7fbfb 100%); + color: var(--foreground); + font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +.dark body { + background: + linear-gradient(135deg, rgba(255, 118, 86, 0.08), transparent 24rem) 0 0 / 36rem 36rem no-repeat, + linear-gradient(145deg, rgba(46, 211, 230, 0.05), transparent 23rem) 100% 0 / 34rem 34rem no-repeat, + linear-gradient(180deg, #080706 0%, #0b0908 54%, #060504 100%); +} + +button, +input, +textarea, +select { + font: inherit; +} + +button svg { + width: 1rem; + height: 1rem; +} + +.mail-card-surface { + background-color: var(--card); +} + +.mail-row-surface { + background-color: var(--card); +} + +.mail-row-surface:hover { + background-color: color-mix(in srgb, var(--primary) 10%, var(--card)); +} + +.mail-row-unread { + background-color: color-mix(in srgb, var(--primary) 10%, var(--card)); + box-shadow: inset 0 0 0 0.0625rem color-mix(in srgb, var(--primary) 16%, transparent); +} + +.mail-row-selected { + background-color: color-mix(in srgb, var(--primary) 15%, var(--card)); + box-shadow: + inset 0.125rem 0 0 var(--primary), + inset 0 0 0 0.0625rem color-mix(in srgb, var(--primary) 24%, transparent); +} + +.mail-sidebar-row { + font-size: 0.8125rem !important; + line-height: 1rem !important; +} + +.mail-sidebar-account { + font-size: 0.8125rem !important; + line-height: 1rem !important; +} + +.mail-sidebar-meta { + font-size: 0.6875rem !important; + line-height: 0.875rem !important; +} + +.mail-sidebar-section-label { + font-size: 0.625rem !important; + line-height: 0.8125rem !important; +} + +.mail-sidebar-count { + font-size: 0.6875rem !important; + line-height: 1 !important; +} + +.mail-search-input { + font-size: 0.75rem !important; + line-height: 1rem !important; +} + +.mail-search-shortcut { + font-size: 0.5625rem !important; + line-height: 1 !important; +} + +.mail-composer { + font-size: 0.75rem; + line-height: 1rem; +} + +.mail-composer-title { + font-size: 0.75rem !important; + line-height: 1rem !important; +} + +.mail-composer-field { + font-size: 0.75rem !important; + line-height: 1rem !important; +} + +.mail-composer-message { + min-height: 8.5rem !important; + font-size: 0.8125rem !important; + line-height: 1.25rem !important; +} + +.mail-composer-meta { + font-size: 0.6875rem !important; + line-height: 0.875rem !important; +} + +.dark main, +.dark .mail-row-surface { + color: var(--foreground); +} + +.dark .text-muted-foreground { + color: var(--muted-foreground) !important; +} + +.dark .mail-card-surface, +.dark .mail-row-surface { + background-color: #0d0b0a; + color: #f6eee9; +} + +.dark .mail-row-surface:hover, +.dark .mail-row-unread { + background-color: #1c100d; +} + +.dark .mail-row-selected { + background-color: #28140e; +} + +::selection { + background: #bdf4ff; + color: #142226; +} + +@media (prefers-reduced-motion: no-preference) { + .atelier-shimmer { + background-size: 220% 100%; + animation: atelier-shimmer 7s ease-in-out infinite; + } + + @keyframes atelier-shimmer { + 0%, + 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..def7e68 --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; +import { EnvironmentBanner } from "@/components/shared/EnvironmentBanner"; +import { getAppEnv, shouldShowEnvironmentBanner } from "@/lib/appEnv"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Atelier Mail", + description: "ATProto identity mail workspace for atelierwork.space", +}; + +const themeScript = ` +(function() { + try { + var theme = window.localStorage.getItem("atelier-mail-theme"); + if (theme !== "light" && theme !== "dark") { + theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + document.documentElement.classList.toggle("dark", theme === "dark"); + document.documentElement.dataset.theme = theme; + } catch (_) {} +})(); +`; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + const appEnv = getAppEnv(); + const bannerHeight = shouldShowEnvironmentBanner(appEnv) ? "2.0625rem" : "0px"; + + return ( + + +