diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dea5311..2aa7785 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,42 +86,4 @@ jobs: with: name: compiled-templates path: templates-parser/out - - docker: - name: docker build & push - runs-on: ubuntu-latest - needs: [fmt, clippy, test, templates-build] - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/mailify - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix=sha- - type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: docker/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max +# docker image build + push lives in .github/workflows/docker.yml (tag-triggered) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..eceea9f --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,98 @@ +name: docker + +on: + push: + tags: ['v*'] + workflow_dispatch: + +jobs: + build: + name: build (${{ matrix.platform }}) + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/mailify + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ secrets.DOCKERHUB_USERNAME }}/mailify,push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + manifest: + name: manifest & push + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + - uses: docker/setup-buildx-action@v3 + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/mailify + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ secrets.DOCKERHUB_USERNAME }}/mailify@sha256:%s ' *) + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ secrets.DOCKERHUB_USERNAME }}/mailify:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fd4ac0d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,200 @@ +name: release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag to release (e.g. v0.2.0). Tag must already exist.' + required: true + +env: + CARGO_TERM_COLOR: always + BIN_NAME: mailify + +jobs: + # ───────────────────────────────────────────────────────────── + # 1. Create a draft GitHub Release up-front so matrix jobs upload into it + # ───────────────────────────────────────────────────────────── + create-release: + name: create draft release + runs-on: ubuntu-24.04 + permissions: + contents: write + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.resolve.outputs.version }} + steps: + - name: Resolve tag + id: resolve + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ inputs.tag }}" + else + TAG="${GITHUB_REF#refs/tags/}" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + with: + ref: ${{ steps.resolve.outputs.tag }} + - name: Create draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh release view "${{ steps.resolve.outputs.tag }}" >/dev/null 2>&1; then + echo "Release already exists — skipping creation." + else + gh release create "${{ steps.resolve.outputs.tag }}" \ + --draft \ + --title "${{ steps.resolve.outputs.tag }}" \ + --generate-notes + fi + + # ───────────────────────────────────────────────────────────── + # 2. Build binaries on native runners, upload signed archive + SHA256 + # ───────────────────────────────────────────────────────────── + build-binaries: + name: build ${{ matrix.target }} + needs: create-release + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-24.04 + archive: tar.gz + - target: x86_64-unknown-linux-musl + runner: ubuntu-24.04 + archive: tar.gz + musl: true + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04-arm + archive: tar.gz + - target: x86_64-apple-darwin + runner: macos-13 + archive: tar.gz + - target: aarch64-apple-darwin + runner: macos-14 + archive: tar.gz + - target: x86_64-pc-windows-msvc + runner: windows-2022 + archive: zip + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.create-release.outputs.tag }} + + - name: Install Bun (templates build) + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3" + + - name: Build templates + working-directory: templates-parser + run: | + bun install --frozen-lockfile + bun run build + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install musl tools + if: matrix.musl == true + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools + + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Build release binary + run: cargo build --release --bin ${{ env.BIN_NAME }} --target ${{ matrix.target }} + + - name: Stage archive (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + STAGE="${BIN_NAME}-${{ needs.create-release.outputs.version }}-${{ matrix.target }}" + mkdir -p "dist/$STAGE" + cp "target/${{ matrix.target }}/release/${BIN_NAME}" "dist/$STAGE/" + cp -r templates-parser/out "dist/$STAGE/templates" + cp README.md LICENSE* "dist/$STAGE/" 2>/dev/null || true + cd dist + tar -czf "$STAGE.tar.gz" "$STAGE" + shasum -a 256 "$STAGE.tar.gz" > "$STAGE.tar.gz.sha256" + echo "ASSET=dist/$STAGE.tar.gz" >> "$GITHUB_ENV" + echo "ASSET_SHA=dist/$STAGE.tar.gz.sha256" >> "$GITHUB_ENV" + + - name: Stage archive (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $stage = "$env:BIN_NAME-${{ needs.create-release.outputs.version }}-${{ matrix.target }}" + New-Item -ItemType Directory -Force -Path "dist/$stage" | Out-Null + Copy-Item "target/${{ matrix.target }}/release/$env:BIN_NAME.exe" "dist/$stage/" + Copy-Item -Recurse "templates-parser/out" "dist/$stage/templates" + Copy-Item README.md,LICENSE* "dist/$stage/" -ErrorAction SilentlyContinue + Push-Location dist + Compress-Archive -Path $stage -DestinationPath "$stage.zip" + (Get-FileHash "$stage.zip" -Algorithm SHA256).Hash.ToLower() + " $stage.zip" | Out-File "$stage.zip.sha256" -Encoding ascii + Pop-Location + "ASSET=dist/$stage.zip" | Out-File -FilePath $env:GITHUB_ENV -Append + "ASSET_SHA=dist/$stage.zip.sha256" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Upload to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ needs.create-release.outputs.tag }}" "${{ env.ASSET }}" "${{ env.ASSET_SHA }}" --clobber + + # ───────────────────────────────────────────────────────────── + # 3. Publish crates to crates.io + # Disabled by default — enable once libs are deemed API-stable and + # CARGO_REGISTRY_TOKEN is set in repo secrets. + # ───────────────────────────────────────────────────────────── + publish-crates: + name: cargo publish + needs: build-binaries + runs-on: ubuntu-24.04 + if: false # flip to: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.create-release.outputs.tag }} + - uses: dtolnay/rust-toolchain@stable + - name: Publish in dep order + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + set -e + for crate in mailify-core mailify-config mailify-templates mailify-smtp mailify-auth mailify-queue mailify-api; do + echo "::group::publish $crate" + cargo publish -p "$crate" --no-verify + # Wait for crates.io index propagation before next dependent publish + sleep 30 + echo "::endgroup::" + done + + # ───────────────────────────────────────────────────────────── + # 4. Finalize: flip draft → published + # ───────────────────────────────────────────────────────────── + finalize: + name: publish release + needs: [create-release, build-binaries] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.create-release.outputs.tag }} + - name: Flip draft to published + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release edit "${{ needs.create-release.outputs.tag }}" --draft=false diff --git a/Cargo.lock b/Cargo.lock index 83d7db5..ec283c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1331,7 +1331,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "mailify-api" -version = "0.1.0" +version = "0.1.2" dependencies = [ "anyhow", "axum", @@ -1363,7 +1363,7 @@ dependencies = [ [[package]] name = "mailify-auth" -version = "0.1.0" +version = "0.1.2" dependencies = [ "argon2", "async-trait", @@ -1382,7 +1382,7 @@ dependencies = [ [[package]] name = "mailify-config" -version = "0.1.0" +version = "0.1.2" dependencies = [ "dotenvy", "figment", @@ -1396,7 +1396,7 @@ dependencies = [ [[package]] name = "mailify-core" -version = "0.1.0" +version = "0.1.2" dependencies = [ "chrono", "serde", @@ -1409,7 +1409,7 @@ dependencies = [ [[package]] name = "mailify-queue" -version = "0.1.0" +version = "0.1.2" dependencies = [ "apalis", "apalis-sql", @@ -1434,7 +1434,7 @@ dependencies = [ [[package]] name = "mailify-smtp" -version = "0.1.0" +version = "0.1.2" dependencies = [ "async-trait", "base64", @@ -1452,7 +1452,7 @@ dependencies = [ [[package]] name = "mailify-templates" -version = "0.1.0" +version = "0.1.2" dependencies = [ "mailify-config", "mailify-core", diff --git a/Cargo.toml b/Cargo.toml index ffc1908..7a58bad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "0.1.2" edition = "2021" rust-version = "1.88" authors = ["Doni Lite"] diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..858cd5a --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,364 @@ +# Mailify — Design System + +> Single source of truth for brand, visual tokens, typography, and UI patterns used across the site, the docs, the email templates, and any future UI surface (admin dashboard, CLI output styling, OG images). + +--- + +## 1. Brand essence + +### 1.1 Name & etymology +**Mailify** = *mail* + *-ify* (the suffix "to make into"). The product takes plain mail and *makes it yours* — branded, themed, shaped to your visual identity. The name is a promise: give us a theme, we give you back mail that looks like you. + +### 1.2 Positioning statement +> *Mailify is the self-hosted transactional email server that wears your brand. One Docker image, your colors, your templates, zero vendor lock-in.* + +### 1.3 Tone of voice +- **Direct, technical, confident** — we talk to developers, no marketing fluff. +- **Pragmatic over poetic** — "send branded mail in 5 minutes" > "elevate your communication". +- **Self-deprecating OK** — "yes, it's just a wrapper around lettre + axum, but it's a well-dressed one". +- **FR/EN switch** — the site is EN-first for reach. Internal docs + CLAUDE.md + commit messages stay FR when natural. + +### 1.4 What we are NOT +- Not a SaaS. No pricing page with tiers. No "enterprise" upsell. +- Not a full marketing suite. No campaigns, no segmentation, no drag-and-drop editor. +- Not trying to replace Postmark / Resend for everyone — trying to be the right tool for devs who want to **own** their mail stack. + +--- + +## 2. Color tokens + +All tokens exposed as CSS custom properties on `:root`. Dark mode flips via `[data-theme="dark"]`. + +### 2.1 Brand scale + +| Token | Light | Dark | Contrast target | +|-------|-------|------|-----------------| +| `--brand-primary` | `#2D5BFF` | `#5B82FF` | WCAG AA on paper + ink | +| `--brand-primary-hover` | `#1E43D9` | `#7A9CFF` | — | +| `--brand-primary-muted` | `#E7EDFF` | `#1A2449` | bg for subtle highlights | +| `--brand-accent` | `#FF8A3D` | `#FFA466` | highlights, never body text | +| `--brand-accent-muted` | `#FFF0E5` | `#3D2414` | bg for callouts | + +### 2.2 Neutral scale (ink → paper) + +| Token | Light | Dark | +|-------|-------|------| +| `--ink-900` | `#0B1020` | `#F8FAFC` | +| `--ink-700` | `#1F2937` | `#E2E8F0` | +| `--ink-500` | `#475569` | `#94A3B8` | +| `--ink-300` | `#94A3B8` | `#475569` | +| `--ink-100` | `#E2E8F0` | `#1F2937` | +| `--paper` | `#F8FAFC` | `#0B1020` | +| `--paper-raised` | `#FFFFFF` | `#111832` | +| `--border` | `#E2E8F0` | `#1F2937` | + +### 2.3 Semantic tokens (role, not color) + +| Token | Maps to | +|-------|---------| +| `--text` | `--ink-900` | +| `--text-muted` | `--ink-500` | +| `--link` | `--brand-primary` | +| `--bg` | `--paper` | +| `--bg-raised` | `--paper-raised` | +| `--success` | `#10B981` | +| `--warning` | `#F59E0B` | +| `--danger` | `#EF4444` | +| `--info` | `--brand-primary` | + +**Rule:** components always reference *semantic* tokens, never brand/neutral tokens directly. Easier to re-theme. + +### 2.4 Email-template palette +The mail templates have their own theme object (`cfg.theme.colors`) injected at render time. The **default** theme ships with these same brand values, so out-of-the-box a Mailify install sends mails in Mailify's own brand. Users override per install. + +--- + +## 3. Typography + +### 3.1 Typefaces + +| Role | Family | Weights | Fallback | +|------|--------|---------|----------| +| Display | **Geist Sans** | 500, 600, 700 | Inter, -apple-system, system-ui | +| Body | **Inter** | 400, 500 | -apple-system, Segoe UI, Roboto | +| Mono | **Geist Mono** | 400, 500 | JetBrains Mono, Menlo, Consolas, monospace | + +Self-host via `@fontsource/*` packages (no Google Fonts request → better SEO + privacy + Lighthouse). + +### 3.2 Scale (modular, ratio 1.250 "major third") + +| Token | Size | Line-height | Use | +|-------|------|-------------|-----| +| `--fs-xs` | 0.75rem / 12px | 1.5 | captions, labels | +| `--fs-sm` | 0.875rem / 14px | 1.5 | body small, UI controls | +| `--fs-base` | 1rem / 16px | 1.65 | body default | +| `--fs-md` | 1.125rem / 18px | 1.6 | lede paragraphs | +| `--fs-lg` | 1.5rem / 24px | 1.3 | h3, section intros | +| `--fs-xl` | 1.875rem / 30px | 1.25 | h2 | +| `--fs-2xl` | 2.5rem / 40px | 1.15 | h1 docs | +| `--fs-3xl` | 3.5rem / 56px | 1.05 | hero landing | +| `--fs-4xl` | 4.5rem / 72px | 1 | mega hero (desktop only) | + +### 3.3 Headings +- `h1` hero landing: `--fs-3xl` Geist Sans 700, tracking `-0.03em`. +- `h1` doc page: `--fs-2xl` Geist Sans 600, tracking `-0.02em`. +- `h2`..`h4` docs: Geist Sans 600, tracking `-0.01em`. +- Body: Inter 400, no tracking adjustment. +- **Always semibold, never bold** for UI surfaces (Geist is already heavy at 600). + +### 3.4 Mono rules +- Inline code: `--fs-[0.925em]` relative to parent + `--brand-primary-muted` background + 4px horizontal padding + 4px radius. +- Code blocks: `--fs-sm`, full width, no inline background on tokens, `--paper-raised` bg, border `1px --border`, radius `--radius-md`. + +--- + +## 4. Space & layout + +### 4.1 Spacing scale (4px base) + +| Token | Value | +|-------|-------| +| `--space-1` | 4px | +| `--space-2` | 8px | +| `--space-3` | 12px | +| `--space-4` | 16px | +| `--space-6` | 24px | +| `--space-8` | 32px | +| `--space-12` | 48px | +| `--space-16` | 64px | +| `--space-24` | 96px | +| `--space-32` | 128px | + +### 4.2 Radii + +| Token | Value | Use | +|-------|-------|-----| +| `--radius-sm` | 4px | inline code, badges | +| `--radius-md` | 8px | buttons, inputs, cards | +| `--radius-lg` | 12px | larger cards, modals | +| `--radius-full` | 9999px | pills, avatars | + +### 4.3 Elevation (shadows) + +| Token | Value | +|-------|-------| +| `--shadow-sm` | `0 1px 2px rgba(11,16,32,0.06)` | +| `--shadow-md` | `0 4px 12px rgba(11,16,32,0.08)` | +| `--shadow-lg` | `0 12px 32px rgba(11,16,32,0.12)` | + +Dark mode: reduce alpha by half and switch to `rgba(0,0,0,...)`. + +### 4.4 Container widths + +| Token | Value | Use | +|-------|-------|-----| +| `--container-prose` | 720px | doc article max-width | +| `--container-main` | 1120px | landing sections | +| `--container-wide` | 1280px | full-width marketing blocks | + +### 4.5 Breakpoints + +| Token | Value | +|-------|-------| +| `--bp-sm` | 640px | +| `--bp-md` | 768px | +| `--bp-lg` | 1024px | +| `--bp-xl` | 1280px | + +Mobile-first everywhere. + +--- + +## 5. Components — visual rules + +### 5.1 Button + +| Variant | Bg | Text | Border | Hover | +|---------|-----|------|--------|-------| +| primary | `--brand-primary` | white | none | `--brand-primary-hover` | +| secondary | `--paper-raised` | `--text` | `1px --border` | `--ink-100` bg | +| ghost | transparent | `--link` | none | `--brand-primary-muted` bg | +| danger | `--danger` | white | none | darker red | + +- Padding: `--space-3 --space-6`. +- Radius: `--radius-md`. +- Font: Geist Sans 500, `--fs-sm`. +- Focus ring: `2px solid --brand-primary` + `2px` offset. +- No shadow by default on buttons — keep them flat. + +### 5.2 Code block +- Header row (optional): filename left, language badge right, copy button far right. +- Line numbers optional, off by default (on for install/config snippets > 5 lines). +- Syntax theme: **Shiki** with dual-theme support — `github-light` / `github-dark`, matched to our `[data-theme]`. +- Copy button on hover top-right, icon `clipboard` from Lucide. + +### 5.3 Callouts / admonitions (for docs) +Four variants, all share left border 3px + tinted bg + icon: + +| Kind | Border | Bg | Icon | +|------|--------|-----|------| +| note | `--brand-primary` | `--brand-primary-muted` | info circle | +| tip | `--success` | green-muted | lightbulb | +| warning | `--warning` | amber-muted | triangle | +| danger | `--danger` | red-muted | octagon | + +### 5.4 Cards (features on landing) +- `--paper-raised` bg, `1px --border`, `--radius-lg`, padding `--space-6`. +- No shadow at rest; `--shadow-md` on hover with `translateY(-2px)` transition 150ms ease-out. +- Icon top-left, 32px, colored `--brand-primary`. +- Title Geist Sans 600 `--fs-md`, body Inter 400 `--fs-sm` `--text-muted`. + +### 5.5 Navigation +- Top bar 64px, sticky, `--paper` bg with blur `backdrop-filter: blur(12px)` and 80% alpha when scrolled. +- Logo left (mark + wordmark), nav links center (Docs, GitHub, Sponsors), CTA right (primary button "Get started"). +- Sidebar docs: 280px wide, sticky, collapsible groups. Active item = left border 2px `--brand-primary` + `--brand-primary-muted` bg. + +### 5.6 Footer +- 3 columns on desktop, stacks on mobile. +- Columns: *Product* (Docs, Changelog, Roadmap), *Project* (GitHub, Sponsors, Discussions), *Community* (Twitter/X, RSS). +- Bottom row: logo + copyright + "made in Rust" badge + theme toggle. + +--- + +## 6. Iconography + +- **Lucide Icons** (open source, tree-shakeable, matches our geometric feel). +- Stroke width: 1.75 default, 2 for small sizes. +- Size tokens: `--icon-sm` 16px, `--icon-md` 20px, `--icon-lg` 24px, `--icon-xl` 32px. +- Never fill icons — always stroke. +- Use sparingly in body text; more generously in UI chrome. + +--- + +## 7. Imagery & illustration + +### 7.1 Landing illustrations +- Abstract, geometric, flat vector — same language as the logo. +- Palette restricted to brand tokens; no external colors. +- No stock photography, no 3D, no AI-gen "corporate glow". +- One signature illustration on the hero; smaller spots on feature sections. + +### 7.2 OG / social cards +- 1200×630, generated per page via Astro's `@vercel/og`-equivalent (`satori` + `astro-og-canvas` or similar). +- Template: dark `--ink-900` bg, big wordmark top-left, page title center-left Geist 600 72px white, subtitle `--ink-300` 32px below, accent diagonal stripe `--brand-primary` bottom-right. +- One shared template, dynamic title from page frontmatter. + +### 7.3 Architecture diagrams +- **Excalidraw** hand-drawn style OR clean SVG with brand palette — pick one and stay consistent. +- Export as SVG, never PNG, so they scale and theme-invert cleanly. + +--- + +## 8. Motion + +- **Default transition:** `150ms ease-out` for color/opacity/transform. +- **Page transitions:** none (SSG, hard reloads are fine). +- **View transitions API** enabled via Astro for same-origin nav (smooth fade between docs pages). +- **Scroll reveal:** subtle `opacity 0 → 1` + `translateY(12px → 0)` on landing feature cards, via `Intersection Observer`, once per element. +- **Respect `prefers-reduced-motion`** — disable all non-essential motion. +- **No auto-playing anything.** No carousels that move on their own. + +--- + +## 9. Accessibility + +Non-negotiable baseline: + +- **WCAG 2.1 AA** contrast on all text (verified via Lighthouse CI). +- **Keyboard nav** — every interactive element reachable via Tab, visible focus ring always. +- **Skip-to-content** link on every page (first Tab stop). +- **Semantic HTML** — `